diff --git a/CHANGELOG.md b/CHANGELOG.md index 583a79cd82..c725b0f69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01) + + +### Bug Fixes + +* **api:** handle multi-version scripts when removing granular ACL ([#9388](https://github.com/windmill-labs/windmill/issues/9388)) ([9d9c503](https://github.com/windmill-labs/windmill/commit/9d9c5038ce8b0016320a670c434ef9063cb40441)) + +## [1.713.0](https://github.com/windmill-labs/windmill/compare/v1.712.0...v1.713.0) (2026-05-31) + + +### Features + +* **flows:** preserve step/subflow worker tags under a custom-tagged flow ([#9375](https://github.com/windmill-labs/windmill/issues/9375)) ([f0301b1](https://github.com/windmill-labs/windmill/commit/f0301b1605cee5fba4024803555333e6fa5c40ee)) +* **oauth:** support per-provider sandbox URLs ([#9358](https://github.com/windmill-labs/windmill/issues/9358)) ([2bf11dc](https://github.com/windmill-labs/windmill/commit/2bf11dcb15540c538ea2ac3cf70dcbe589060b4e)) + + +### Bug Fixes + +* **ai:** validate token_url for SSRF in OAuth credentials flow ([#9385](https://github.com/windmill-labs/windmill/issues/9385)) ([4b06881](https://github.com/windmill-labs/windmill/commit/4b06881918b76c5a411cc70b318e46efcc1393a7)) +* **api:** authorize and harden log-file reading endpoints ([#9368](https://github.com/windmill-labs/windmill/issues/9368)) ([bb90f4c](https://github.com/windmill-labs/windmill/commit/bb90f4ce83a0e60af219b11c12ab4fe1d13f47a4)) +* **apps:** make public apps opt into cross-origin isolation via wm_coep (GIT-884) ([#9374](https://github.com/windmill-labs/windmill/issues/9374)) ([2c0c2c4](https://github.com/windmill-labs/windmill/commit/2c0c2c467f163cd24c14c7be2db07af9cf2ce020)) +* **auth:** enforce monotonic privilege on user token lifecycle endpoints ([#9371](https://github.com/windmill-labs/windmill/issues/9371)) ([2ddf93d](https://github.com/windmill-labs/windmill/commit/2ddf93de96622b2a1b2b6f59398a7a1f59360efd)) +* batch encryption-key rotation into one git-sync job ([#9355](https://github.com/windmill-labs/windmill/issues/9355)) ([04a0897](https://github.com/windmill-labs/windmill/commit/04a08976aec4ba9b0516350316df303e9f96bfd3)) +* **cli:** preserve user drafts on sync push and permissioned-as ([#9381](https://github.com/windmill-labs/windmill/issues/9381)) ([b0c3b01](https://github.com/windmill-labs/windmill/commit/b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb)) +* **frontend:** sanitize user markdown to prevent stored XSS ([#9386](https://github.com/windmill-labs/windmill/issues/9386)) ([def01b8](https://github.com/windmill-labs/windmill/commit/def01b8ff6f331cc36ce02b947adc31c766042c4)) +* **security:** re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) ([#9387](https://github.com/windmill-labs/windmill/issues/9387)) ([edf340c](https://github.com/windmill-labs/windmill/commit/edf340c4d4f18b16b142cb7deb67afa586f10946)) + ## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28) diff --git a/ai_evals/README.md b/ai_evals/README.md index 464d235a07..d5fe1661a2 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record -GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro +GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview bun run cli -- run global global-test1-script-create bun run cli -- run cli bun-hello-script @@ -89,8 +89,6 @@ Today: - `opus` - `4o` - `gpt-5.5` -- `gemini-flash` -- `gemini-pro` - `gemini-3-flash-preview` - `gemini-3.1-pro-preview` - `deepseek-v4-flash` diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts index 819300bd62..01a55e048e 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts @@ -21,9 +21,9 @@ describe("proxy helpers", () => { describe("resolveEvalModelProvider", () => { it("infers googleai from Gemini model ids", () => { - expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({ + expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({ provider: "googleai", - model: "gemini-2.5-flash", + model: "gemini-3-flash-preview", }); }); @@ -35,9 +35,11 @@ describe("resolveEvalModelProvider", () => { }); it("preserves an explicit provider", () => { - expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({ + expect( + resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"), + ).toEqual({ provider: "googleai", - model: "gemini-2.5-pro", + model: "gemini-3.1-pro-preview", }); }); }); diff --git a/ai_evals/cases/flow.yaml b/ai_evals/cases/flow.yaml index af7added38..5f4abafc48 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -8,6 +8,9 @@ args: a: 4 b: 5 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the flow takes `a` and `b` as inputs" - "the main step is named `sum_numbers`" @@ -25,6 +28,9 @@ args: a: 2 b: 3 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the flow takes `a` and `b` as inputs" - "the main step is named `sum_numbers`" @@ -42,6 +48,9 @@ args: a: 7 b: 8 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the parent flow takes `a` and `b` as inputs" - "the main step is named `call_add_numbers`" @@ -426,6 +435,7 @@ - return_schedule_status toolExpect: requiredToolsUsed: + - test_run_flow - create_schedule toolCallArgs: - tool: create_schedule @@ -453,6 +463,7 @@ - webhook_response toolExpect: requiredToolsUsed: + - test_run_flow - create_trigger toolCallArgs: - tool: create_trigger diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 36238e8ccd..732ca7f2e9 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -430,3 +430,191 @@ judgeChecklist: - asks which script to update when the user refers to "this script" without selected or active editor context - does not guess a path or create a new script draft + +- id: global-test15-human-postgres-resource + prompt: |- + I'm wiring the eval reporting database into this workspace. + Can you stage a Postgres connection for it in the shared evals/global folder? + Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`. + Keep the credentials safe. + This is just draft work for now. + runtime: + maxTurns: 10 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + - password + valueIncludes: + - "true" + - report + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + valueIncludes: + - postgres + - reports-db.internal + - "5432" + - evals_reporting + - report_reader + - "$var:" + valueExcludes: + - pg-redacted-reporting-password + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - pg-redacted-reporting-password + skipJudge: true + judgeChecklist: + - creates a Postgres resource draft for the eval reporting database + - creates a secret variable draft for the database password + - puts the drafts in sensible eval/global reporting-related paths + - uses the requested host, port, database, and user + - references the secret variable from the resource instead of embedding the password + - leaves the work as a draft only + +- id: global-test16-human-visible-variable + prompt: |- + We keep reusing a 30 day trial cutoff in eval notification jobs. + Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls? + It is not a secret. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - trial + valueIncludes: + - "30" + - "false" + - trial + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates exactly one non-secret variable draft for the trial cutoff + - stores the value 30 + - chooses a sensible eval/global path related to trials or notifications + - includes a useful description of what the value controls + - does not create resources, schedules, triggers, or deployed workspace changes + +- id: global-test17-human-schedule-existing-helper + prompt: |- + The workspace already has a report digest helper. + Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on? + I only want the schedule draft for review. + initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: schedule + pathIncludes: + - digest + valueIncludes: + - f/evals/global/send_report_digest + - UTC + - dry_run + - "true" + toolExpect: + requiredToolsUsed: + - list_workspace_items + - write_schedule + forbiddenToolsUsed: + - write_script + - write_flow + - write_resource + - write_variable + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - finds the existing report digest helper rather than creating a new script or flow + - creates one schedule draft for that helper + - schedules it for weekdays around 08:30 UTC + - passes dry_run as true + - leaves only the schedule draft for review + +- id: global-test18-human-slack-resource-with-secret + prompt: |- + I'm preparing Slack notifications for eval failures. + Can you stage a Slack connection in the shared evals/global folder? + The bot token is `xoxb-redacted-test-token`; keep it safe. + Don't deploy anything yet. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + - token + valueIncludes: + - "true" + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + valueIncludes: + - slack + - "$var:" + valueExcludes: + - xoxb-redacted-test-token + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates a secret variable draft for the Slack bot token placeholder + - creates a Slack resource draft that references the secret variable instead of embedding the token + - keeps both drafts under a sensible eval/global Slack-related path + - does not create schedules, triggers, or deployed workspace changes diff --git a/ai_evals/cases/script.yaml b/ai_evals/cases/script.yaml index feae74dcda..f0910e939a 100644 --- a/ai_evals/cases/script.yaml +++ b/ai_evals/cases/script.yaml @@ -5,6 +5,9 @@ Keep it simple and do not add external dependencies. initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json + toolExpect: + requiredToolsUsed: + - test_run_script judgeChecklist: - uses the existing `name` input - returns a plain greeting string @@ -20,6 +23,7 @@ expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json toolExpect: requiredToolsUsed: + - test_run_script - create_schedule toolCallArgs: - tool: create_schedule @@ -44,6 +48,7 @@ expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json toolExpect: requiredToolsUsed: + - test_run_script - create_trigger toolCallArgs: - tool: create_trigger diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 526ea7c70f..05e2f1527b 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -14,6 +14,21 @@ describe("loadCases", () => { }, }, }); + expect(caseEntry?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_flow"], + }); + }); + + it("loads script and flow test tool expectations", async () => { + const scriptCases = await loadCases("script"); + const flowCases = await loadCases("flow"); + + expect(scriptCases.find((entry) => entry.id === "script-test1-greet-user")?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_script"], + }); + expect(flowCases.find((entry) => entry.id === "flow-test0-sum-two-numbers")?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_flow"], + }); }); it("loads the workspace-flow preference benchmark case", async () => { @@ -238,7 +253,7 @@ describe("loadCases", () => { ); expect(caseEntry?.toolExpect).toEqual({ - requiredToolsUsed: ["create_schedule"], + requiredToolsUsed: ["test_run_script", "create_schedule"], toolCallArgs: [ { tool: "create_schedule", diff --git a/ai_evals/core/models.test.ts b/ai_evals/core/models.test.ts index 986d9cd7cd..ba53c24592 100644 --- a/ai_evals/core/models.test.ts +++ b/ai_evals/core/models.test.ts @@ -18,14 +18,6 @@ describe("resolveEvalModel", () => { }); it("supports Gemini aliases for frontend evals", () => { - expect(resolveEvalModel("flow", "gemini").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-flash", - }); - expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-pro", - }); expect( resolveEvalModel("script", "gemini-3-flash-preview").frontend, ).toEqual({ @@ -52,8 +44,8 @@ describe("resolveEvalModel", () => { }); it("rejects Gemini aliases for cli evals", () => { - expect(() => resolveEvalModel("cli", "gemini")).toThrow( - "Model gemini-flash is not supported for cli mode", + expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow( + "Model gemini-3-flash-preview is not supported for cli mode", ); }); }); diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 0e18037f82..295cd36135 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -96,24 +96,6 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-5.5", }, }, - { - id: "gemini-flash", - label: "Gemini 2.5 Flash", - aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"], - frontend: { - provider: "googleai", - model: "gemini-2.5-flash", - }, - }, - { - id: "gemini-pro", - label: "Gemini 2.5 Pro", - aliases: ["gemini-pro", "gemini-2.5-pro"], - frontend: { - provider: "googleai", - model: "gemini-2.5-pro", - }, - }, { id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview", diff --git a/ai_evals/fixtures/frontend/global/initial/report_digest_script.json b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json new file mode 100644 index 0000000000..832b06712f --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json @@ -0,0 +1,23 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/send_report_digest", + "summary": "Build and send the eval report digest", + "description": "Returns a dry-run summary for eval report digest notifications.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "required": ["dry_run"] + }, + "content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n" + } + ] + } +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 37fa905cb0..079944ac88 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1141,7 +1141,7 @@ dependencies = [ "http 1.4.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", @@ -1335,7 +1335,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "itoa", "matchit 0.8.4", @@ -1531,7 +1531,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -1551,7 +1551,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -1684,7 +1684,7 @@ dependencies = [ "hex", "http 1.4.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -2056,14 +2056,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -3599,7 +3599,7 @@ dependencies = [ "hickory-resolver", "http 1.4.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "ipnet", @@ -3802,7 +3802,7 @@ dependencies = [ "deno_error 0.6.1", "deno_tls", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "log", @@ -5643,7 +5643,7 @@ dependencies = [ "futures", "http 1.4.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-rustls 0.26.0", "hyper-tls", "hyper-tungstenite", @@ -5693,9 +5693,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -5723,7 +5723,7 @@ dependencies = [ "futures-util", "headers", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -5743,7 +5743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -5775,7 +5775,7 @@ checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "log", "rustls 0.22.4", @@ -5793,7 +5793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "log", "rustls 0.23.35", @@ -5810,7 +5810,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -5825,7 +5825,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "native-tls", "tokio", @@ -5840,7 +5840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" dependencies = [ "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -5860,7 +5860,7 @@ dependencies = [ "futures-util", "http 1.4.1", "http-body 1.0.1", - "hyper 1.10.0", + "hyper 1.10.1", "ipnet", "libc", "percent-encoding", @@ -5881,7 +5881,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -6400,7 +6400,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -6595,9 +6595,9 @@ dependencies = [ [[package]] name = "libgssapi-sys" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7518e6902e94f92e7c7271232684b60988b4bd813529b4ef9d97aead96956ae8" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" dependencies = [ "bindgen 0.71.1", "pkg-config", @@ -6675,9 +6675,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -7714,7 +7714,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper 1.10.0", + "hyper 1.10.1", "itertools 0.14.0", "md-5 0.10.6", "parking_lot", @@ -9420,7 +9420,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -9468,7 +9468,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -9524,7 +9524,7 @@ dependencies = [ "futures", "getrandom 0.2.17", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "reqwest 0.13.1", "reqwest-middleware", "retry-policies", @@ -10205,15 +10205,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -10307,12 +10298,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "seahash" version = "4.1.0" @@ -10630,24 +10615,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -10723,6 +10707,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -12418,9 +12408,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -12601,7 +12591,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12633,7 +12623,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.10.0", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", @@ -13004,9 +12994,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" @@ -13336,9 +13326,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -13774,7 +13764,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-nats", @@ -13855,7 +13845,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.712.0" +version = "1.713.1" dependencies = [ "async-stream", "async-trait", @@ -13888,7 +13878,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -13901,7 +13891,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "argon2", @@ -13931,7 +13921,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14039,12 +14029,12 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "lazy_static", "quick_cache", "serde", @@ -14062,7 +14052,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14075,7 +14065,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14101,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.712.0" +version = "1.713.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14111,7 +14101,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14128,7 +14118,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14150,7 +14140,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14173,7 +14163,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14189,11 +14179,11 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", - "hyper 1.10.0", + "hyper 1.10.1", "serde", "serde_json", "sql-builder", @@ -14210,7 +14200,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14231,7 +14221,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14245,7 +14235,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-nats", @@ -14277,14 +14267,14 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "lazy_static", "serde", "serde_json", @@ -14302,7 +14292,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "flate2", @@ -14320,7 +14310,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14342,7 +14332,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14362,13 +14352,13 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", "futures", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "quick_cache", @@ -14392,7 +14382,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14420,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.712.0" +version = "1.713.1" dependencies = [ "lazy_static", "serde", @@ -14432,14 +14422,14 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.712.0" +version = "1.713.1" dependencies = [ "argon2", "axum 0.8.9", "chrono", "dashmap", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "lazy_static", "serde", "serde_json", @@ -14457,7 +14447,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14471,13 +14461,13 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.712.0" +version = "1.713.1" dependencies = [ "axum 0.8.9", "chrono", "hex", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "lazy_static", "magic-crypt", "regex", @@ -14504,7 +14494,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.712.0" +version = "1.713.1" dependencies = [ "chrono", "lazy_static", @@ -14518,7 +14508,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14537,7 +14527,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.712.0" +version = "1.713.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14573,7 +14563,7 @@ dependencies = [ "globset", "hex", "hmac", - "hyper 1.10.0", + "hyper 1.10.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14638,7 +14628,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.712.0" +version = "1.713.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14657,7 +14647,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.712.0" +version = "1.713.1" dependencies = [ "regex", "serde", @@ -14672,7 +14662,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14696,7 +14686,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "futures", @@ -14713,7 +14703,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.712.0" +version = "1.713.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14729,7 +14719,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -14750,7 +14740,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -14781,7 +14771,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "arc-swap", @@ -14806,7 +14796,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-stream", @@ -14840,7 +14830,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "futures", @@ -14858,7 +14848,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.712.0" +version = "1.713.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14867,7 +14857,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -14879,7 +14869,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde_json", @@ -14891,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "gosyn", @@ -14903,7 +14893,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -14915,7 +14905,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde_json", @@ -14927,7 +14917,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "nu-parser", @@ -14938,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14949,7 +14939,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14961,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14972,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-recursion", @@ -14994,7 +14984,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde_json", @@ -15006,7 +14996,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -15020,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15037,7 +15027,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -15050,7 +15040,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde", @@ -15062,7 +15052,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -15080,7 +15070,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15096,7 +15086,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15112,7 +15102,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde", @@ -15123,7 +15113,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-recursion", @@ -15161,7 +15151,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "const_format", @@ -15199,7 +15189,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.712.0" +version = "1.713.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15210,7 +15200,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-recursion", @@ -15218,7 +15208,7 @@ dependencies = [ "chrono", "futures", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "lazy_static", "quick_cache", "reqwest 0.13.1", @@ -15240,7 +15230,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15264,14 +15254,14 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "rand 0.9.0", @@ -15297,7 +15287,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15330,7 +15320,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15350,7 +15340,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15384,7 +15374,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15396,7 +15386,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.10.0", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "matchit 0.7.3", @@ -15420,7 +15410,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15443,7 +15433,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15467,7 +15457,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-nats", @@ -15491,7 +15481,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15526,7 +15516,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15554,7 +15544,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-trait", @@ -15579,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15598,7 +15588,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-once-cell", @@ -15708,7 +15698,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.712.0" +version = "1.713.1" dependencies = [ "bytes", "futures", @@ -16522,18 +16512,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a4950571f2..9c15e1341b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.712.0" +version = "1.713.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.712.0" +version = "1.713.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 45bb54e258..4e1f516570 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -08e3b9b818f9f1b439e9a6ba477fd5fdbcab944e +3742e0659c5e97aab03b9efeea14cd94a3ac658a diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 06f936e955..94b979af53 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.712.0" +version = "1.713.1" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.712.0" +version = "1.713.1" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.712.0" +version = "1.713.1" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.712.0" +version = "1.713.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 2b0e060503..e2bb2affad 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.712.0" +version = "1.713.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index 87285e2ae1..29bea050ed 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -258,6 +258,15 @@ pub fn main() -> anyhow::Result<()> { } async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { + // The `cache` CLI mode never connects to the DB, so HUB_BASE_URL keeps its + // compiled default. Allow overriding it via env so the prebuild cache step can + // be pointed at a private/staging hub (e.g. a local proxy for testing). + if let Ok(hub_base_url) = std::env::var("HUB_BASE_URL") { + if !hub_base_url.is_empty() { + tracing::info!("Overriding hub base url from env: {hub_base_url}"); + windmill_common::HUB_BASE_URL.store(std::sync::Arc::new(hub_base_url)); + } + } let file_path = file_path.unwrap_or("./hubPaths.json".to_string()); let mut file = File::open(&file_path) .await @@ -567,6 +576,7 @@ fn print_help() { println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup"); println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool"); println!(" SYNC_CACHED_RT = false Sync cached resource types to admins workspace on server start"); + println!(" HUB_BASE_URL = https://hub.windmill.dev Hub to fetch scripts from in `cache` mode (server/worker use the DB setting instead)"); println!(); println!("Notes:"); println!("- Advanced and less commonly used settings are managed via the database and are omitted here."); diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index d6d49c8d85..48a50ef9b9 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -451,6 +451,7 @@ def main(): preserve_on_behalf_of: None, ws_error_handler_muted: None, labels: None, + skip_draft_deletion: None, }) .send() .await @@ -513,6 +514,7 @@ def main(): custom_path: None, preserve_on_behalf_of: None, labels: None, + skip_draft_deletion: None, }) .send() .await diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index fb29454ca5..52d04910de 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -20,7 +20,7 @@ where lazy_static::lazy_static! { static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") + pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") .ok() .map(|v| v == "true" || v == "1") .unwrap_or(false); diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 99fa677cee..734753a0e0 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -235,6 +235,202 @@ where Ok(()) } +/// Returns the caller's "real" scope restrictions: every scope other than +/// `if_jobs:filter_tags:` tag filters. `None` means the token is unscoped and +/// has the full privileges of its user; `Some` means it is restricted to the +/// returned scopes. An empty or filter-tags-only scope list is treated as +/// unscoped, mirroring `check_scopes`/`check_route_access`. +fn scope_restrictions(scopes: Option<&[String]>) -> Option> { + let restrictions: Vec<&String> = scopes? + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + .collect(); + (!restrictions.is_empty()).then_some(restrictions) +} + +/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes +/// a credential on behalf of `authed`: the resulting credential must never be +/// more privileged than the caller's own token. +/// +/// - An unscoped caller may grant any scopes (this is the existing UI/CLI flow). +/// - A scope-restricted caller may only grant scopes that are a subset of its +/// own, and may never produce an unscoped credential. +/// +/// Without this, a `users:write` token could create or rescope a token to be +/// unscoped, and a `users:read` token could refresh into an unscoped session — +/// escaping its own restrictions. +pub fn ensure_scopes_within_caller( + authed: &ApiAuthed, + requested_scopes: Option<&[String]>, +) -> error::Result<()> { + if let Some(caller_restrictions) = scope_restrictions(authed.scopes.as_deref()) { + let Some(requested_restrictions) = scope_restrictions(requested_scopes) else { + return Err(Error::PermissionDenied( + "A scope-restricted token cannot create or update a token with broader (unscoped) \ + privileges" + .to_string(), + )); + }; + + // MCP scopes (`mcp:all`, `mcp:favorites`, `mcp:scripts:*`, etc.) use a + // custom format that ScopeDefinition::from_scope_string parses + // permissively but the MCP runtime interprets via its own parser + // (parse_mcp_scopes). The two views disagree — e.g. the generic parser + // accepts `mcp:scripts` as an unrestricted-resource scope, while the + // MCP runtime ignores it as unrecognized but interprets `mcp:scripts:*` + // as granting all scripts. So generic containment would silently allow + // `mcp:scripts` → `mcp:scripts:*` (a widening). Legitimate MCP token + // issuance goes through the OAuth gateway (mcp/oauth_server.rs), not + // these user-token endpoints, so require byte-identical match for MCP + // scopes here rather than trying to mirror MCP semantics in two places. + // Unparseable non-MCP caller scopes are intentionally dropped + // (fail-closed): a caller scope that fails to parse can only narrow + // the set of requested scopes that get covered, never widen it. + // Unparseable requested scopes surface as `BadRequest`, which is what + // we want — the client is sending garbage. + let parsed_caller: Vec = caller_restrictions + .iter() + .filter(|s| !s.starts_with("mcp:")) + .filter_map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect(); + let caller_mcp: std::collections::HashSet<&str> = caller_restrictions + .iter() + .filter(|s| s.starts_with("mcp:")) + .map(|s| s.as_str()) + .collect(); + + for requested in requested_restrictions { + if requested.starts_with("mcp:") { + if !caller_mcp.contains(requested.as_str()) { + return Err(Error::PermissionDenied(format!( + "A scope-restricted token cannot grant MCP scope '{requested}' unless the \ + caller holds the same scope verbatim" + ))); + } + continue; + } + let requested_scope = ScopeDefinition::from_scope_string(requested)?; + let covered = parsed_caller + .iter() + .any(|caller_scope| scope_contains(caller_scope, &requested_scope)); + if !covered { + return Err(Error::PermissionDenied(format!( + "A scope-restricted token cannot grant scope '{requested}' which exceeds its \ + own scopes" + ))); + } + } + } + + // `if_jobs:filter_tags:` fences which job tags a token can run on (enforced + // at job operations as `v2_job.tag = ANY(...)`), and is checked independently + // of domain/action/resource subset. A caller restricted by filter_tags must + // not be able to mint or rescope a credential that drops or widens the fence + // — even if the caller has no other scope restrictions (filter_tags-only + // tokens otherwise look "unscoped" to `scope_restrictions`). + if let Some(caller_tags) = first_filter_tags(authed.scopes.as_deref()) { + let Some(requested_tags) = first_filter_tags(requested_scopes) else { + return Err(Error::PermissionDenied( + "A token restricted by if_jobs:filter_tags cannot mint or rescope a token that \ + drops the tag restriction" + .to_string(), + )); + }; + let caller_set: std::collections::HashSet<&str> = caller_tags.iter().copied().collect(); + for tag in &requested_tags { + if !caller_set.contains(tag) { + return Err(Error::PermissionDenied(format!( + "A token restricted by if_jobs:filter_tags cannot grant tag '{tag}' which is \ + not within its own filter_tags" + ))); + } + } + } + + Ok(()) +} + +/// Tags from the first `if_jobs:filter_tags:` scope, matching the +/// semantics of [`get_scope_tags`] (which is what the job runtime consults). +/// Returns `None` if no such scope is present. +fn first_filter_tags(scopes: Option<&[String]>) -> Option> { + scopes?.iter().find_map(|s| { + s.strip_prefix("if_jobs:filter_tags:") + .map(|tags| tags.split(',').collect()) + }) +} + +/// Whether `caller` grants at least everything `requested` grants (directional +/// containment). +/// +/// This is intentionally NOT `ScopeDefinition::includes`: that method answers +/// "does this scope grant access to a required action" using OR semantics over +/// resources (any overlap counts, and a `*` on either side matches), which is +/// correct for access checks but unsafe for subset checks — it would let a +/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or +/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that +/// EVERY requested resource is covered by SOME caller resource. +fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { + if caller.domain != requested.domain { + return false; + } + + // write subsumes read; otherwise the action must match exactly. + match (caller.action.as_str(), requested.action.as_str()) { + (c, r) if c == r || (c == "write" && r == "read") => {} + _ => return false, + } + + if caller.domain == "jobs" && caller.action == "run" { + match (&caller.kind, &requested.kind) { + (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { + return false + } + // Caller pinned to a kind, but the request covers any kind. + (Some(_), None) => return false, + _ => {} + } + } + + match (&caller.resource, &requested.resource) { + // Caller is unrestricted on resources: covers everything. + (None, _) => true, + // Caller is resource-restricted but the request is not: broader. + (Some(_), None) => false, + (Some(caller_resources), Some(requested_resources)) => { + resource_set_contains(caller_resources, requested_resources) + } + } +} + +/// Every resource in `requested` must be covered by some resource in `caller`. +fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { + if caller.iter().any(|r| r == "*") { + return true; + } + requested + .iter() + .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) +} + +/// Directional: does the single caller resource pattern cover `requested`? +/// `caller` may be an exact path or a `/*` subtree wildcard; `requested` +/// may itself be a subtree wildcard, in which case the whole requested subtree +/// must fall within the caller's subtree. +fn resource_covers(caller: &str, requested: &str) -> bool { + if caller == requested { + return true; + } + let Some(prefix) = caller.strip_suffix("/*") else { + // An exact caller resource only covers itself (handled above). + return false; + }; + let requested_base = requested.strip_suffix("/*").unwrap_or(requested); + requested_base == prefix + || (requested_base.starts_with(prefix) + && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) +} + /// Returns a predicate that checks whether `path` is within the token's /// scope for `{domain}:{action}:{path}`. For tokens without scope /// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes), @@ -574,6 +770,13 @@ impl NewToken { } } +/// Low-level token mint shared by trusted callers (the user-facing +/// `tokens/create` handler and internal mints such as native-trigger webhook +/// tokens). It does NOT enforce that `token_config.scopes` is within the +/// caller's own scopes — callers exposed to untrusted input must call +/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally +/// skip it, since their scopes derive from the action being authorized, not the +/// caller's token). pub async fn create_token_internal( tx: &mut sqlx::PgConnection, db: &DB, @@ -908,4 +1111,278 @@ mod tests { assert!(allowed("u/alice/foo")); assert!(!allowed("u/alice/bar")); } + + fn opt_scopes(scopes: Option>) -> Option> { + scopes.map(|v| v.into_iter().map(String::from).collect()) + } + + // Regression tests for WIN-1999: scoped user tokens must not be able to + // mint or rescope credentials with broader privileges than themselves. + + #[test] + fn unscoped_caller_can_grant_anything() { + let authed = authed_with_scopes(None); + assert!(ensure_scopes_within_caller(&authed, None).is_ok()); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn filter_tags_only_caller_is_unrestricted_on_domain_action_dimension() { + // The domain/action/resource subset check treats filter-tags-only as + // unrestricted, mirroring check_scopes/check_route_access. The tag + // dimension is checked separately (see filter_tags_dimension_is_monotonic). + let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"])); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:write", "if_jobs:filter_tags:default"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn filter_tags_dimension_is_monotonic() { + // Caller restricted to tag fence "a" cannot drop the fence … + let single = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a"])); + assert!(ensure_scopes_within_caller(&single, None).is_err()); + assert!( + ensure_scopes_within_caller(&single, opt_scopes(Some(vec!["users:read"])).as_deref()) + .is_err(), + "minting a token without filter_tags must be rejected" + ); + // … cannot widen to a tag it lacks … + assert!(ensure_scopes_within_caller( + &single, + opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref() + ) + .is_err()); + // … and cannot mint a token fenced on a disjoint tag. + assert!(ensure_scopes_within_caller( + &single, + opt_scopes(Some(vec!["if_jobs:filter_tags:b"])).as_deref() + ) + .is_err()); + // Narrowing or matching the tag fence is allowed. + let multi = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a,b"])); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["if_jobs:filter_tags:a"])).as_deref() + ) + .is_ok()); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref() + ) + .is_ok()); + // A caller with a real scope plus a tag fence cannot drop just the fence. + let mixed = authed_with_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])); + assert!(ensure_scopes_within_caller( + &mixed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_err()); + assert!(ensure_scopes_within_caller( + &mixed, + opt_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])).as_deref() + ) + .is_ok()); + // An unrestricted caller may grant filter_tags freely. + let unscoped = authed_with_scopes(None); + assert!(ensure_scopes_within_caller( + &unscoped, + opt_scopes(Some(vec!["if_jobs:filter_tags:x"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn scoped_caller_cannot_mint_unscoped_token() { + // Primitive 2 in the report: a users:write token minting an unscoped token. + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller(&authed, None).is_err()); + // Empty scope list is effectively unscoped and must also be rejected. + assert!(ensure_scopes_within_caller(&authed, Some(&[])).is_err()); + // A scope list of only tag filters is effectively unscoped too. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["if_jobs:filter_tags:default"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_cannot_remove_its_own_scopes() { + // Primitive 3 in the report: a users:write token setting its scopes to null. + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller(&authed, None).is_err()); + } + + #[test] + fn scoped_caller_cannot_grant_scope_it_lacks() { + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_can_grant_subset_of_own_scopes() { + let authed = authed_with_scopes(Some(vec!["users:write", "jobs:run:scripts"])); + // Equal scope is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_ok()); + // write implies read, so a narrower read scope is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:read"])).as_deref() + ) + .is_ok()); + // Tag filters narrow further and are always permitted. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:read", "if_jobs:filter_tags:default"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn scoped_caller_cannot_broaden_resource_scope() { + let authed = authed_with_scopes(Some(vec!["scripts:read:f/team/*"])); + // Narrower resource within the subtree is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team/sub"])).as_deref() + ) + .is_ok()); + // A nested subtree within the caller's subtree is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team/sub/*"])).as_deref() + ) + .is_ok()); + // The subtree root itself is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team"])).as_deref() + ) + .is_ok()); + // A path outside the subtree is rejected. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/other/x"])).as_deref() + ) + .is_err()); + // read caller cannot grant write. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:write:f/team/db"])).as_deref() + ) + .is_err()); + } + + #[test] + fn mcp_scopes_require_byte_identical_match() { + // Regression for the access-grant-OR vs runtime-MCP-parser confusion: + // ScopeDefinition treats `mcp:scripts` as an unrestricted-resource scope + // and `mcp:scripts:*` as a strictly narrower one, so generic containment + // would silently allow widening. The MCP runtime however ignores + // `mcp:scripts` (unrecognized) while `mcp:scripts:*` grants all scripts. + // Legitimate MCP token issuance is the OAuth gateway, not these + // user-token endpoints, so MCP scopes must match the caller verbatim. + + // The bypass the reviewer flagged: malformed `mcp:scripts` would widen + // into the real `mcp:scripts:*` under generic containment. + let bypass = authed_with_scopes(Some(vec!["users:write", "mcp:scripts"])); + assert!(ensure_scopes_within_caller( + &bypass, + opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref() + ) + .is_err()); + + // A caller without any MCP scope cannot grant one (widening on the MCP + // dimension), even if the rest of the requested scopes are within reach. + let no_mcp = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller( + &no_mcp, + opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref() + ) + .is_err()); + + // Byte-identical MCP scope passes; an additional non-matching MCP scope + // alongside it does not. + let mcp_caller = authed_with_scopes(Some(vec!["mcp:scripts:*"])); + assert!(ensure_scopes_within_caller( + &mcp_caller, + opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref() + ) + .is_ok()); + assert!(ensure_scopes_within_caller( + &mcp_caller, + opt_scopes(Some(vec!["mcp:scripts:*", "mcp:flows:*"])).as_deref() + ) + .is_err()); + + // Even a narrowing within MCP semantics (`mcp:all` → `mcp:scripts:*`) + // is rejected by the byte-identical rule. This is intentional — these + // endpoints are not the legitimate path for narrowing MCP tokens. + let mcp_all = authed_with_scopes(Some(vec!["mcp:all"])); + assert!(ensure_scopes_within_caller( + &mcp_all, + opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_cannot_escalate_to_wildcard_or_superset() { + // Regression for the access-grant-OR vs subset-containment confusion: + // ScopeDefinition::includes would (incorrectly) allow all of these. + let star = authed_with_scopes(Some(vec!["scripts:read:f/team/a"])); + // Minting `*` from a single-path scope must be rejected. + assert!(ensure_scopes_within_caller( + &star, + opt_scopes(Some(vec!["scripts:read:*"])).as_deref() + ) + .is_err()); + // Minting a broader subtree must be rejected. + assert!(ensure_scopes_within_caller( + &star, + opt_scopes(Some(vec!["scripts:read:f/team/*"])).as_deref() + ) + .is_err()); + + // A comma-separated list that adds an uncovered resource must be rejected, + // even though one element overlaps the caller's scope. + let list = authed_with_scopes(Some(vec!["scripts:read:f/team/a"])); + assert!(ensure_scopes_within_caller( + &list, + opt_scopes(Some(vec!["scripts:read:f/team/a,f/other/b"])).as_deref() + ) + .is_err()); + + // A subset of a multi-resource caller scope is allowed. + let multi = authed_with_scopes(Some(vec!["scripts:read:f/team/a,f/team/b"])); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref() + ) + .is_ok()); + + // A wildcard caller covers any subset, but not `*`-less escalation rules apply + // only when the caller itself lacks `*`. + let wildcard = authed_with_scopes(Some(vec!["scripts:read:*"])); + assert!(ensure_scopes_within_caller( + &wildcard, + opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref() + ) + .is_ok()); + } } diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index f5d7b6cbe6..fb5eb4a880 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -551,13 +551,17 @@ async fn create_flow( w_id ).execute(&mut *tx).await?; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - nf.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + nf.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -1138,13 +1142,17 @@ async fn update_flow( })?; } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - flow_path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + flow_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -1956,6 +1964,7 @@ mod tests { })), preprocessor_module: None, same_worker: false, + preserve_step_tags: false, skip_expr: None, cache_ttl: None, cache_ignore_s3_path: None, diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index 3f0549f4e8..1f88c33e9f 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -383,6 +383,8 @@ async fn remove_granular_acl( // workspace export. let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. + // LIMIT 1: `script` shares (workspace_id, path) across versions, so `old` can + // return >1 row, which would break the scalar subquery in RETURNING. let obj_o = sqlx::query_scalar::<_, bool>(&format!( "WITH old AS ( SELECT extra_perms->$1 as old_write FROM {table} @@ -390,7 +392,7 @@ async fn remove_granular_acl( ) UPDATE {table} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 - RETURNING (SELECT old_write FROM old)::bool" + RETURNING (SELECT old_write FROM old LIMIT 1)::bool" )) .bind(&owner) .bind(path) diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 428b5f68e9..e05d2cb0ff 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -614,6 +614,9 @@ async fn is_noop_deploy_against_parent( // caller-intent flag (auto-resolve parent), not script state auto_parent: _, labels, + // caller-intent flag (preserve user drafts on CLI/git-sync deploys); + // transient, never persisted, does not change what the script *is* + skip_draft_deletion: _, } = ns; if path != &parent.path { @@ -802,6 +805,9 @@ async fn create_script_internal<'c>( } } let script_path = ns.path.clone(); + // Caller-intent: CLI / git-sync deploys ask us to preserve any existing + // user draft at this path instead of wiping it as part of the deploy. + let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false); let hash = ScriptHash(hash_script(&ns)); let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; @@ -1234,13 +1240,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - p_path, - &w_id - ) - .execute(&mut *tx) - .await?; + if !skip_draft_deletion { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } sqlx::query!( "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", @@ -1319,7 +1327,7 @@ async fn create_script_internal<'c>( tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; } } - } else { + } else if !skip_draft_deletion { sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", ns.path, diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 405efba4e5..95b9527a8e 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1964,7 +1964,8 @@ async fn login( windmill_common::login_rate_limit::record_login_failure(&email); Err(Error::BadRequest("Invalid login".to_string())) } else { - let token = create_session_token(&email, super_admin, &mut tx, cookies).await?; + let token = + create_session_token(&email, super_admin, None, false, &mut tx, cookies).await?; let audit_author = AuditAuthor { email: email.clone(), @@ -2036,7 +2037,15 @@ async fn refresh_token( .await? .unwrap_or(false); - let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?; + let new_token = create_session_token( + &authed.email, + super_admin, + authed.scopes.as_deref(), + authed.read_only, + &mut tx, + cookies, + ) + .await?; audit_log( &mut *tx, @@ -2066,6 +2075,8 @@ lazy_static::lazy_static! { pub async fn create_session_token<'c>( email: &str, super_admin: bool, + scopes: Option<&[String]>, + read_only: bool, tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, cookies: Cookies, ) -> Result { @@ -2108,15 +2119,17 @@ pub async fn create_session_token<'c>( sqlx::query!( "INSERT INTO token - (token_hash, token_prefix, token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only) + VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)", t_hash, t_prefix, plaintext as Option<&str>, email, "session", &MAX_SESSION_VALIDITY_SECONDS.to_string(), - super_admin + super_admin, + scopes, + read_only, ) .execute(&mut **tx) .await?; @@ -2146,6 +2159,8 @@ async fn create_token( ) -> Result<(StatusCode, String)> { check_token_create_rate_limit(&authed.username)?; + windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?; + let mut tx = db.begin().await?; let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; @@ -2353,6 +2368,8 @@ async fn update_token_scopes( Path(token_prefix): Path, Json(req): Json, ) -> Result { + windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?; + let mut tx = db.begin().await?; let updated: Option = sqlx::query_scalar!( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d06fe2f4bd..1a3c9e3e8f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.712.0 + version: 1.713.1 title: Windmill API contact: @@ -9831,6 +9831,9 @@ paths: type: boolean deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "201": description: flow created @@ -9872,6 +9875,9 @@ paths: properties: deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "200": @@ -10370,6 +10376,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10422,6 +10431,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10726,6 +10738,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." responses: "200": description: app updated @@ -10772,6 +10787,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." js: type: string css: @@ -22032,6 +22050,9 @@ components: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path." required: - path diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 3196c9df0c..0e096eb798 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -305,6 +305,22 @@ async fn get_token_using_oauth( resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; + // Validate the resolved token_url against SSRF rules before issuing the request, + // mirroring the protection applied to base_url in `get_base_url` (same + // ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could + // point token_url at an internal/metadata address. + if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS { + use windmill_common::ssrf::SsrfValidationError; + windmill_common::ssrf::validate_url_for_ssrf(&resource.token_url) + .await + .map_err(|e| match e { + e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( + "{e}. If you need to use private/internal AI endpoints, \ + set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" + )), + e => Error::from(e), + })?; + } let mut params = HashMap::new(); params.insert("grant_type", "client_credentials"); params.insert("scope", "https://cognitiveservices.azure.com/.default"); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 553d5748bf..b30daaa409 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -291,6 +291,11 @@ pub struct CreateApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, Deserialize)] @@ -304,6 +309,11 @@ pub struct EditApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, FromRow)] @@ -1294,13 +1304,17 @@ async fn create_app_internal<'a>( )); } } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - &app.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !app.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + &app.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } let id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) @@ -1899,13 +1913,17 @@ async fn update_app_internal<'a>( ))); } }; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !ns.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 2b93d59bc0..35728bc693 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -39,23 +39,43 @@ pub struct StaticFile(Uri); impl IntoResponse for StaticFile { fn into_response(self) -> Response { let original_path = self.0.path(); + let query = self.0.query(); let path = original_path.trim_start_matches('/'); - serve_path(path, original_path) + serve_path(path, original_path, query) } } #[cfg(feature = "static_frontend")] const TWO_HUNDRED: &str = "200.html"; -/// Check if the original path requires cross-origin isolation headers +/// Check if the original path requires cross-origin isolation headers. +/// /// These headers are needed for SharedArrayBuffer and TypeScript workers -/// Only enabled for /apps_raw paths (raw app editor) +/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`). +/// +/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep` +/// query param: a public (raw) app must set COEP to be embeddable as an iframe +/// inside a cross-origin-isolated page (which requires the embedded document to +/// also set COEP). It is opt-in rather than always-on because cross-origin +/// isolation also blocks subresources without CORP (e.g. external image URLs +/// or embeds used by classic apps), so we only enable it when the embedder +/// explicitly requests it. #[cfg(feature = "static_frontend")] -fn needs_cross_origin_isolation(original_path: &str) -> bool { - original_path.starts_with("/apps_raw/") || original_path.starts_with("/ui_builder/") +fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { + original_path.starts_with("/apps_raw/") + || original_path.starts_with("/ui_builder/") + || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) + && query_has_flag(query, "wm_coep")) } -fn serve_path(path: &str, original_path: &str) -> Response { +/// Returns true if `query` contains the given flag key (with or without a +/// value), e.g. `?wm_coep`, `?wm_coep=on`, `?foo=1&wm_coep=1`. +#[cfg(feature = "static_frontend")] +fn query_has_flag(query: Option<&str>, flag: &str) -> bool { + query.is_some_and(|q| q.split('&').any(|kv| kv.split('=').next() == Some(flag))) +} + +fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response { if path.starts_with("api/") { return Response::builder().status(404).body(Body::empty()).unwrap(); } @@ -71,7 +91,7 @@ fn serve_path(path: &str, original_path: &str) -> Response { // Add cross-origin isolation headers only for paths that need them // (apps_raw editor needs SharedArrayBuffer for TypeScript workers) - if needs_cross_origin_isolation(original_path) { + if needs_cross_origin_isolation(original_path, query) { res = res .header("Cross-Origin-Opener-Policy", "same-origin") .header("Cross-Origin-Embedder-Policy", "require-corp") @@ -102,12 +122,68 @@ fn serve_path(path: &str, original_path: &str) -> Response { None if path.starts_with("_app/") => { Response::builder().status(404).body(Body::empty()).unwrap() } - None => serve_path(TWO_HUNDRED, original_path), + None => serve_path(TWO_HUNDRED, original_path, query), } #[cfg(not(feature = "static_frontend"))] { - let _ = original_path; // suppress unused warning + let _ = (original_path, query); // suppress unused warning Response::builder().status(404).body(Body::empty()).unwrap() } } + +#[cfg(all(test, feature = "static_frontend"))] +mod tests { + use super::*; + + #[test] + fn test_query_has_flag() { + assert!(query_has_flag(Some("wm_coep"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep=on"), "wm_coep")); + assert!(query_has_flag(Some("foo=1&wm_coep=1"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep&foo=1"), "wm_coep")); + assert!(!query_has_flag(Some("wm_coepx=1"), "wm_coep")); + assert!(!query_has_flag(Some("foo=wm_coep"), "wm_coep")); + assert!(!query_has_flag(Some(""), "wm_coep")); + assert!(!query_has_flag(None, "wm_coep")); + } + + #[test] + fn test_needs_cross_origin_isolation() { + // editor + bundler are always isolated, regardless of query + assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); + + // public apps (and custom paths) are isolated only when they opt in via wm_coep + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep") + )); + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep=on") + )); + assert!(needs_cross_origin_isolation( + "/a/ws/my/path", + Some("wm_coep=on") + )); + assert!(!needs_cross_origin_isolation("/public/ws/secret", None)); + assert!(!needs_cross_origin_isolation("/a/ws/my/path", None)); + assert!(!needs_cross_origin_isolation( + "/public/ws/secret", + Some("foo=1") + )); + + // unrelated paths never get the headers + assert!(!needs_cross_origin_isolation( + "/apps/get/foo", + Some("wm_coep") + )); + // `/api/` must not be caught by the `/a/` prefix + assert!(!needs_cross_origin_isolation( + "/api/version", + Some("wm_coep") + )); + assert!(!needs_cross_origin_isolation("/", None)); + } +} diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index dd4c0bb42e..38cd444f29 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -393,6 +393,7 @@ pub async fn clone_script<'c>( modules: s.modules, auto_parent: None, labels: s.labels, + skip_draft_deletion: None, }; let new_hash = hash_script(&ns); diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 4cac407adf..964fd558f1 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5387,6 +5387,7 @@ async fn push_inner<'c, 'd>( cache_ttl: cache_ttl.map(|val| val as u32), cache_ignore_s3_path: cache_ignore_s3_path, same_worker: false, + preserve_step_tags: false, early_return: None, skip_expr: None, preprocessor_module: None, diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 7f9f16bf56..1c8882f327 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -109,6 +109,11 @@ pub struct NewFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this flow must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } impl NewFlow { @@ -168,6 +173,13 @@ pub struct FlowValue { #[serde(default)] #[serde(skip_serializing_if = "is_default")] pub same_worker: bool, + // When the flow runs on a custom worker tag, by default that tag is propagated to + // (and overrides) every step, script and nested sub-flow. Set this to true to instead + // let steps that declare their own non-empty tag run on it; steps without their own tag + // still inherit the flow's tag. Defaults to false to preserve the historical behavior. + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + pub preserve_step_tags: bool, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, #[serde(flatten)] diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index fd4f4b1be1..d9aff7866b 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -538,9 +538,19 @@ pub struct NewScript { pub auto_parent: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this script must NOT delete an existing user draft at the same path. + /// Transient — never persisted. Deliberately excluded from `impl Hash` + /// below (it must not affect the version hash) and from the no-op + /// comparison in the deploy handler (it isn't part of what the script + /// *is*). See `is_noop_deploy_against_parent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } // IMPORTANT: update this Hash impl when adding fields to NewScript +// (exception: caller-intent flags like `skip_draft_deletion` are intentionally +// omitted — they must not influence the computed version hash) impl Hash for NewScript { fn hash(&self, state: &mut H) { self.path.hash(state); diff --git a/backend/windmill-worker/nsjail/download_deps.py.sh b/backend/windmill-worker/nsjail/download_deps.py.sh index ce056e7e76..13fc00ddf9 100755 --- a/backend/windmill-worker/nsjail/download_deps.py.sh +++ b/backend/windmill-worker/nsjail/download_deps.py.sh @@ -31,6 +31,7 @@ $PY_PATH $INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG --system --reinstall +--compile-bytecode " echo $CMD diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 4bec8d44bf..3895c823b7 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -2095,6 +2095,9 @@ async fn spawn_uv_install( "--no-cache", // If we invoke uv pip install, then we want to overwrite existing data "--reinstall", + // Compile .py to .pyc at install time so imports are fast even + // through read-only nsjail mounts (no in-memory compilation per job). + "--compile-bytecode", ]; if let Some(py_path) = py_path.as_ref() { diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 6e2deb7446..281a9785c2 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2985,6 +2985,87 @@ struct PushNextFlowJobRec { // #[async_recursion] // #[instrument(level = "trace", skip_all)] +/// Resolve the worker tag for a flow's child job (step, nested sub-flow, preprocessor). +/// +/// A child normally inherits the parent flow job's tag so the whole flow runs on one worker +/// group. The exceptions, in order: +/// - the preprocessor step, or a flow running on the generic `flow` / `flow-{workspace}` tag, +/// always uses the child's own tag (`step_tag`); +/// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag, +/// that tag is honored instead of being overridden by the flow tag; +/// - otherwise the child inherits the parent flow job's tag. +fn resolve_flow_step_tag( + is_preprocessor_step: bool, + flow_tag: &str, + workspace_id: &str, + preserve_step_tags: bool, + step_tag: Option<&str>, +) -> Option { + if is_preprocessor_step || flow_tag == "flow" || flow_tag == format!("flow-{}", workspace_id) { + step_tag.map(str::to_string) + } else if preserve_step_tags && step_tag.is_some_and(|t| !t.is_empty()) { + step_tag.map(str::to_string) + } else { + Some(flow_tag.to_string()) + } +} + +#[cfg(test)] +mod tag_resolution_tests { + use super::resolve_flow_step_tag; + + #[test] + fn step_inherits_custom_flow_tag_by_default() { + // Parent flow on a custom tag, step declares its own tag, preserve disabled: + // the step inherits the flow tag (historical behavior). + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", false, Some("worker-group-B")), + Some("worker-group-A".to_string()) + ); + } + + #[test] + fn step_keeps_own_tag_when_preserve_enabled() { + // The exact customer scenario: a sub-flow tagged worker-group-B run as a step of a + // flow tagged worker-group-A now runs on worker-group-B when preserve_step_tags is on. + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } + + #[test] + fn untagged_step_inherits_flow_tag_even_when_preserve_enabled() { + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, None), + Some("worker-group-A".to_string()) + ); + // An empty tag counts as "no tag" and still inherits. + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("")), + Some("worker-group-A".to_string()) + ); + } + + #[test] + fn generic_flow_tag_always_uses_step_tag() { + for flow_tag in ["flow", "flow-w1"] { + assert_eq!( + resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } + } + + #[test] + fn preprocessor_step_uses_step_tag() { + assert_eq!( + resolve_flow_step_tag(true, "worker-group-A", "w1", false, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } +} + async fn push_next_flow_job( flow_job: Arc, mut status: FlowStatus, @@ -4118,13 +4199,13 @@ async fn push_next_flow_job( .map(|x| x.into()); tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed perms for job {i} of {len}"); - let tag = if step.is_preprocessor_step() - || (flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id)) - { - payload_tag.tag.clone() - } else { - Some(flow_job.tag.clone()) - }; + let tag = resolve_flow_step_tag( + step.is_preprocessor_step(), + &flow_job.tag, + &flow_job.workspace_id, + flow.preserve_step_tags, + payload_tag.tag.as_deref(), + ); let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref() { @@ -4822,6 +4903,7 @@ fn payload_from_modules<'a>( modules_node: Option, failure_module: Option<&Box>, same_worker: bool, + preserve_step_tags: bool, id: impl FnOnce() -> String, path: impl FnOnce() -> String, opt_empty_inner_flows: bool, @@ -4842,7 +4924,13 @@ fn payload_from_modules<'a>( } Some(JobPayload::RawFlow { - value: FlowValue { modules, failure_module, same_worker, ..Default::default() }, + value: FlowValue { + modules, + failure_module, + same_worker, + preserve_step_tags, + ..Default::default() + }, path: Some(path()), restarted_from: None, }) @@ -5144,6 +5232,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{i}", status.step), || format!("{}/forloop-{i}", flow_job.runnable_path()), true, @@ -5280,6 +5369,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || status.step.to_string(), || format!("{}/branchone-{}", flow_job.runnable_path(), branch_idx), true, @@ -5321,6 +5411,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{i}", status.step), || format!("{}/branchall-{}", flow_job.runnable_path(), i), false, @@ -5391,6 +5482,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{}", status.step, branch_status.branch), || { format!( @@ -5473,6 +5565,7 @@ async fn next_loop_iteration( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{}", status.step, ns.index), inner_path, true, diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 924052b688..d3479c294e 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.712.0"; +export const VERSION = "v1.713.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index a6f85fde1c..6f0263c5cf 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -192,6 +192,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -205,6 +207,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -480,6 +484,8 @@ const command = new Command() on_behalf_of_email: email, } as any, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`)); diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 5b9e880adc..fcfc68915e 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -466,6 +466,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), @@ -486,6 +488,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 4717711ae5..c0e94c478d 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -224,6 +224,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -237,6 +239,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } catch (e) { @@ -1159,6 +1163,8 @@ const command = new Command() path: flowPath, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, } as any, }); log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`)); diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 808456f151..0299744056 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -758,6 +758,9 @@ async function createScript( workspace: Workspace ): Promise { const start = performance.now(); + // Preserve any user draft at this path: a CLI / git-sync deploy must not wipe + // an in-progress draft the way a UI "deploy from draft" intentionally does. + body = { ...body, skip_draft_deletion: true }; // skip_if_noop asks the backend to treat deploys identical to the parent // (same content, lockfile, and metadata) as a no-op, so the CLI does not // produce phantom git-sync / promotion commits on re-pushes. @@ -1796,6 +1799,8 @@ async function setPermissionedAs( parent_hash: remote.hash, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`)); diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 714fdec222..7dd25dc705 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -10,7 +10,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-bash", description: "MUST use when writing Bash scripts.", languageKey: "bash" }, { name: "write-script-bigquery", description: "MUST use when writing BigQuery queries.", languageKey: "bigquery" }, { name: "write-script-bun", description: "MUST use when writing Bun/TypeScript scripts.", languageKey: "bun" }, - { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts.", languageKey: "bunnative" }, + { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker.", languageKey: "bunnative" }, { name: "write-script-csharp", description: "MUST use when writing C# scripts.", languageKey: "csharp" }, { name: "write-script-deno", description: "MUST use when writing Deno/TypeScript scripts.", languageKey: "deno" }, { name: "write-script-duckdb", description: "MUST use when writing DuckDB queries.", languageKey: "duckdb" }, @@ -19,7 +19,6 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-java", description: "MUST use when writing Java scripts.", languageKey: "java" }, { name: "write-script-mssql", description: "MUST use when writing MS SQL Server queries.", languageKey: "mssql" }, { name: "write-script-mysql", description: "MUST use when writing MySQL queries.", languageKey: "mysql" }, - { name: "write-script-nativets", description: "MUST use when writing Native TypeScript scripts.", languageKey: "nativets" }, { name: "write-script-php", description: "MUST use when writing PHP scripts.", languageKey: "php" }, { name: "write-script-postgresql", description: "MUST use when writing PostgreSQL queries.", languageKey: "postgresql" }, { name: "write-script-powershell", description: "MUST use when writing PowerShell scripts.", languageKey: "powershell" }, @@ -926,7 +925,7 @@ ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-bunnative": `--- name: write-script-bunnative -description: MUST use when writing Bun Native scripts. +description: MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker. --- ## CLI Commands @@ -966,13 +965,14 @@ Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes \`fetch\` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with \`//native\` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. \`./helper.ts\`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on \`fetch\` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, \`node:*\` modules, child processes, native addons) will not work on the native worker; use the regular \`bun\` language for those. ## Structure Export a single **async** function called \`main\`: \`\`\`typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -988,6 +988,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the \`RT\` namespace for resource types: \`\`\`typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -999,9 +1000,10 @@ Before using a resource type, check the \`rt.d.ts\` file in the project root to ## Imports -**No imports allowed.** Use the globally available \`fetch\` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides \`fetch\` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (\`node:fs\`, \`child_process\`, the \`Bun\` API, native modules) belongs in a regular \`bun\` script instead. Use the globally available \`fetch\` for HTTP: \`\`\`typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -1010,13 +1012,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: \`\`\`typescript +//native type Event = { kind: | "webhook" @@ -1049,6 +1052,7 @@ Windmill provides built-in support for S3-compatible storage operations. The \`w ### Receiving an S3Object as a script parameter \`\`\`typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -1060,6 +1064,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations \`\`\`typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -2976,682 +2981,6 @@ All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storag omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or \`csv\`). Use this for large result sets — rows stream directly to S3 instead of being buffered as the script return value. -`, - "write-script-nativets": `--- -name: write-script-nativets -description: MUST use when writing Native TypeScript scripts. ---- - -## CLI Commands - -Place scripts in a folder. - -After writing, tell the user which command fits what they want to do: - -- \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. -- \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. -- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". - -### Preview vs run — choose by intent, not habit - -If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use \`script preview\`. Do NOT push the script to then \`script run\` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes. - -Only use \`script run\` when: -- The user explicitly says "run the deployed version" / "run what's on the server". -- There is no local script being edited (you're just invoking an existing script). - -Only use \`sync push\` when: -- The user explicitly asks to deploy, publish, push, or ship. -- The preview has already validated the change and the user wants it in the workspace. - -### After writing — offer to test, don't wait passively - -If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. - -If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. - -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. - -For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -\`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -\`\`\` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -workerHasInternalServer(): boolean - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format \`$res:path\` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * \`\`\` - * - * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Permanently delete a file from S3 by key. - * - * \`\`\`typescript - * await wmill.deleteS3File({ s3: "path/to/file.txt" }) - * \`\`\` - * - * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) - * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) - */ -async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use \`step()\` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling \`fn(item)\`, which should be a task(). - * Items are dispatched in batches of \`concurrency\` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise - -/** - * Commit Kafka offsets for a trigger with auto_commit disabled. - * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) - * @param topic - Kafka topic name (from event.topic) - * @param partition - Partition number (from event.partition) - * @param offset - Message offset to commit (from event.offset) - */ -async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age}::int - * \`.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age} - * \`.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-php": `--- name: write-script-php @@ -5502,7 +4831,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. diff --git a/cli/src/main.ts b/cli/src/main.ts index 34c186182d..6e98f4d7cb 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -89,7 +89,7 @@ export { token, }; -export const VERSION = "1.712.0"; +export const VERSION = "1.713.1"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 2bfa883466..cd8ab1a33f 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index d6616b5b97..fb807d38a9 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c59d3e5b35..11156f4319 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.712.0", + "version": "1.713.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.712.0", + "version": "1.713.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -66,6 +66,7 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", @@ -6352,6 +6353,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", @@ -10888,6 +10904,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4f0c9ada75..38340bebb9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.712.0", + "version": "1.713.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", @@ -126,7 +126,6 @@ "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "mdast-util-find-and-replace": "^3.0.2", - "unist-util-visit": "^5.0.0", "minimatch": "^10.0.1", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", @@ -141,12 +140,14 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", "svelte-infinite-loading": "^1.4.0", "tailwind-merge": "^1.13.2", + "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index c6243f3529..200f66f538 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -13,6 +13,7 @@ import DropdownV2Inner from './DropdownV2Inner.svelte' import { pointerDownOutside } from '$lib/utils' import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte' + import type { MenubarMenuElements } from '@melt-ui/svelte' import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte' import Button from '$lib/components/common/button/Button.svelte' import { twMerge } from 'tailwind-merge' @@ -40,7 +41,10 @@ size?: ButtonType.UnifiedSize btnText?: string buttonReplacement?: import('svelte').Snippet - menu?: import('svelte').Snippet + // In customMenu mode the snippet receives the melt-ui `item` action + // store so consumers can wrap their own rows in (or + // `use:melt={$item}`) and get arrow-key navigation + aria wiring. + menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]> maxHeight?: string | undefined } @@ -172,7 +176,7 @@ transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }} > {#if customMenu} - {@render menu?.()} + {@render menu?.({ item, close })} {:else}
{ @@ -1829,6 +1835,30 @@ $effect(() => { lang = scriptLangToEditorLang(scriptLang) }) + + // Opt-in (syncExternalCode): reflect external `code` prop mutations into + // Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g. + // the inline rawscript in the flow editor — otherwise mutate the prop + // without Monaco ever showing the change (the AI chat editing a flow + // module's content in a session is the motivating case). Gated off by + // default: Editor is sensitive and most callers either bind:code (and + // carry their own external-sync) or treat code as init-only, so a blanket + // setValue would risk clobbering them. The `getValue() !== code` guard + // keeps the caret intact when the change originated from typing inside + // Monaco (which round-trips code back via `$bindable`, re-firing this + // effect with `code === getValue()`). + let lastExternalCodeSync = code + $effect(() => { + if (!syncExternalCode) return + if (code === lastExternalCodeSync) return + lastExternalCodeSync = code + if (!editor) return + untrack(() => { + if (editor!.getValue() !== code) { + editor!.setValue(code ?? '') + } + }) + }) $effect(() => { filePath = computePath(path) }) diff --git a/frontend/src/lib/components/FlowDiffViewer.svelte b/frontend/src/lib/components/FlowDiffViewer.svelte index 86fc002795..3539cbc5fe 100644 --- a/frontend/src/lib/components/FlowDiffViewer.svelte +++ b/frontend/src/lib/components/FlowDiffViewer.svelte @@ -6,13 +6,28 @@ interface Props { beforeYaml: string afterYaml: string + /** Side-by-side vs unified. Leave undefined to let + * FlowGraphDiffViewer show its own user-facing toggle (matches the + * pre-fork-diff-drawer behavior). */ + inlineDiff?: boolean + /** Forwarded to FlowGraphDiffViewer — render an empty surface + * placeholder for the "before" / "after" pane when the item is + * added / removed. */ + beforeMissing?: boolean + afterMissing?: boolean } - let { beforeYaml, afterYaml }: Props = $props() + let { + beforeYaml, + afterYaml, + inlineDiff = undefined, + beforeMissing = false, + afterMissing = false + }: Props = $props() let diffMode: 'yaml' | 'graph' = $state('graph') -
+
@@ -30,6 +45,7 @@ defaultLang="yaml" defaultOriginal={beforeYaml} defaultModified={afterYaml} + {inlineDiff} readOnly /> {/await} @@ -37,7 +53,7 @@ {#await import('$lib/components/FlowGraphDiffViewer.svelte')} {:then Module} - + {/await} {/if}
diff --git a/frontend/src/lib/components/FlowGraphDiffViewer.svelte b/frontend/src/lib/components/FlowGraphDiffViewer.svelte index 3639f0955b..caab746502 100644 --- a/frontend/src/lib/components/FlowGraphDiffViewer.svelte +++ b/frontend/src/lib/components/FlowGraphDiffViewer.svelte @@ -2,12 +2,12 @@ import type { OpenFlow } from '$lib/gen' import YAML from 'yaml' import FlowGraphV2 from './graph/FlowGraphV2.svelte' - import { Alert, Button } from './common' + import { Alert } from './common' import { computeFlowModuleDiff } from './flows/flowDiff' import { Pane, Splitpanes } from 'svelte-splitpanes' + import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import type { Viewport } from '@xyflow/svelte' const SIDE_BY_SIDE_MIN_WIDTH = 700 @@ -15,13 +15,54 @@ interface Props { beforeYaml: string afterYaml: string + /** When true, render an empty surface placeholder for the "before" + * pane in side-by-side mode (use for added items where there's no + * prior flow to show). */ + beforeMissing?: boolean + /** Same as `beforeMissing` but for the "after" pane (use for removed + * items). */ + afterMissing?: boolean + /** Render the unified single-pane diff when true, side-by-side + * otherwise. When undefined, the component renders its own + * Unified / Side-by-side toggle in the corner (legacy behavior for + * the standalone comparison page). A narrow viewer still falls back + * to unified automatically. */ + inlineDiff?: boolean | undefined } - let { beforeYaml, afterYaml }: Props = $props() + let { + beforeYaml, + afterYaml, + beforeMissing = false, + afterMissing = false, + inlineDiff = undefined + }: Props = $props() + + // Local toggle state, used only when no inlineDiff prop is supplied. + let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside') + const showLocalToggle = $derived(inlineDiff === undefined) + const effectiveInlineDiff = $derived( + inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified' + ) let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH) let beforePaneSize = $state(50) - let viewMode = $state<'sidebyside' | 'unified'>('sidebyside') + // Track the content area's rendered height so unified-mode graphs can + // grow to fill the diff box (otherwise FlowGraphV2 sits at its + // content-fit height + small floor, leaving empty space below). + let contentAreaHeight = $state(0) + + // Each FlowGraphV2 sizes itself to its own content (clamped to minHeight). + // In side-by-side mode we want both graphs to share the same height, so + // we track each side's reported height and feed back the max as minHeight + // to both. The width-graph then stays at its computed size; the shorter + // graph grows to match. + let beforeContentHeight = $state(0) + let afterContentHeight = $state(0) + const SHARED_MIN_HEIGHT = 400 + const sharedMinHeight = $derived( + Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight) + ) // Shared viewport for synchronizing both graphs in side-by-side mode let sharedViewport = $state({ x: 0, y: 0, zoom: 1 }) @@ -29,7 +70,10 @@ let beforeGraph: FlowGraphV2 | undefined = $state(undefined) let afterGraph: FlowGraphV2 | undefined = $state(undefined) - function parseFlow(yaml: string, label: 'before' | 'after'): { + function parseFlow( + yaml: string, + label: 'before' | 'after' + ): { flow: OpenFlow | undefined error: string | undefined } { @@ -49,14 +93,27 @@ } } - let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before')) - let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after')) + // For added/removed items, the caller passes empty YAML and sets the + // corresponding *Missing flag. We swap in an empty OpenFlow stub on + // that side so the unified diff path still has something to compare + // against (every module on the present side becomes added / removed). + // The side-by-side rendering uses the flag directly to draw a + // placeholder pane instead. + const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } } + + let beforeParsed = $derived.by(() => + beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before') + ) + let afterParsed = $derived.by(() => + afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after') + ) let parseError = $derived(beforeParsed.error ?? afterParsed.error) let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow) let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow) - // Determine if we should render side-by-side or unified (user controlled via toggle) - let isSideBySide = $derived(viewMode === 'sidebyside') + // Side-by-side unless the caller asked for unified, OR the viewer pane + // is too narrow to comfortably split (fallback to unified for legibility). + const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH) // Build timeline using history-based approach // In side-by-side view, mark removed modules as 'shadowed' in the After graph @@ -72,14 +129,6 @@ sharedViewport = viewport } } - - $effect(() => { - if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) { - viewMode = 'unified' - } else { - viewMode = 'sidebyside' - } - }) {#if parseError} @@ -88,10 +137,12 @@ {:else if beforeFlow && afterFlow}
- -
-
- + {#if showLocalToggle} + +
+ {#snippet children({ item })}
- + {/if} + +
{#if isSideBySide} - -
- +
{/if} -
- - -
{#if isSideBySide} -
-
- - {#snippet leftHeader()} - Before - {/snippet} - -
+
+ {#if beforeMissing} + + Before (no prior version) + + {:else} +
+ (beforeContentHeight = h)} + > + {#snippet leftHeader()} + Before + {/snippet} + +
+ {/if}
-
-
- - {#snippet leftHeader()} - After - {/snippet} - -
+
+ {#if afterMissing} + + After (flow deleted) + + {:else} +
+ (afterContentHeight = h)} + > + {#snippet leftHeader()} + After + {/snippet} + +
+ {/if}
@@ -219,7 +299,7 @@ editMode={false} download={false} scroll={false} - minHeight={400} + minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)} triggerNode={false} />
@@ -231,3 +311,31 @@

Loading graphs...

{/if} + + diff --git a/frontend/src/lib/components/GfmMarkdown.svelte b/frontend/src/lib/components/GfmMarkdown.svelte index 83cce90fbe..377951b97b 100644 --- a/frontend/src/lib/components/GfmMarkdown.svelte +++ b/frontend/src/lib/components/GfmMarkdown.svelte @@ -1,19 +1,12 @@
diff --git a/frontend/src/lib/components/LocalDraftBanner.svelte b/frontend/src/lib/components/LocalDraftBanner.svelte new file mode 100644 index 0000000000..357744c2b2 --- /dev/null +++ b/frontend/src/lib/components/LocalDraftBanner.svelte @@ -0,0 +1,101 @@ + + + + +{#if show} +
+
+ + + You have unsaved changes + +
+
+ + {#if !disabled} + + {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 2ea6b21652..4acf693dfd 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -14,7 +14,6 @@ import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte' - import { notifyRestoredFromLocal } from '$lib/userDraftToast' import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte' interface Props { @@ -26,6 +25,14 @@ defaultValues?: Record | undefined workspace?: string | undefined selected?: string | undefined + /** Notifies the parent drawer whether a local draft for the selected + * workspace diverges from the deployed baseline, so it can show the + * "unsaved changes" banner below its header. */ + onDraftStateChange?: (hasDraft: boolean) => void + /** Notifies the parent drawer of write-access for the selected workspace, + * so it can hide the banner's Discard button in read-only mode (matches + * the trigger editors' `disabled={!can_write}` wiring). */ + onCanWriteChange?: (canWrite: boolean) => void } let { @@ -36,7 +43,9 @@ onChange, defaultValues = undefined, workspace = undefined, - selected: selectedProp = $bindable() + selected: selectedProp = $bindable(), + onDraftStateChange, + onCanWriteChange }: Props = $props() type ResourceState = { @@ -189,6 +198,11 @@ Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + // Banner is scoped to the selected workspace — the diff/discard only + // operate on it, so showing it for an unrelated dirty workspace would be + // misleading. The cross-workspace `otherDirty` alert below still covers + // that case. + const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected)) const otherDirty = $derived( dirtyWorkspaces.length == 1 ? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore) @@ -277,11 +291,6 @@ { workspace: ws } ) } - notifyRestoredFromLocal(false, true, { - onResetToDeployed: () => { - UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws }) - } - }) } } ensureHandle(ws, s) @@ -330,6 +339,29 @@ canSave = anyDirty && dirtyValid && dirtyCanWrite }) + // Drive the parent drawer's "unsaved changes" banner. The drawer chrome + // (header + banner slot) lives in ResourceEditorDrawer, above this + // lazily-imported content, so the state is lifted up via these accessors. + $effect(() => { + onDraftStateChange?.(!!initialPath && selectedDirty) + }) + $effect(() => { + onCanWriteChange?.(can_write) + }) + + export function localDraftDeployed(): ResourceState | undefined { + return selected ? initialStates[selected] : undefined + } + export function localDraftCurrent(): ResourceState | undefined { + return current + } + export function discardLocalDraft(): void { + if (!selected) return + UserDraft.discard('resource', initialPath ?? '', initialStates[selected], { + workspace: selected + }) + } + $effect(() => { if (current) // $state.snapshot deep-reads (so the effect re-runs on nested diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index ed80717a32..919f8edeff 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -6,6 +6,7 @@ import { Loader2, Save } from 'lucide-svelte' import WsSpecificVersions from './WsSpecificVersions.svelte' import { workspaceStore } from '$lib/stores' + import LocalDraftBanner from './LocalDraftBanner.svelte' let { workspace = undefined, @@ -17,7 +18,16 @@ let resource_type: string | undefined = $state(undefined) let defaultValues: Record | undefined = $state(undefined) - let resourceEditor: { save: () => void } | undefined = $state(undefined) + let resourceEditor: + | { + save: () => void + localDraftDeployed: () => unknown + localDraftCurrent: () => unknown + discardLocalDraft: () => void + } + | undefined = $state(undefined) + let hasLocalDraft = $state(false) + let canWriteSelected = $state(true) let path: string | undefined = $state(undefined) let selected: string | undefined = $state(undefined) @@ -62,8 +72,19 @@ bind:this={resourceEditor} bind:canSave bind:selected + onDraftStateChange={(v) => (hasLocalDraft = v)} + onCanWriteChange={(v) => (canWriteSelected = v)} /> {/await} + {#snippet banner()} + resourceEditor?.localDraftDeployed()} + getCurrent={() => resourceEditor?.localDraftCurrent()} + onDiscard={() => resourceEditor?.discardLocalDraft()} + disabled={!canWriteSelected} + /> + {/snippet} {#snippet actions()} {#if mode == 'edit' && path && effectiveWorkspace} void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ - { - label: 'Deploy & Stay here', - onClick: () => { - handleEditScript(true) - } - }, - { - label: 'Fork', - onClick: () => { - window.open(`/scripts/add?template=${initialPath}`) - } - }, - ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ...(!inSessionPane ? [ { - label: 'Edit in workspace fork', + label: 'Deploy & Stay here', onClick: () => { - window.open(buildForkEditUrl('script', initialPath)) + handleEditScript(true) } - } + }, + { + label: 'Fork', + onClick: () => { + window.open(`/scripts/add?template=${initialPath}`) + } + }, + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ? [ + { + label: 'Edit in workspace fork', + onClick: () => { + window.open(buildForkEditUrl('script', initialPath)) + } + } + ] + : []) ] : []), ...(customUi?.topBar?.diff !== false && savedScript && diffDrawer @@ -678,7 +696,10 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.auto_kind + ...(!inSessionPane && + !script.draft_only && + script.kind === 'script' && + !script.auto_kind ? [ { label: 'Exit & See details', @@ -688,7 +709,7 @@ } ] : []), - ...(isWorkflowAsCode(script.content, script.language) + ...(!inSessionPane && isWorkflowAsCode(script.content, script.language) ? [ { label: 'Export as YAML/JSON', @@ -701,7 +722,11 @@ ] : [] - if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) { + if ( + !inSessionPane && + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { dropdownItems = [ { label: 'Export as YAML/JSON', @@ -727,7 +752,11 @@ } let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) + // 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 + // caller pre-assigned. The session preview opens AI-created scripts as new + // (empty initialPath) but with a path the AI already picked. + let dirtyPath = $state(initialPathChosen) let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state( (() => { @@ -1896,6 +1925,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet + {initialTestPanelCollapsed} />
{:else} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 9fe173f0ba..a281b266da 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -160,6 +160,11 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + // When true the right-hand test/run pane mounts collapsed. The user + // can still expand it via `toggleTestPanel`. Defaults to false so the + // regular /scripts/edit route keeps its current open-by-default UX; + // the session preview opts in to save vertical real estate. + initialTestPanelCollapsed?: boolean } let { @@ -193,7 +198,8 @@ assets = $bindable(), modules = $bindable(undefined), editorBarRight, - enablePreprocessorSnippet = false + enablePreprocessorSnippet = false, + initialTestPanelCollapsed = false }: Props = $props() let initialArgs = structuredClone($state.snapshot(args)) @@ -1360,8 +1366,11 @@ // dynamic minimum below — so when the editor shrinks, the displayed test // pane grows to honor the new minimum without needing an effect. The code // pane's size is purely derived from it (100 - test). - let rawTestPanelSize = $state(30) - let storedTestPanelSize = untrack(() => rawTestPanelSize) + // `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while + // keeping the "remembered" size at 30, so the user's first toggle expands + // the pane to a sensible width rather than 0. + let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30))) + let storedTestPanelSize = 30 const testPanelSize = $derived( rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent) ) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 2330762bd8..3a6403c6e5 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -17,8 +17,8 @@ import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte' - import { notifyRestoredFromLocal } from '$lib/userDraftToast' import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte' + import LocalDraftBanner from './LocalDraftBanner.svelte' const dispatch = createEventDispatcher() @@ -128,6 +128,11 @@ Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + // Banner is scoped to the selected workspace — the diff/discard only + // operate on it, so showing it for an unrelated dirty workspace would be + // misleading. The cross-workspace `otherDirty` alert below still covers + // that case. + const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected)) const otherDirty = $derived( dirtyWorkspaces.length == 1 ? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore) @@ -191,11 +196,6 @@ if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws }) } - notifyRestoredFromLocal(false, true, { - onResetToDeployed: () => { - UserDraft.discard('variable', p, s, { workspace: ws }) - } - }) } } ensureHandle(ws, s) @@ -335,6 +335,20 @@ title={edit ? `Update variable at ${initialPath}` : 'Add a variable'} on:close={drawer?.closeDrawer} > + {#snippet banner()} + (selected ? initialStates[selected] : undefined)} + getCurrent={() => current} + onDiscard={() => { + if (!selected) return + UserDraft.discard('variable', editPath ?? '', initialStates[selected], { + workspace: selected + }) + }} + disabled={!can_write} + /> + {/snippet}
{#if !can_write} diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index 0ffa4d8ba6..c688759874 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -75,7 +75,7 @@ disabled={edit && $userStore?.operator} /> {#if variable.is_secret} - + Every secret is encrypted at rest and in transit with a key specific to this workspace. In addition, any read of a secret variable generates an audit log whose operation name is: variables.decrypt_secret diff --git a/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte new file mode 100644 index 0000000000..2ee01b4607 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte @@ -0,0 +1,162 @@ + + + +{#if kind === 'flow'} +
+ +
+{:else if hasContent} +
+ + + + +
+ {#if contentTab === 'content'} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {/if} +
+
+{:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} +
+ +
+ {/await} +{/if} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index abee584afa..9136f52205 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import { onMount, untrack } from 'svelte' import { @@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' + import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' type Kind = WorkspaceItemKind type Item = WorkspaceItem @@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // Sibling-popover open: melt-ui's `openFocus` runs once during the close→open // transition; the picker may not be mounted yet. Retry after settle. + // Also kicks off the initial scope's fetch — drill/goUp do the same from + // their respective branches, so `ensureLoaded` is always a callback + // reaction to user navigation, never a reactive consequence. onMount(() => { const t = setTimeout(focus, 50) + const initial = untrack(() => scope) + if (initial) { + if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(initial.kind) + } return () => clearTimeout(t) }) @@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let scope = $state(untrack(() => initialScope)) let filter = $state('') + /** + * Canonical entry point for changing the picker's scope. Triggers the + * fetch for the kind(s) the new scope needs at the same point in time. + * Replaces the older "react to `scope` change via `$effect`" wiring, + * which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the + * effect ended up subscribed to the signal it fills — every fetch + * result re-fired it. With explicit callbacks the fetch is tied to + * the user's action, never to a reactive consequence of that action. + */ + function setScope(next: Scope) { + scope = next + if (!next) return + if (next.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(next.kind) + } + /** Tracks whether the last user action was mouse movement (true) or * keyboard nav (false). When false, row `mouseenter` events are ignored * — prevents the cursor from stealing the keyboard-driven highlight as @@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * mounts under a stationary cursor doesn't clobber `initialHighlight`. */ let mouseActive = $state(false) - // Seed from cache so kinds already fetched in this session render on the - // first frame. Read once at mount: melt-ui mounts a fresh picker per - // popover open, so workspace changes are picked up at the next open - // without needing this seed to be reactive. + // Seed from the last fetched snapshot so kinds already fetched in this + // session render on the first frame. Each entry is replaced once + // `loadKind` returns fresh data — stale-while-revalidate, so deploys and + // AI-created drafts surface on the next open without explicit cache + // busting. let loaded = $state>>( (() => { if (!$workspaceStore) return {} @@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level async function ensureLoaded(kind: Kind) { if (!$workspaceStore) return - if (loaded[kind]) return - loadingKind[kind] = true + // Always re-fetch. If we have nothing cached, show a spinner; if we do, + // keep displaying it and quietly swap to fresh data when it lands. + // `loaded[kind]` is read inside `untrack(...)` because this function is + // reachable from the search `$effect` below — without the untrack, + // that effect would subscribe to the signal `ensureLoaded` fills, and + // each `loaded[kind] = items` (proxy `set` notifies even when the ref + // is unchanged from cache) would refire it → runaway loop. Drill + // navigation goes through `setScope` directly so it isn't affected. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true try { const items = await loadKind($workspaceStore, kind) loaded[kind] = items @@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level } } - // Fetch the scope's kind on entry to a non-root level. The `'all'` scope - // needs every kind loaded since it merges items across them. - $effect(() => { - if (!scope) return - if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k) - else ensureLoaded(scope.kind) - }) + // Chat tools and session editor previews write drafts through + // `UserDraft` (workspace-scoped, localStorage-backed). Merge those into + // the picker so users can navigate to in-flight items that haven't been + // deployed yet. Filter to kinds the picker actually displays. + // + // Gated on the same dev flag as the rest of the sessions feature: without + // it there are no sessions, so the only UserDrafts present are the + // standalone editors' autosaves — surfacing those in the breadcrumb picker + // would be surprising (they'd appear as navigable items that 404 on the + // backend draft fetch). When the flag is off this is a no-op. + const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + function aiDraftsForKind(k: Kind): Item[] { + if (!isGlobalAiEnabled()) return [] + if (!$workspaceStore) return [] + const targetType = KIND_TO_DRAFT_TYPE[k] + return listGlobalDrafts($workspaceStore) + .filter((d) => d.type === targetType) + .map((d) => ({ + path: d.path, + summary: d.summary ?? '', + kind: k, + // `raw_app` lives on the draft envelope for legacy/raw-app distinction. + raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined + })) + } // Searching is global → load every kind. $effect(() => { @@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level leaves: Item[] } + /** Merge AI-created in-memory drafts into a kind's list. The AI may have + * scaffolded a script/flow/app via chat tools without the user saving + * yet — those drafts should be navigable from the picker. Existing items + * (same path) win to keep the backend's metadata (summary etc.). */ + function withAiDrafts(items: Item[], k: Kind): Item[] { + const ai = aiDraftsForKind(k) + if (ai.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(ai.filter((d) => !known.has(d.path))) + } + /** Inject the currently-edited item into a kind's list at its live path, * dropping the saved entry when a draft rename is in progress. Other kinds * pass through untouched. */ @@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * cached. */ function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] { if (!kinds.includes(k)) return [] - const items = withCurrent(list ?? [], k) + const items = withAiDrafts(withCurrent(list ?? [], k), k) if (items.length === 0) return [] return buildTreeFromItems(items) } @@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * one folder hierarchy. Each leaf still carries its real kind, so the row * icon and `editPathFor` routing still work; folders contain a mix. */ const allTree = $derived.by(() => { - const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k)) + const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k)) return merged.length === 0 ? [] : buildTreeFromItems(merged) }) @@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let allItems = $derived( kinds.flatMap((k) => - withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` })) + withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({ + ...it, + _key: `${k}:${it.path}` + })) ) ) @@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level function drill(entry: Entry) { if (entry.type === 'kind') { - scope = { kind: entry.kind } + setScope({ kind: entry.kind }) } else if (entry.type === 'dir') { - scope = { kind: entry.kind, dir: entry.node.fullPath } + setScope({ kind: entry.kind, dir: entry.node.fullPath }) } else { pick(entry.item) } @@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // just left, so the user sees where they came from. if (!scope.dir) { const leaving = kindKey(scope.kind) - scope = undefined + setScope(undefined) highlightedKey = leaving return } const leaving = dirKey(scope.kind, scope.dir) const parent = parentDirPath(scope.dir) - scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind } + setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }) highlightedKey = leaving } @@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level {#snippet leafRow(it: Item, secondary: string, baseClass: string)} {@const key = leafKey(it)} - {@const isHl = key === highlightedKey} - {@const isCur = isCurrent(it)} - + /> {/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} + + +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte index e8ae0f8335..ff9c4263a8 100644 --- a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte +++ b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte @@ -5,10 +5,8 @@ import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types' import { initCss } from '../../utils' import RunnableWrapper from '../helpers/RunnableWrapper.svelte' - import { Markdown, type Plugin } from 'svelte-exmarkdown' - import { gfmPlugin } from 'svelte-exmarkdown/gfm' - import rehypeRaw from 'rehype-raw' - import { rehypeGithubAlerts } from 'rehype-github-alerts' + import { Markdown } from 'svelte-exmarkdown' + import { markdownPlugins as plugins } from '$lib/components/markdownPlugins' import { classNames } from '$lib/utils' import { components } from '../../editor/component' import ResolveConfig from '../helpers/ResolveConfig.svelte' @@ -31,11 +29,6 @@ configuration }: Props = $props() - const plugins: Plugin[] = [ - gfmPlugin(), - { rehypePlugin: [rehypeRaw] }, - { rehypePlugin: [rehypeGithubAlerts] } - ] const { app, worldStore, mode } = getContext('AppViewerContext') const resolvedConfig = $state( diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 0e2f233579..d6d35b4367 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -78,20 +78,29 @@ replaceStateFn = (path: string) => window.history.replaceState(null, '', path), gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), onSavedNewAppPath, + onNavigate, initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) + // Inside a session pane the AIChatManager is injected via context. Sessions + // have their own state machinery (sessionRuntime + per-fork backend), and + // the user-facing $workspaceStore stays on the main workspace even when + // the session is editing in a fork — so a UserDraft handle here would + // share its LS key with the regular /apps/edit route and clobber both + // sides' autosaves. Skip UserDraft entirely in that case. + const inSessionPane = !!getContext('aiChatManager') + const appDraftPath = newApp ? '' : (path ?? '') - const appDraftHandle = UserDraft.use('app', appDraftPath) + const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry // (`UserDraft.remove`) when it wants to force a fresh start — // `?nodraft=true`, template/hub loads, etc. - const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) + const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the // seed meta on the very first persist of this entry. After that the @@ -111,6 +120,7 @@ let firstMirror = true $effect(() => { readFieldsRecursively(stateApp) + if (!appDraftHandle) return untrack(() => { // Resolve the meta to attach BEFORE the wipe — the wipe clears // in-memory meta and would otherwise force-seed `initialRevs` @@ -164,7 +174,7 @@ groups: $userStore?.groups, username: $userStore?.username, name: $userStore?.name, - query: urlParamsToObject(new URL(window.location.href).searchParams), + query: urlParamsToObject(new URL(window.location.href).searchParams, { stripReserved: true }), hash: window.location.hash.substring(1), workspace: $workspaceStore, mode: 'editor', @@ -881,6 +891,7 @@ rightPanelHidden={rightPanelSize === 0} bottomPanelHidden={runnablePanelSize === 0} {onSavedNewAppPath} + {onNavigate} onShowLeftPanel={() => showLeftPanel()} onShowRightPanel={() => showRightPanel()} onShowBottomPanel={() => showBottomPanel()} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 0bcb9d46d5..97b4604314 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -59,7 +59,7 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' - import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { beforeNavigate, goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' @@ -101,6 +101,7 @@ onHideRightPanel?: () => void onHideLeftPanel?: () => void onHideBottomPanel?: () => void + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void } let { @@ -120,7 +121,8 @@ onShowBottomPanel, onHideLeftPanel, onHideRightPanel, - onHideBottomPanel + onHideBottomPanel, + onNavigate = undefined }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -160,6 +162,14 @@ const { history, jobsDrawerOpen, refreshComponents } = getContext('AppEditorContext') + // Sessions inject an AIChatManager via context; AppEditor skips its + // UserDraft handle in that case, so the cleanup calls here must skip too + // (otherwise we'd wipe a non-session tab's autosave at the same path). The + // session-side equivalent is the View's `onDeploy` → + // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // the preview to the deployed version. + const inSessionPane = !!getContext('aiChatManager') + const loading = $state({ publish: false, save: false @@ -213,7 +223,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -297,7 +307,6 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) - invalidatePicker($workspaceStore!, 'app') invalidateWorkspacePaths($workspaceStore!) savedApp = { summary: $summary, @@ -314,7 +323,7 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) if ($appPath !== npath) { onSavedNewAppPath?.(npath) } @@ -791,7 +800,7 @@ bind:path={newEditedPath} savedPath={$appPath || newPath || undefined} kind="app" - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
{#if $app} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 649e67ef0d..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -35,7 +35,8 @@ newEditedPath = $bindable(), newPath, hideSecretUrl = false, - preserveOnBehalfOf = $bindable(false) + preserveOnBehalfOf = $bindable(false), + rawApp = false }: { policy: any setPublishState: () => void @@ -51,6 +52,11 @@ newPath: string hideSecretUrl?: boolean preserveOnBehalfOf?: boolean + // Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic + // (low-code) apps must NOT get the flag — it would force COEP on the + // document and break no-CORP cross-origin subresources (external images, + // {@html} embeds, CDN imports). + rawApp?: boolean } = $props() let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) @@ -91,6 +97,17 @@ isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : '' }${customPath}` ) + + // When embedding a raw app in an iframe inside another Windmill app (or any + // cross-origin-isolated page), the embedded document must set COEP. The + // `wm_coep` flag opts the public app into the cross-origin isolation headers. + // Only raw apps get it — for classic (low-code) apps COEP would break + // no-CORP cross-origin subresources, so their snippet stays a plain iframe. + let embedMode = $state(false) + function toEmbedSnippet(url: string): string { + const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url + return `` + } async function getSecretUrl() { secretUrl = await AppService.getPublicSecretOfApp({ workspace: $workspaceStore!, @@ -122,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -247,18 +264,40 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref} - +
+ (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> +
+ {:else} {/if}
- Share this url directly or embed it using an iframe (if requiring login, top-level domain of - embedding app must be the same as the one of Windmill) + {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + The wm_coep flag Sets the cross-origin isolation headers (COEP) so the app can be embedded inside + another Windmill app or any cross-origin-isolated page. Without it the browser blocks + the iframe. lets it load inside a cross-origin-isolated page. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this url directly, or switch to Embed to get an iframe snippet. + {/if}
@@ -305,7 +344,10 @@
Custom public URL
- +
{dirtyCustomPath ? customPathError : ''} diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 48c3d5578d..fef6ba8fa3 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -145,7 +145,7 @@ name: $userStore?.name, groups: $userStore?.groups, username: $userStore?.username, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={effectiveWorkspace} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 6c94ac08a5..5ff52d6826 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -161,6 +161,8 @@ export interface AppEditorProps { replaceStateFn?: (path: string) => void gotoFn?: (path: string, opt?: Record | undefined) => void onSavedNewAppPath?: (path: string) => void + /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** * Backend revs at the load that produced `app`. Used as the seed * `UserDraft` meta on the first local autosave: until the handle has diff --git a/frontend/src/lib/components/common/EditableInput.svelte b/frontend/src/lib/components/common/EditableInput.svelte index e772247c0a..e85625c58f 100644 --- a/frontend/src/lib/components/common/EditableInput.svelte +++ b/frontend/src/lib/components/common/EditableInput.svelte @@ -78,6 +78,15 @@ this component just proposes new values. }) } + // External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap + // stays active for a brief window after the menu closes — focusing our + // input during that window causes checkFocusIn to slam focus back out, which + // fires onblur=save and instantly closes the edit. A 50ms defer is enough + // for Melt's trap to release. + export function edit() { + setTimeout(startEditing, 50) + } + function save() { // Re-entry guard: Enter calls `save()` and sets `editing = false`, // which unmounts the `` and synchronously fires its `blur` diff --git a/frontend/src/lib/components/common/drawer/DrawerContent.svelte b/frontend/src/lib/components/common/drawer/DrawerContent.svelte index ad89c2c725..6b76873c49 100644 --- a/frontend/src/lib/components/common/drawer/DrawerContent.svelte +++ b/frontend/src/lib/components/common/drawer/DrawerContent.svelte @@ -22,6 +22,8 @@ id?: string | undefined actions?: import('svelte').Snippet titleExtra?: import('svelte').Snippet + /** Rendered fixed below the header, above the scrollable content. */ + banner?: import('svelte').Snippet children?: import('svelte').Snippet } @@ -40,6 +42,7 @@ id, actions, titleExtra, + banner, children }: Props = $props() @@ -83,6 +86,10 @@ {/if}
+ {#if banner} + {@render banner()} + {/if} +
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e2f21ce96..a23a8da905 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -61,7 +61,12 @@ import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' -import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' +import { + globalToolsFor, + prepareGlobalSystemMessage, + prepareGlobalUserMessage, + type GlobalToolHelpers +} from './global/core' import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message @@ -208,13 +213,26 @@ export class AIChatManager { private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined + disabledModes: Partial> = $state({}) + // Set by AI sessions. Enables the session-only preview tools (open_preview / + // get_preview_status) and their system-prompt guidance in GLOBAL mode; the + // global side-panel chat leaves it false so those tools aren't offered. + isSessionChat = false + // The session this manager belongs to (session chats only). Carried into the + // tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS + // session rather than the UI-active one — keeps backgrounded sessions isolated. + sessionId: string | undefined = undefined + allowedModes: Record = $derived({ - script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined, - flow: this.flowAiChatHelpers !== undefined, - app: this.appAiChatHelpers !== undefined, - navigator: true, - ask: true, - API: true, + script: + this.flowAiChatHelpers === undefined && + this.scriptEditorOptions !== undefined && + !this.disabledModes.script, + flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, + navigator: !this.disabledModes.navigator, + ask: !this.disabledModes.ask, + API: !this.disabledModes.API, // Dev-only gate. See `./global/gate.ts` for how to enable. global: isAIModeVisible(AIMode.GLOBAL) }) @@ -495,9 +513,15 @@ export class AIChatManager { this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) - this.systemMessage = prepareGlobalSystemMessage(customPrompt) - this.tools = [...globalTools] - this.helpers = {} + this.systemMessage = prepareGlobalSystemMessage(customPrompt, { + previewTools: this.isSessionChat + }) + this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) + this.helpers = { + ...(this.isSessionChat ? { sessionId: this.sessionId } : {}), + testActiveFlow: async (args?: Record) => + this.flowAiChatHelpers?.testFlow(args) + } satisfies GlobalToolHelpers } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -795,6 +819,12 @@ export class AIChatManager { } } + // Optional pre-flight hook called once per send, after validation but + // before any UI state mutates or backend calls go out. Sessions use + // this to commit/materialise the workspace (creating a staged fork via + // the API) so the first message targets the correct workspace. + beforeSend?: () => Promise | void + sendRequest = async ( options: { removeDiff?: boolean @@ -819,6 +849,24 @@ export class AIChatManager { if (!this.instructions.trim()) { return } + if (this.beforeSend) { + try { + await this.beforeSend() + } catch (e) { + // beforeSend commits the session's workspace before the first + // message hits the backend. If it throws, sending anyway would + // silently target the wrong workspace (typically the parent), so + // abort and tell the user — their message text stays in the input. + console.error('AIChatManager beforeSend hook failed', e) + sendUserToast( + `Could not prepare the session before sending: ${ + e instanceof Error ? e.message : String(e) + }. Your message was not sent — please try again.`, + true + ) + return + } + } try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 35ebbc257d..b1c77df84d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -16,7 +16,13 @@ vi.mock('$lib/gen', () => ({ })) vi.mock('$lib/stores', () => ({ - workspaceStore: { subscribe: () => () => undefined } + workspaceStore: { subscribe: () => () => undefined }, + userStore: { + subscribe: (run: (value: { username: string }) => void) => { + run({ username: 'admin' }) + return () => undefined + } + } })) vi.mock('$lib/toast', () => ({ @@ -55,10 +61,12 @@ vi.mock('esm-env', async (importOriginal) => ({ function createFlowHelpers({ hasPendingChanges, - acceptAllModuleActions + acceptAllModuleActions, + testFlow = vi.fn() }: { hasPendingChanges: () => boolean acceptAllModuleActions: () => void + testFlow?: FlowAIChatHelpers['testFlow'] }): FlowAIChatHelpers { return { getFlowAndSelectedId: vi.fn(), @@ -74,7 +82,7 @@ function createFlowHelpers({ rejectAllModuleActions: vi.fn(), hasPendingChanges, selectStep: vi.fn(), - testFlow: vi.fn(), + testFlow, getLintErrors: vi.fn() } as unknown as FlowAIChatHelpers } @@ -162,6 +170,27 @@ describe('AIChatManager autonomy mode', () => { expect(applied).toBe(true) }) + + it('does not pass the AI session id as a flow test conversation id in global mode', async () => { + const manager = new AIChatManager() + const testFlow = vi.fn(async () => 'job-flow-preview') + + manager.isSessionChat = true + manager.sessionId = 'htc1xouxd96dcyo6ruqo39' + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => false, + acceptAllModuleActions: vi.fn(), + testFlow + }) + ) + + manager.changeMode(AIMode.GLOBAL) + const jobId = await manager.helpers.testActiveFlow({ name: 'Ada' }) + + expect(jobId).toBe('job-flow-preview') + expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }) + }) }) describe('AIChatManager persisted autonomy default', () => { diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 27adb56ef3..2ab480a706 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -76,7 +76,7 @@ onClick={() => onMenuOpen?.()} startIcon={{ icon: Menu }} iconOnly - > + />
{@render children?.()} @@ -96,5 +96,13 @@ {/if} {:else} - {@render children?.()} +
+ {@render children?.()} +
{/if} diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index e59ed4b513..5b4258d4e7 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -3,9 +3,16 @@ import { CircleHelp } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { aiChatManager } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' import type { UserQuestionDisplay } from './shared' + // Sessions inject a per-pane `AIChatManager` via context; outside of + // sessions getAiChatManager falls back to the global singleton. Without + // this, answers clicked inside a session would dispatch to the singleton's + // pending callbacks map (which doesn't have the session manager's question + // callback), and the AI loop would stall. + const aiChatManager = getAiChatManager() + interface Props { toolCallId: string userQuestion: UserQuestionDisplay diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 5a40c49e36..89b714b80f 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -2,7 +2,10 @@ import { ChevronDown } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import Button from '$lib/components/common/button/Button.svelte' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode' diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 4df548192a..65abb0e1c5 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -1,7 +1,9 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 2b9f4a09f1..56a594ff1e 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -867,6 +867,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { @@ -930,6 +931,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { diff --git a/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte b/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte index 8926863c30..139251d062 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte @@ -34,7 +34,7 @@ {#if $workerTags} {#if $workerTags?.length > 0}
- {#if flowStore.val.tag == undefined || isPreprocessor} + {#if flowStore.val.tag == undefined || isPreprocessor || flowStore.val.value?.preserve_step_tags} { - flowStore.val.tag ? (displayWorkerTagPicker = true) : null + if (flowStore.val.tag) { + displayWorkerTagPicker = true + } else if (flowStore.val.value.preserve_step_tags) { + // preserve_step_tags has no effect without a flow worker tag; clear it so it + // doesn't linger as invisible state when the tag is removed via any path + // (the picker, the toggle, or the YAML editor). + flowStore.val.value.preserve_step_tags = undefined + } }) let activeAdvancedOptions = $derived([ @@ -78,6 +85,7 @@ { name: 'Error Handler Muted', active: Boolean(flowStore.val.ws_error_handler_muted) }, { name: 'Invisible to Others', active: Boolean(flowStore.val.visible_to_runner_only) }, { name: 'Shared Directory', active: Boolean(flowStore.val.value.same_worker) }, + { name: 'Preserve Step Tags', active: Boolean(flowStore.val.value.preserve_step_tags) }, { name: 'Cache Results', active: Boolean(flowStore.val.value.cache_ttl) }, { name: 'Early Stop', active: Boolean(flowStore.val.value.skip_expr) }, { name: 'Early Return', active: Boolean(flowStore.val.value.early_return) }, @@ -182,6 +190,8 @@ on:change={() => { displayWorkerTagPicker = !displayWorkerTagPicker if (!displayWorkerTagPicker) { + // Clearing the tag triggers the reactive block above, which also + // resets preserve_step_tags so it doesn't linger as invisible state. flowStore.val.tag = undefined } }} @@ -197,6 +207,25 @@
+ {#if flowStore.val.tag} +
+ +
+ {/if} {/if}
diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 563a387184..fbe07ac7b8 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -200,6 +200,9 @@ markRemovedAsShadowed?: boolean controlsPosition?: 'top' | 'bottom' outerDivClass?: string + /** Fires when the computed graph height changes. Diff views can use + * this to equalize heights of side-by-side graphs. */ + onHeight?: (height: number) => void } let { @@ -273,7 +276,8 @@ onMoveMultiple = undefined, movingIds = undefined, controlsPosition = 'top', - outerDivClass = '' + outerDivClass = '', + onHeight = undefined }: Props = $props() // Initialize note manager with fine-grained reactivity @@ -759,6 +763,7 @@ const computed = maxBottom - minY height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight) } + onHeight?.(height) } $effect(() => { diff --git a/frontend/src/lib/components/markdownPlugins.ts b/frontend/src/lib/components/markdownPlugins.ts new file mode 100644 index 0000000000..4495619868 --- /dev/null +++ b/frontend/src/lib/components/markdownPlugins.ts @@ -0,0 +1,30 @@ +import type { Plugin } from 'svelte-exmarkdown' +import { gfmPlugin } from 'svelte-exmarkdown/gfm' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import { rehypeGithubAlerts } from 'rehype-github-alerts' + +/** + * Shared plugin chain for rendering user-supplied Markdown (script/flow/resource + * descriptions, flow-graph notes, the App "Markdown" component, ...). + * + * Order matters and is security-sensitive: + * 1. `gfmPlugin` — GitHub-flavored Markdown. + * 2. `rehypeRaw` — re-parses embedded raw HTML into live hast nodes. + * 3. `rehypeSanitize` — strips dangerous nodes (` + +{#if !inSessionPane} + +{/if} + { - if (appPath == '') { + if (newApp || appPath == '') { createApp(newEditedPath) } else { handleUpdateApp(newEditedPath) @@ -585,7 +620,7 @@ startIcon={{ icon: Save }} disabled={pathError != '' || customPathError != '' || app == undefined} on:click={() => { - if (appPath == '') { + if (newApp || appPath == '') { createApp(newEditedPath) } else { handleUpdateApp(newEditedPath) @@ -604,6 +639,7 @@ {appPath} {onLatest} {savedApp} + rawApp bind:summary bind:customPath bind:deploymentMsg @@ -691,7 +727,7 @@ savedPath={appPath || newPath || undefined} kind="app" raw_app - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index f58a3635d5..fc6e31ffa2 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -36,9 +36,20 @@ export interface ScriptBuilderProps { savedPrimarySchedule?: ScheduleTrigger | undefined functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined children?: import('svelte').Snippet - onDeploy?: (e: { path: string; hash: string }) => void + // Fires on every successful deploy. `stay` is true for "Deploy & Stay here" + // and for lib scripts (the editor stays in place rather than navigating to + // the deployed item) — consumers should skip post-deploy navigation when set. + onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void onDeployError?: (e: { path: string; error: any }) => void onHistoryRestore?: () => void onSeeDetails?: (e: { path: string }) => void onNavigate?: (item: WorkspaceItem) => void + // Forwarded to the underlying ScriptEditor. When true, the right-hand + // test/run pane opens collapsed. Used by the session preview. + initialTestPanelCollapsed?: boolean + // Treat the path as already chosen (seeds the path "dirty" flag) so the + // summary→path auto-slug for new scripts (initialPath == '') doesn't + // overwrite it. Used by the session preview, which opens AI-created scripts + // as new but with a path the AI already assigned. + initialPathChosen?: boolean } diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte new file mode 100644 index 0000000000..0ea66146ac --- /dev/null +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -0,0 +1,181 @@ + + +{#if runtime.savedFlow.val} + +{/if} +{#if runtime.loadingFlow && !runtime.loadedPath} +
Loading flow {path}…
+{:else if runtime.notFound && !runtime.loadedPath} + +{:else} + + runtime.scheduleForkComparisonRefresh()} + onDeploy={() => { + // FlowBuilder has no deploy toast and the session stays put, so toast + // here, then sync the preview to deployed (pulls the new locks + version_id). + sendUserToast('Deployed') + runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) + }} + /> +{/if} diff --git a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte new file mode 100644 index 0000000000..acfac1f03f --- /dev/null +++ b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte @@ -0,0 +1,732 @@ + + + searchableText(d)} +/> + +{#snippet renderTreeNode(node: TreeNode, depth: number)} + {#if node.type === 'folder'} + {@const isUserScope = node.isScope && node.name.startsWith('u/')} + {@const fkey = folderKey(node)} + {@const open = isFolderOpen(fkey)} + {@const isHl = fkey === highlightedKey} +
(folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)} + class="select-none" + > + setHoverHighlight(fkey)} + class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl + ? 'bg-surface-hover' + : ''}" + style="padding-left: {depth * 12 + 8}px" + > + + + {#if isUserScope} + + {:else} + + {/if} + {node.name} + +
+ {#each node.children as child} + {@render renderTreeNode(child, depth + 1)} + {/each} +
+
+ {:else} + {@const status = statusOf(node.diff)} + {@const key = itemKey(node.diff)} + { + highlightedKey = key + scrollToDiff(node.diff) + }} + onmouseenter={() => setHoverHighlight(key)} + > + {#snippet extras()} + + {/snippet} + + {/if} +{/snippet} + + + drawer?.closeDrawer()} + documentationLink={undefined} + noPadding + overflow_y={false} + > + {#snippet titleExtra()} +
+ + {forkWs?.name ?? forkWorkspaceId} + + {parentWs?.name ?? parentWorkspaceId} + {#if comparison} + + {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} + + {#if comparison.summary.conflicts > 0} + + + {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} + + {/if} + {/if} +
+ {/snippet} + {#snippet actions()} + + {#snippet children({ item })} + + + {/snippet} + + + {/snippet} +
+ {#if comparison && comparison.diffs.length > 0} + + {/if} +
+
+ {#if loading && !comparison} +
+ + Loading comparison... +
+ {:else if error} +
{error}
+ {:else if comparison?.skipped_comparison} +
+ This fork was created before change tracking was added — diffs are not available. +
+ {:else if comparison && comparison.diffs.length === 0} +
No changes between this fork and its parent.
+ {:else if comparison && filteredDiffs.length === 0} +
No files match "{searchQuery}".
+ {:else if comparison} +
+ {#each filteredDiffs as d (itemKey(d))} + {@const key = itemKey(d)} + {@const status = statusOf(d)} + {@const StatusIcon = statusIcons[status]} + {@const loaded = loadedDiffs[key]} + {@const editUrl = editUrlFor(d)} +
onDetailsToggle(d, e)} + > + + + + +
+ {#if d.ahead > 0} + {d.ahead} ahead + {/if} + {#if d.behind > 0} + {d.behind} behind + {/if} + + + {status} + +
+
+
+ {#if !loaded || loaded.state === 'loading'} +
+ + Loading diff… +
+ {:else if loaded.state === 'error'} +
{loaded.error}
+ {:else if loaded.state === 'ready'} + + {/if} +
+
+ {/each} +
+ {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte new file mode 100644 index 0000000000..af77298a87 --- /dev/null +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -0,0 +1,158 @@ + + +{#if runtime.savedRawApp.val} + +{/if} +{#if runtime.loadingRawApp && !runtime.loadedRawAppPath} +
Loading raw app {path}…
+{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath} + +{:else if runtime.rawApp.val} + { + // Sync the preview to deployed (raw apps deploy only from this editor). + runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) + }} + defaultSidebarCollapsed + sidebarStorageKey="raw-app-sidebar-collapsed-preview" + defaultSplitWithPreview={false} + /> +{/if} diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte new file mode 100644 index 0000000000..da7b7fcb53 --- /dev/null +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -0,0 +1,224 @@ + + +{#if runtime.savedScript.val} + +{/if} +{#if runtime.loadingScript && !runtime.loadedScriptPath} +
Loading script {path}…
+{:else if runtime.notFoundScript && !runtime.loadedScriptPath} + +{:else if runtime.scriptStore.val} + + { + runtime.scheduleForkComparisonRefresh() + // Re-pin parent_hash to the latest version so the next Deploy's conflict + // check (which runs before deploy, while the session stays mounted) + // doesn't misfire. + try { + const latest = await ScriptService.getScriptLatestVersion({ + workspace: workspaceId, + path: e.path + }) + const cur = runtime.scriptStore.val + if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash + } catch (err) { + console.error('Failed to sync parent_hash after save draft', err) + } + }} + onDeploy={(e) => { + // Fires on every deploy (primary, "Deploy & Stay here", and lib — we + // ignore e.stay since the session always stays). Toast, then sync the + // preview to the deployed version. + sendUserToast('Deployed') + runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) + }} + /> +{/if} diff --git a/frontend/src/lib/components/sessions/SessionForkBar.svelte b/frontend/src/lib/components/sessions/SessionForkBar.svelte new file mode 100644 index 0000000000..9205c4c087 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionForkBar.svelte @@ -0,0 +1,213 @@ + + +{#if committedId && isUnavailable} + +
+
+ +
+ The fork has been archived or deleted + + Move this session to another workspace, or discard it. + {committedId} + +
+
+
+ onMove?.(workspaceId)} + onCreateFork={async (fork) => { + await onCreateForkAndMove?.(fork) + }} + createForkCaption="Created immediately and the session moved into it." + > + {#snippet trigger()} + + {/snippet} + + +
+
+{:else if forksAllowed && isFork && sessionWorkspace && parentWorkspace && parentWorkspaceId && committedId} + {@const StatusIcon = + forkStatus === 'ahead' + ? GitPullRequestArrow + : forkStatus === 'diverged' + ? GitCompareArrows + : GitFork} + {@const statusColor = + forkStatus === 'ahead' + ? 'text-blue-500' + : forkStatus === 'diverged' + ? 'text-amber-500' + : 'text-secondary'} + {@const statusTitle = + forkStatus === 'ahead' + ? 'Ahead of parent' + : forkStatus === 'diverged' + ? 'Diverged from parent' + : forkStatus === 'in_sync' + ? 'In sync with parent' + : 'Fork'} +
+
+ + + + + {sessionWorkspace.name} + + + + {parentWorkspace.name} + +
+
+ + +
+
+ + +{/if} diff --git a/frontend/src/lib/components/sessions/SessionItemNotFound.svelte b/frontend/src/lib/components/sessions/SessionItemNotFound.svelte new file mode 100644 index 0000000000..dc1a088c53 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionItemNotFound.svelte @@ -0,0 +1,54 @@ + + +
+
+ +
+
+ {KIND_NOT_FOUND_LABEL[kind]} not found at path + {path}. +
+
diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte new file mode 100644 index 0000000000..31c463e79f --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -0,0 +1,629 @@ + + +{#if !globalEnabled} + +{:else if isCollapsed} +
+ + {#snippet children({ createMenu })} + + {#snippet triggr({ trigger })} +
+ + {#if totalUnread > 0} + + {/if} +
+ {/snippet} + {#snippet children({ item })} +
+
+ + + New session + +
+
+ {#each visibleSessions as session (session.id)} + {@const runtime = getRuntime(session.id)} + {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const isSelected = + onSessionsPage && session.id === sessionState.currentSessionId} + {@const unread = unreadFor(session)} + {@const draft = hasDraft(session)} + activate(session)} + {item} + > + + 0 ? 'font-semibold text-primary' : '' + )} + > + {session.summary ?? 'Untitled session'} + + {#if draft || unread > 0} + + {#if draft} + + {/if} + {#if unread > 0} + + {unread > 9 ? '9+' : unread} + + {/if} + + {/if} + + {/each} +
+
+ {/snippet} +
+ {/snippet} +
+
+{:else} +
+
+ {#if visibleSessions.length > 0} + + {:else} + + + AI sessions + + {/if} +
+ + {#snippet trigger()} + + {/snippet} + {#snippet content()} +
+ + {#if archivedCount > 0} + + {archivedCount} archived session{archivedCount === 1 ? '' : 's'} + + {/if} +
+ {/snippet} +
+
+
+ {#if !sectionCollapsed.val} +
+ {#each visibleSessions as session (session.id)} + {@const runtime = getRuntime(session.id)} + {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const isSelected = onSessionsPage && session.id === sessionState.currentSessionId} + {@const isEditing = editingId === session.id} + {@const unread = unreadFor(session)} + {@const draft = hasDraft(session)} +
+ {#if isEditing} + + + + { + if (e.key === 'Enter') commitRename() + else if (e.key === 'Escape') cancelRename() + }} + onblur={commitRename} + placeholder="Untitled session" + autofocus + spellcheck="false" + class="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs font-normal text-primary" + /> + + {:else} + +
+ startRename(session) + }, + session.archived + ? { + displayName: 'Unarchive', + icon: ArchiveRestore, + action: () => setSessionArchived(session.id, false) + } + : { + displayName: 'Archive', + icon: Archive, + action: () => setSessionArchived(session.id, true) + }, + { + displayName: 'Delete', + icon: Trash2, + type: 'delete', + action: () => (pendingDelete = session) + } + ]} + > + {#snippet buttonReplacement()} + + + + {/snippet} + +
+ {/if} +
+ {/each} +
+ {/if} +
+{/if} + + { + pendingDelete = undefined + deleteAlsoFork = true + }} +> +
+

+ Delete session {pendingDelete?.summary ?? pendingDelete?.name}? This cannot be undone. +

+ {#if pendingDeleteForkId} +
+ +
+ Also delete forked workspace {pendingDeleteForkId} + The fork won't be reachable from any other session — leaving it would orphan it. +
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/sessions/SessionStatusDot.svelte b/frontend/src/lib/components/sessions/SessionStatusDot.svelte new file mode 100644 index 0000000000..b47816ab19 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionStatusDot.svelte @@ -0,0 +1,98 @@ + + + + {#if status === 'streaming'} + + + + + + {:else if status === 'needs-confirmation'} + + {:else if status === 'error'} + + {:else if isFork} + {#if forkStatus === 'ahead'} + + {:else if forkStatus === 'diverged'} + + {:else if forkStatus === 'unavailable'} + + {:else} + + {/if} + {:else} + + {/if} + + + diff --git a/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte new file mode 100644 index 0000000000..310d43e5be --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte @@ -0,0 +1,75 @@ + + +
+ Run in + + {#snippet trigger()} + + {#if pendingFork || (currentWs && currentWs.id !== root?.id)} + + {:else} + + {/if} + + {pendingFork?.name ?? currentWs?.name ?? effectiveId ?? 'Pick workspace'} + + {#if pendingFork} + (new) + {/if} + + + {/snippet} + +
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte new file mode 100644 index 0000000000..002ecbfef0 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -0,0 +1,513 @@ + + +{#if !session || !runtime} +
Session not found
+{:else} + {@const hasTarget = + session.target?.kind === 'flow' || + session.target?.kind === 'script' || + session.target?.kind === 'raw_app'} + {@const hasEditor = mountEditor && hasTarget && editorVisible} + + {#snippet inputPreface()} + {#if !hasFirstUserMessage} + + {/if} + moveAndActivate(workspaceId)} + onCreateForkAndMove={(fork) => createForkAndMove(fork)} + onArchive={() => archiveAndReset()} + onDelete={() => (deleteConfirmOpen = true)} + /> + {/snippet} + + + {#snippet sessionEmptyHint()}{/snippet} + + + +
+ { + session.summary = v + persistSessions() + }} + class="text-sm font-semibold" + inputClass="!text-sm !font-semibold" + /> + summaryInput?.edit() + }, + session.archived + ? { + displayName: 'Unarchive', + icon: ArchiveRestore, + action: () => setSessionArchived(session.id, false) + } + : { + displayName: 'Archive', + icon: Archive, + action: () => archiveAndReset() + }, + { + displayName: 'Delete', + icon: Trash2, + type: 'delete', + action: () => (deleteConfirmOpen = true) + } + ]} + > + {#snippet buttonReplacement()} + + + + {/snippet} + + {#if !session.target && hasFirstUserMessage} + +
+ + {#snippet trigger()} + + {/snippet} + {#snippet content()} + pickEditorTarget(item)} + /> + {/snippet} + +
+ {:else if hasTarget && mountEditor && !editorVisible} +
+ +
+ {:else if hasEditor} +
+ +
+ {/if} +
+
+ +
+
+ {#if hasEditor && session.target} + +
+ {#if session.target.kind === 'flow'} + + {:else if session.target.kind === 'script'} + + {:else if session.target.kind === 'raw_app'} + + {/if} +
+
+ {/if} +
+ + { + deleteConfirmOpen = false + deleteAlsoFork = false + }} + > +
+

+ Delete session {session?.summary ?? session?.name}? This cannot be undone. +

+ {#if sessionForkId} +
+ +
+ Also delete forked workspace {sessionForkId} + The fork won't be reachable from any other session — leaving it would orphan it. +
+
+ {/if} +
+
+ + { + archiveConfirmOpen = false + archiveAlsoFork = false + }} + > +
+

+ Archive session {session?.summary ?? session?.name}? You can restore it later from the archived list. +

+ {#if sessionForkId} +
+ +
+ Also archive forked workspace {sessionForkId} + Archived workspaces can be unarchived later from instance settings. +
+
+ {/if} +
+
+{/if} + + diff --git a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte new file mode 100644 index 0000000000..61b18a53b9 --- /dev/null +++ b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte @@ -0,0 +1,312 @@ + + + { + if (!dropdownOpen) return + if (creatingFork) return + if (navRows.length === 0) return + if (e.key === 'ArrowDown') { + keyArrowPos = keyArrowPos === undefined ? 0 : Math.min(navRows.length - 1, keyArrowPos + 1) + e.preventDefault() + } else if (e.key === 'ArrowUp') { + keyArrowPos = keyArrowPos === undefined ? navRows.length - 1 : Math.max(0, keyArrowPos - 1) + e.preventDefault() + } else if (e.key === 'Enter' && keyArrowPos !== undefined) { + activateRow(navRows[keyArrowPos]) + e.preventDefault() + } else if (e.key === 'Escape') { + dropdownOpen = false + e.preventDefault() + } + }} +/> + + + {#snippet buttonReplacement()} + {@render trigger({ open: dropdownOpen })} + {/snippet} + {#snippet menu()} + {@const rowBase = + 'px-3 py-1.5 text-xs text-primary flex flex-row gap-2 items-center text-left rounded-sm w-full'} +
+ {#if showCreateFork} + {#if creatingFork} +
+
+ + + { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + void stageNewFork() + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + cancelCreate() + } + } + }} + /> + +
+ {#if forkNameError || createForkCaption} +
+ + {#if !forkNameError && createForkCaption} + {createForkCaption} + {/if} +
+ {/if} +
+ {:else} + {@const createIdx = 0} + + {/if} +
+ {/if} + + {#if root} + {@const rootIdx = showCreateFork ? 1 : 0} + + {/if} + {#each forks as f, fi (f.id)} + {@const forkIdx = (showCreateFork ? 1 : 0) + (root ? 1 : 0) + fi} + + {/each} + {#if pendingFork && !creatingFork} +
+ + {pendingFork.name} + New +
+ {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/sessions/appDraftCodec.test.ts b/frontend/src/lib/components/sessions/appDraftCodec.test.ts new file mode 100644 index 0000000000..f83e1e590b --- /dev/null +++ b/frontend/src/lib/components/sessions/appDraftCodec.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { + runtimeRawAppToDraft, + applyDraftToRuntimeRawApp, + type RuntimeRawApp, + type RawAppDraft +} from './appDraftCodec' + +function runtime(over: Partial = {}): RuntimeRawApp { + return { + summary: 'app', + path: 'f/foo/app', + files: { '/index.tsx': 'x' }, + runnables: {}, + data: { tables: [] } as any, + policy: { execution_mode: 'publisher' }, + ...over + } +} + +describe('appDraftCodec — custom_path round-trip', () => { + it('carries custom_path from runtime → draft', () => { + const draft = runtimeRawAppToDraft(runtime({ custom_path: 'my-url' })) + expect(draft.custom_path).toBe('my-url') + }) + + it('carries custom_path from draft → runtime', () => { + const base = runtime({ custom_path: undefined }) + const dv: RawAppDraft = { + summary: 'app', + files: {}, + runnables: {}, + data: { tables: [] } as any, + policy: { execution_mode: 'publisher' }, + custom_path: 'kept-url' + } + expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('kept-url') + }) + + it('survives a full runtime → draft → runtime round-trip', () => { + const original = runtime({ custom_path: 'round-trip-url' }) + const back = applyDraftToRuntimeRawApp( + runtime({ custom_path: undefined }), + runtimeRawAppToDraft(original) + ) + expect(back.custom_path).toBe('round-trip-url') + // runtime-only `path` is preserved from the target, not the draft + expect(back.path).toBe('f/foo/app') + }) + + it('falls back to the runtime custom_path when the draft omits it', () => { + const base = runtime({ custom_path: 'existing' }) + const dv: RawAppDraft = { + summary: 'app', + files: {}, + runnables: {}, + data: { tables: [] } as any + } + expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('existing') + }) +}) diff --git a/frontend/src/lib/components/sessions/appDraftCodec.ts b/frontend/src/lib/components/sessions/appDraftCodec.ts new file mode 100644 index 0000000000..47493da3f2 --- /dev/null +++ b/frontend/src/lib/components/sessions/appDraftCodec.ts @@ -0,0 +1,55 @@ +import type { RawAppData } from '$lib/components/raw_apps/dataTableRefUtils' + +// The raw-app draft shape stored under `UserDraft` — matches the +// regular `/apps_raw/edit` route's UserDraft handle exactly. The chat's +// `userDraftAdapter.saveGlobalAppDraft` writes through the same shape, so +// session previews and the chat round-trip identically. +export type RawAppDraft = { + files: Record + runnables: Record + data: RawAppData + summary: string + policy?: any + custom_path?: string +} + +// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in +// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and +// makes `policy` required for the editor's live binding. +export type RuntimeRawApp = { + summary: string + path: string + files: Record + runnables: Record + data: RawAppData + policy: any + custom_path?: string +} + +// Strip runtime-only metadata (just `path`, the storage key) when persisting +// to UserDraft. `custom_path` is a real draft field and must round-trip — else +// session sync erases a draft's custom URL. +export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft { + return { + summary: raw.summary, + files: raw.files, + runnables: raw.runnables, + data: raw.data, + policy: raw.policy, + custom_path: raw.custom_path + } +} + +// Overlay a UserDraft-stored raw-app draft onto an existing runtime raw app, +// preserving the runtime-only `path` field. +export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): RuntimeRawApp { + return { + ...raw, + summary: dv.summary, + files: dv.files, + runnables: dv.runnables, + data: dv.data, + policy: dv.policy ?? raw.policy, + custom_path: dv.custom_path ?? raw.custom_path + } +} diff --git a/frontend/src/lib/components/sessions/flowDraftSig.test.ts b/frontend/src/lib/components/sessions/flowDraftSig.test.ts new file mode 100644 index 0000000000..052ef2faea --- /dev/null +++ b/frontend/src/lib/components/sessions/flowDraftSig.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { flowDraftSig } from './flowDraftSig' + +describe('flowDraftSig', () => { + it('changes when only the summary changes (the regression this guards)', () => { + const base = { value: { modules: [] }, schema: {}, summary: 'a' } + const renamed = { ...base, summary: 'b' } + expect(flowDraftSig(base)).not.toBe(flowDraftSig(renamed)) + }) + + it('is stable for equal value/schema/summary', () => { + const a = { value: { modules: [1] }, schema: { x: 1 }, summary: 's' } + const b = { value: { modules: [1] }, schema: { x: 1 }, summary: 's' } + expect(flowDraftSig(a)).toBe(flowDraftSig(b)) + }) + + it('changes when the value changes', () => { + expect(flowDraftSig({ value: { modules: [] }, summary: 's' })).not.toBe( + flowDraftSig({ value: { modules: [1] }, summary: 's' }) + ) + }) +}) diff --git a/frontend/src/lib/components/sessions/flowDraftSig.ts b/frontend/src/lib/components/sessions/flowDraftSig.ts new file mode 100644 index 0000000000..5b4137e908 --- /dev/null +++ b/frontend/src/lib/components/sessions/flowDraftSig.ts @@ -0,0 +1,9 @@ +// Dedup signature for the session flow preview's two-way sync (FlowEditorView). +// +// The inbound (draft → editor) and outbound (editor → draft) effects compare +// this signature to skip no-op work. It MUST include `summary` — otherwise a +// summary-only change produces an identical signature and never propagates or +// persists. +export function flowDraftSig(x: { value?: unknown; schema?: unknown; summary?: unknown }): string { + return JSON.stringify({ value: x.value, schema: x.schema, summary: x.summary }) +} diff --git a/frontend/src/lib/components/sessions/forkDiffCache.test.ts b/frontend/src/lib/components/sessions/forkDiffCache.test.ts new file mode 100644 index 0000000000..2d38320a6a --- /dev/null +++ b/frontend/src/lib/components/sessions/forkDiffCache.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' + +// Documents the cache-invalidation contract that ForkDiffDrawer.fetchComparison +// relies on. The bug (cubic P2): per-item raw diffs (`loadedDiffs[key]`) and +// per-item `summaries` are component-local $state that persist for the +// drawer's lifetime. fetchComparison re-fetches the comparison on every +// open(), but loadDiffFor short-circuits on `loadedDiffs[key]` — so an +// edit-then-reopen would show fresh counts but the prior open's cached +// expanded raw content. The fix clears both records at the top of +// fetchComparison before the await. This test pins the contract from the +// outside: nothing is allowed to read a stale per-item value across a +// fetchComparison invocation. + +type LoadedDiff = { state: 'ready'; parentRaw: string; forkRaw: string } + +function simulateFetchComparison( + state: { loadedDiffs: Record; summaries: Record }, + items: Array<{ key: string; parentRaw: string; forkRaw: string; summary: string }> +) { + // The fix: clear caches *before* re-populating, matching the production + // code's `loadedDiffs = {}; summaries = {}` at the top of fetchComparison. + state.loadedDiffs = {} + state.summaries = {} + for (const it of items) { + // loadDiffFor's cache-hit shortcut: if state.loadedDiffs[key] exists, + // it returns early. With the reset above this is always false here, so + // every item gets fresh content. + if (state.loadedDiffs[it.key]) continue + state.loadedDiffs[it.key] = { state: 'ready', parentRaw: it.parentRaw, forkRaw: it.forkRaw } + state.summaries[it.key] = it.summary + } +} + +describe('ForkDiffDrawer.fetchComparison — cache invalidation', () => { + it('reopen after edit replaces stale per-item raw content', () => { + const state = { + loadedDiffs: {} as Record, + summaries: {} as Record + } + + // First open: populate. + simulateFetchComparison(state, [ + { key: 'script/f/foo', parentRaw: 'v1', forkRaw: 'v1-fork', summary: 'summary v1' } + ]) + expect(state.loadedDiffs['script/f/foo']).toEqual({ + state: 'ready', + parentRaw: 'v1', + forkRaw: 'v1-fork' + }) + expect(state.summaries['script/f/foo']).toBe('summary v1') + + // User edits the script in the session editor, closes drawer, reopens. + // Without the reset, the cache-hit shortcut would keep 'v1-fork'. + simulateFetchComparison(state, [ + { key: 'script/f/foo', parentRaw: 'v1', forkRaw: 'v2-fork', summary: 'summary v2' } + ]) + expect(state.loadedDiffs['script/f/foo']).toEqual({ + state: 'ready', + parentRaw: 'v1', + forkRaw: 'v2-fork' + }) + expect(state.summaries['script/f/foo']).toBe('summary v2') + }) + + it('reopen drops entries for items that vanished from the new comparison', () => { + const state = { + loadedDiffs: {} as Record, + summaries: {} as Record + } + + simulateFetchComparison(state, [ + { key: 'script/f/a', parentRaw: 'x', forkRaw: 'x-fork', summary: 'a' }, + { key: 'flow/f/b', parentRaw: 'y', forkRaw: 'y-fork', summary: 'b' } + ]) + expect(Object.keys(state.loadedDiffs).sort()).toEqual(['flow/f/b', 'script/f/a']) + + // User merges 'flow/f/b' so it's no longer ahead of parent. + simulateFetchComparison(state, [ + { key: 'script/f/a', parentRaw: 'x', forkRaw: 'x-fork', summary: 'a' } + ]) + // Without the reset, the orphan entry would linger in loadedDiffs and + // the tree could render a row whose raw content references a path no + // longer in the live comparison. + expect(Object.keys(state.loadedDiffs)).toEqual(['script/f/a']) + expect(state.summaries['flow/f/b']).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/sessions/forkDiffNav.test.ts b/frontend/src/lib/components/sessions/forkDiffNav.test.ts new file mode 100644 index 0000000000..3266ffaa9f --- /dev/null +++ b/frontend/src/lib/components/sessions/forkDiffNav.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { parentFolderKey } from './forkDiffNav' + +describe('parentFolderKey', () => { + describe('folders', () => { + it('a scope folder (2 segments) has no parent', () => { + expect(parentFolderKey('folder', 'f/foo')).toBeUndefined() + expect(parentFolderKey('folder', 'u/alice')).toBeUndefined() + }) + it('a nested folder belongs to its parent folder', () => { + expect(parentFolderKey('folder', 'f/foo/sub')).toBe('folder:f/foo') + expect(parentFolderKey('folder', 'f/foo/sub/deep')).toBe('folder:f/foo/sub') + }) + }) + + describe('files', () => { + it('a single-segment leaf has no scope folder', () => { + expect(parentFolderKey('file', 'orphan')).toBeUndefined() + }) + it('a 2-segment file maps to its scope folder (the cubic fix)', () => { + // Regression: previously returned `folder:f` (nonexistent) → ArrowLeft broke. + expect(parentFolderKey('file', 'f/foo')).toBe('folder:f/foo') + }) + it('a 3-segment file (directly under scope) maps to the scope folder', () => { + expect(parentFolderKey('file', 'f/foo/bar')).toBe('folder:f/foo') + expect(parentFolderKey('file', 'u/alice/script')).toBe('folder:u/alice') + }) + it('a deeper file belongs to its immediate folder', () => { + expect(parentFolderKey('file', 'f/foo/sub/bar')).toBe('folder:f/foo/sub') + }) + }) +}) diff --git a/frontend/src/lib/components/sessions/forkDiffNav.ts b/frontend/src/lib/components/sessions/forkDiffNav.ts new file mode 100644 index 0000000000..6dfe829cf3 --- /dev/null +++ b/frontend/src/lib/components/sessions/forkDiffNav.ts @@ -0,0 +1,25 @@ +// Keyboard-navigation helpers for the fork-diff tree (ForkDiffDrawer). +// +// The tree groups diffs by "scope" (the first two path segments, e.g. `f/foo` +// or `u/alice`); deeper segments become nested folders. Folder keys are +// `folder:`. See ForkDiffDrawer's tree builder. + +/** + * The folder key that contains an entry (for ArrowLeft "go to parent"), or + * `undefined` when the entry sits at the top with no parent folder. + * + * - A scope folder (≤2 segments) has no parent. + * - A single-segment file leaf sits at the tree root with no scope folder. + * - A file directly at its scope (`f/foo` or `f/foo/bar`) belongs to the scope + * folder = first two segments (`folder:f/foo`). + * - Anything deeper belongs to its immediate folder. + */ +export function parentFolderKey(kind: 'folder' | 'file', path: string): string | undefined { + const parts = path.split('/') + if (kind === 'folder' && parts.length <= 2) return undefined + if (kind === 'file') { + if (parts.length < 2) return undefined + if (parts.length <= 3) return `folder:${parts.slice(0, 2).join('/')}` + } + return `folder:${parts.slice(0, -1).join('/')}` +} diff --git a/frontend/src/lib/components/sessions/forkEditUrl.ts b/frontend/src/lib/components/sessions/forkEditUrl.ts new file mode 100644 index 0000000000..e2cecb5c02 --- /dev/null +++ b/frontend/src/lib/components/sessions/forkEditUrl.ts @@ -0,0 +1,14 @@ +import type { WorkspaceItemDiff } from '$lib/gen' + +// Editor URL for a workspace-item diff, scoped to a given workspace. Returns +// undefined for kinds we don't have a dedicated editor for (resource, +// variable, schedule, triggers, …). +export function editUrlFor(d: WorkspaceItemDiff, workspaceId: string): string | undefined { + const ws = encodeURIComponent(workspaceId) + const path = d.path + if (d.kind === 'flow') return `/flows/edit/${path}?workspace=${ws}` + if (d.kind === 'script') return `/scripts/edit/${path}?workspace=${ws}` + if (d.kind === 'app') return `/apps/edit/${path}?workspace=${ws}` + if (d.kind === 'raw_app') return `/apps_raw/edit/${path}?workspace=${ws}` + return undefined +} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts new file mode 100644 index 0000000000..15912a28e9 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -0,0 +1,700 @@ +import { SvelteMap, SvelteSet } from 'svelte/reactivity' +import { get } from 'svelte/store' +import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' +import { initFlow } from '$lib/components/flows/flowStore.svelte' +import { + AppService, + FlowService, + ScriptService, + WorkspaceService, + type Flow, + type NewScript, + type NewScriptWithDraft, + type WorkspaceComparison +} from '$lib/gen' +import type { HiddenRunnable } from '$lib/components/apps/types' +import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' +import { workspaceStore } from '$lib/stores' +import { emptySchema, type StateStore } from '$lib/utils' +import { + commitSessionWorkspace, + deleteSession as deleteSessionState, + ensureChatIdsSeeded, + materializeTransient, + sessionState, + setSessionChatId, + setSessionTarget, + type Session, + type SessionTarget +} from './sessionState.svelte' +import { UserDraft } from '$lib/userDraft.svelte' +import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft, type RawAppDraft } from './appDraftCodec' +import { + setDeployedInSessionHandler, + setGetPreviewStatusHandler, + setOpenPreviewHandler +} from '$lib/components/copilot/chat/global/core' + +export interface SessionRuntime { + readonly sessionId: string + readonly manager: AIChatManager + // Flow target state + readonly flowStore: StateStore + readonly flowStateStore: { val: Record } + readonly savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } + readonly loadingFlow: boolean + readonly notFound: boolean + readonly loadedPath: string | undefined + loadFlow(workspace: string, path: string, force?: boolean): Promise + // Script target state (parallel to flow, populated only for script-targeted sessions) + readonly scriptStore: { val: NewScript | undefined } + readonly savedScript: { val: NewScriptWithDraft | undefined } + readonly loadingScript: boolean + readonly notFoundScript: boolean + readonly loadedScriptPath: string | undefined + loadScript(workspace: string, path: string, force?: boolean): Promise + // Note: legacy drag-and-drop apps are intentionally NOT hosted in the + // session preview pane (only code-based raw apps are), so there's no + // app target state here. + // Raw App (HTML-based) target state + readonly rawApp: { + val: + | { + files: Record + runnables: Record + data: RawAppData + policy: any + summary: string + path: string + custom_path?: string + } + | undefined + } + readonly savedRawApp: { + val: + | { + value: { + files: Record + runnables: Record + } + draft?: any + path: string + summary: string + policy: any + draft_only?: boolean + custom_path?: string + } + | undefined + } + readonly loadingRawApp: boolean + readonly notFoundRawApp: boolean + readonly loadedRawAppPath: string | undefined + loadRawApp(workspace: string, path: string, force?: boolean): Promise + // Discard the local draft + refresh the fork diff + force-reload the editor, + // so the preview matches the deployed version. Used by editor onDeploy + the + // chat deploy handler. + syncPreviewWithDeployed( + workspace: string, + kind: 'script' | 'flow' | 'raw_app', + path: string + ): void + // Fork comparison cache: shared between SessionForkBar (count + dropdown) + // and any future consumer that needs the parent ↔ fork diff list. Keyed + // implicitly by the (parent, fork) pair last passed to ensureForkComparison; + // invalidateForkComparison() forces a refresh after a known-mutating action. + readonly forkComparison: { val: WorkspaceComparison | undefined } + readonly loadingForkComparison: boolean + ensureForkComparison(parent: string, fork: string): Promise + invalidateForkComparison(): void + // Force-refresh against the last (parent, fork) pair the runtime + // fetched for. No-op if no comparison has ever been loaded. Useful + // for session-activation hooks that need a fresh count regardless of + // the dedupe key match. + refreshForkComparison(): Promise + // Re-fetch the comparison shortly after a local mutation (e.g. an + // editor "Save draft"). The backend tally that registers the change + // lands asynchronously, so an immediate refresh races it — this + // schedules a couple of delayed refreshes so the fork-bar count + // updates without waiting for an AI turn or a tab refocus. + scheduleForkComparisonRefresh(): void + // Cancel pending fork-comparison refresh timers. Called by disposeRuntime so + // a torn-down (e.g. LRU-evicted, deleted) runtime can't fire a stray + // refreshForkComparisonNow / compareWorkspaces after it's gone. + dispose(): void +} + +const runtimes = new SvelteMap() + +function emptyFlow(): Flow { + return { + summary: '', + value: { modules: [] }, + path: '', + edited_at: '', + edited_by: '', + archived: false, + extra_perms: {}, + schema: emptySchema() + } +} + +function createRuntime(session: Session): SessionRuntime { + const manager = new AIChatManager() + manager.disabledModes = { navigator: true } + // Sessions always operate in GLOBAL mode (workspace-item tools across + // the session's workspace). The page-level gate already requires the + // global-AI flag, so this is always available here. Mode is locked + // and the dropdown is hidden in the chat UI. + manager.mode = AIMode.GLOBAL + // Session chats drive a side-panel preview, so they get the session-only + // preview tools (open_preview / get_preview_status); the global side-panel + // chat does not. + manager.isSessionChat = true + // Carried into the tool helpers so this session's preview/deploy tool calls + // dispatch to THIS session even when another session is the UI-active one. + manager.sessionId = session.id + // Pre-flight: materialise the (still-transient) session, then commit + // the workspace (creating a staged fork if needed) before any send. + // AIChatManager awaits this so the first message hits a persisted + // session targeting the right workspace. Both calls are idempotent. + manager.beforeSend = async () => { + materializeTransient(session.id) + const committed = await commitSessionWorkspace(session.id, get(workspaceStore) ?? undefined) + // commitSessionWorkspace returns undefined only when the session did NOT + // commit to a workspace — most importantly when a staged fork failed to + // materialise (materializeFork is built to toast + return undefined rather + // than throw). Throwing here is what makes AIChatManager.sendRequest abort: + // otherwise the send proceeds against get(workspaceStore) (the parent for a + // staged-new-fork draft), shipping the message + its tool calls to the + // wrong workspace while the pending_fork is silently dropped. + if (!committed) { + throw new Error( + 'the session workspace could not be created or committed (fork creation may have failed)' + ) + } + } + + const flowStore: StateStore = $state({ val: emptyFlow() }) + const flowStateStore: { val: Record } = $state({ val: {} }) + const savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } = $state({ + val: undefined + }) + + let loadingFlow = $state(false) + let notFound = $state(false) + let loadedPath = $state(undefined) + + const scriptStore: { val: NewScript | undefined } = $state({ val: undefined }) + const savedScript: { val: NewScriptWithDraft | undefined } = $state({ val: undefined }) + let loadingScript = $state(false) + let notFoundScript = $state(false) + let loadedScriptPath = $state(undefined) + + const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined }) + const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined }) + let loadingRawApp = $state(false) + let notFoundRawApp = $state(false) + let loadedRawAppPath = $state(undefined) + + const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined }) + let loadingForkComparison = $state(false) + let forkComparisonKey: string | undefined = undefined + let forkRefreshTimers: ReturnType[] = [] + + // Stale-while-revalidate re-fetch against the last (parent, fork) pair. + // Shared by refreshForkComparison() and scheduleForkComparisonRefresh(). + async function refreshForkComparisonNow() { + const key = forkComparisonKey + if (!key) return + const sep = key.indexOf('|') + if (sep < 0) return + const parent = key.slice(0, sep) + const fork = key.slice(sep + 1) + if (loadingForkComparison) return + loadingForkComparison = true + try { + forkComparison.val = await WorkspaceService.compareWorkspaces({ + workspace: parent, + targetWorkspaceId: fork + }) + } catch (e) { + console.error('SessionRuntime: forkComparison refresh failed', e) + } finally { + loadingForkComparison = false + } + } + + return { + sessionId: session.id, + manager, + flowStore, + flowStateStore, + savedFlow, + get loadingFlow() { + return loadingFlow + }, + get notFound() { + return notFound + }, + get loadedPath() { + return loadedPath + }, + + async loadFlow(workspace: string, path: string, force = false) { + if (loadedPath === path && !force) return + // See loadScript: forced reload remounts via the render gate. + if (force) loadedPath = undefined + loadingFlow = true + notFound = false + try { + // Draft first. UserDraft is the shared authoritative content + // source — the chat (write_flow / patch_flow_json / + // set_flow_module_code) and the editor's outbound $effect both + // write through it. If a draft exists we render from it, even + // when the path has never been deployed. + const aiDraft = UserDraft.get('flow', path, { workspace }) + + // getFlowByPathWithDraft omits version_id (the diff's deployed side, + // via getFlowByPath, has it) — stamp it onto the editing flow so it + // doesn't always diff. Best-effort (a never-deployed flow has none). + let deployedVersionId: number | undefined + try { + deployedVersionId = (await FlowService.getFlowByPath({ workspace, path }))?.version_id + } catch { + deployedVersionId = undefined + } + + if (aiDraft) { + // Best-effort fetch the backend baseline for the diff + // drawer. Don't fail the load if the path doesn't exist + // yet on the backend — draft-only flows are a valid state. + try { + const result = await FlowService.getFlowByPathWithDraft({ workspace, path }) + savedFlow.val = result + } catch { + savedFlow.val = undefined + } + await initFlow(aiDraft, flowStore, flowStateStore) + if (deployedVersionId != null && flowStore.val) + flowStore.val.version_id = deployedVersionId + loadedPath = path + return + } + + // No draft yet. Seed one from the last deploy (or the + // backend-side draft, if one exists). + const result = await FlowService.getFlowByPathWithDraft({ workspace, path }) + savedFlow.val = result + const flow: Flow = (result.draft as Flow | undefined) ?? (result as Flow) + UserDraft.save('flow', path, flow, { workspace }) + await initFlow(flow, flowStore, flowStateStore) + if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId + loadedPath = path + } catch (err) { + console.error('Failed to load flow', err) + notFound = true + } finally { + loadingFlow = false + } + }, + + scriptStore, + savedScript, + get loadingScript() { + return loadingScript + }, + get notFoundScript() { + return notFoundScript + }, + get loadedScriptPath() { + return loadedScriptPath + }, + + async loadScript(workspace: string, path: string, force = false) { + if (loadedScriptPath === path && !force) return + // Forced reload: clearing loadedScriptPath drops us into the + // `{#if loading && !loadedScriptPath}` gate, which unmounts then remounts + // the editor — avoids the Monaco init race a synchronous {#key} would hit. + if (force) loadedScriptPath = undefined + loadingScript = true + notFoundScript = false + try { + // Draft first. UserDraft is the shared authoritative content + // source — the chat (write_script / edit_script) and the + // editor's outbound $effect both write through it. If a draft + // exists we render from it, even when the path has never been + // deployed. + const aiDraft = UserDraft.get('script', path, { workspace }) + + if (aiDraft && typeof aiDraft.content === 'string') { + // Best-effort fetch the backend baseline for the diff + // drawer + parent_hash. 404 means draft-only — leave + // savedScript undefined and skip parent_hash. + try { + const result = await ScriptService.getScriptByPathWithDraft({ workspace, path }) + savedScript.val = result + } catch { + savedScript.val = undefined + } + // Clone the deployed/backend baseline before layering the AI + // draft on top — otherwise we'd mutate `savedScript.val` in + // place (it's the same object) and lose the pristine baseline + // that the diff drawer + the deploy/discard affordance compare + // against. + const baseline: NewScript = savedScript.val + ? (structuredClone( + $state.snapshot( + (savedScript.val.draft as NewScript | undefined) ?? (savedScript.val as NewScript) + ) + ) as NewScript) + : { + path, + summary: aiDraft.summary ?? '', + content: '', + description: '', + schema: emptySchema(), + language: (aiDraft.language ?? 'bun') as any + } + if (savedScript.val?.hash) { + baseline.parent_hash = savedScript.val.hash + } + baseline.content = aiDraft.content + if (aiDraft.language) baseline.language = aiDraft.language + if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary + scriptStore.val = baseline + loadedScriptPath = path + return + } + + // No draft yet. Seed from backend. + const result = await ScriptService.getScriptByPathWithDraft({ workspace, path }) + savedScript.val = result + // Clone before mutating: when result.draft is falsy, `baseline` would + // otherwise alias `result` (= savedScript.val), so baseline.parent_hash + // would corrupt the pristine deployed baseline the diff drawer reads. + const baseline = structuredClone( + (result.draft as NewScript | undefined) ?? (result as NewScript) + ) + baseline.parent_hash = result.hash + UserDraft.save('script', path, baseline, { workspace }) + scriptStore.val = baseline + loadedScriptPath = path + } catch (err) { + console.error('Failed to load script', err) + notFoundScript = true + } finally { + loadingScript = false + } + }, + + rawApp, + savedRawApp, + get loadingRawApp() { + return loadingRawApp + }, + get notFoundRawApp() { + return notFoundRawApp + }, + get loadedRawAppPath() { + return loadedRawAppPath + }, + + async loadRawApp(workspace: string, path: string, force = false) { + if (loadedRawAppPath === path && !force) return + // See loadScript: forced reload remounts via the render gate. + if (force) loadedRawAppPath = undefined + loadingRawApp = true + notFoundRawApp = false + try { + // Draft first. UserDraft is the shared authoritative content + // source — the chat (init_app / write_app_file / ...) and the + // editor's outbound $effect both write through it. If a draft + // exists we render from it, even when the path has never been + // deployed. + const aiDraft = UserDraft.get('raw_app', path, { workspace }) + + if (aiDraft) { + // Best-effort fetch the backend baseline for the diff + // drawer. Don't fail the load if the path doesn't exist + // yet on the backend — draft-only apps are a valid state. + try { + const result = await AppService.getAppByPathWithDraft({ workspace, path }) + savedRawApp.val = { + summary: result.summary, + value: result.value as any, + path: result.path, + policy: result.policy, + draft_only: result.draft_only, + draft: result.draft, + custom_path: result.custom_path + } + } catch { + savedRawApp.val = undefined + } + rawApp.val = applyDraftToRuntimeRawApp( + { + files: {}, + runnables: {}, + data: { ...DEFAULT_DATA }, + policy: undefined, + summary: aiDraft.summary ?? '', + path + }, + aiDraft + ) + loadedRawAppPath = path + return + } + + // No draft yet. Seed one from the last deploy (or the + // backend-side draft, if one exists — that's the user's + // "Save draft" content from the standalone editor and is + // fresher than `value`). + const result = await AppService.getAppByPathWithDraft({ workspace, path }) + savedRawApp.val = { + summary: result.summary, + value: result.value as any, + path: result.path, + policy: result.policy, + draft_only: result.draft_only, + draft: result.draft, + custom_path: result.custom_path + } + const sourceValue: any = result.draft ?? result.value + let data: RawAppData = { ...DEFAULT_DATA } + if (sourceValue?.data) { + const d = sourceValue.data + if (d.creation) { + data = { + tables: d.tables ?? [], + datatable: d.creation.datatable, + schema: d.creation.schema + } + } else { + data = d + } + } else if (sourceValue?.datatables) { + data = { ...DEFAULT_DATA, tables: sourceValue.datatables } + } + const runtimeValue = { + files: (sourceValue?.files ?? {}) as Record, + runnables: (sourceValue?.runnables ?? {}) as Record, + data, + policy: result.policy, + summary: result.summary ?? '', + path: result.path, + custom_path: result.custom_path + } + UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace }) + rawApp.val = runtimeValue + loadedRawAppPath = path + } catch (err) { + console.error('Failed to load raw app', err) + notFoundRawApp = true + } finally { + loadingRawApp = false + } + }, + + syncPreviewWithDeployed(workspace, kind, path) { + this.scheduleForkComparisonRefresh() + UserDraft.discard(kind, path, undefined, { workspace }) + if (kind === 'script') void this.loadScript(workspace, path, true) + else if (kind === 'flow') void this.loadFlow(workspace, path, true) + else void this.loadRawApp(workspace, path, true) + }, + + forkComparison, + get loadingForkComparison() { + return loadingForkComparison + }, + + async ensureForkComparison(parent: string, fork: string) { + const key = `${parent}|${fork}` + if (forkComparisonKey === key && forkComparison.val) return + if (loadingForkComparison && forkComparisonKey === key) return + forkComparisonKey = key + loadingForkComparison = true + try { + forkComparison.val = await WorkspaceService.compareWorkspaces({ + workspace: parent, + targetWorkspaceId: fork + }) + } catch (e) { + console.error('SessionRuntime: forkComparison fetch failed', e) + forkComparison.val = undefined + // On error, clear the key so the next call retries. + if (forkComparisonKey === key) forkComparisonKey = undefined + } finally { + loadingForkComparison = false + } + }, + + invalidateForkComparison() { + forkComparisonKey = undefined + forkComparison.val = undefined + }, + + // Stale-while-revalidate: re-fetch in place so the cached status + // (driving the sidebar dot, fork bar, etc.) stays put until the new + // result lands. Clearing forkComparison.val here flickered the icon + // back to the neutral GitFork on every session-activate refresh. + refreshForkComparison() { + return refreshForkComparisonNow() + }, + + scheduleForkComparisonRefresh() { + // The backend fork tally registers a draft save (createScript + // draft_only / DraftService.createDraft) asynchronously — ~300ms + // after the API call returns — so an immediate refresh races it. + // Fetch once after the tally typically lands, then again as a + // backstop for slower backends. Coalesce rapid saves by clearing + // any still-pending timers first. + for (const t of forkRefreshTimers) clearTimeout(t) + forkRefreshTimers = [ + setTimeout(() => void refreshForkComparisonNow(), 700), + setTimeout(() => void refreshForkComparisonNow(), 2200) + ] + }, + dispose() { + for (const t of forkRefreshTimers) clearTimeout(t) + forkRefreshTimers = [] + } + } +} + +async function initRuntime(runtime: SessionRuntime, session: Session) { + const { manager } = runtime + await manager.historyManager.init() + manager.historyManager.setSessionId(session.id) + await ensureChatIdsSeeded(manager.historyManager) + + if (session.chatId) { + manager.historyManager.setCurrentChatId(session.chatId) + await manager.historyManager.tagChatWithSession(session.chatId, session.id) + await manager.loadPastChat(session.chatId) + } else { + setSessionChatId(session.id, manager.historyManager.getCurrentChatId()) + } +} + +export function getOrCreateRuntime(session: Session): SessionRuntime { + let runtime = runtimes.get(session.id) + if (!runtime) { + runtime = createRuntime(session) + runtimes.set(session.id, runtime) + initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + } + return runtime +} + +export function disposeRuntime(sessionId: string) { + const runtime = runtimes.get(sessionId) + if (!runtime) return + runtime.dispose() + runtime.manager.cancel('runtime disposed') + runtime.manager.historyManager.close() + runtimes.delete(sessionId) +} + +export function listRuntimes(): SessionRuntime[] { + return Array.from(runtimes.values()) +} + +export function getRuntime(sessionId: string): SessionRuntime | undefined { + return runtimes.get(sessionId) +} + +export type SessionChatStatus = + | 'idle' + | 'streaming' + | 'awaiting-user' + | 'needs-confirmation' + | 'draft' + | 'error' + +// MRU set of session ids whose FlowEditorView is currently mounted. Capped at +// MAX_WARM_EDITORS — sessions outside the set show chat-only. Module-scoped so +// both the page (which mutates) and the sidebar (which reads for the dev clue) +// see the same state. +const MAX_WARM_EDITORS = 3 +export const editorWarmIds = new SvelteSet() + +// Full session teardown: dispose the runtime, drop the LRU entry, and remove +// from sessionState in one call. Callers (sidebar / header dropdowns) just +// invoke this; navigation away from a deleted active session is the caller's +// responsibility. +export function removeSession(sessionId: string): void { + disposeRuntime(sessionId) + editorWarmIds.delete(sessionId) + deleteSessionState(sessionId) +} + +export function promoteEditorWarm(sessionId: string): void { + editorWarmIds.delete(sessionId) + editorWarmIds.add(sessionId) + while (editorWarmIds.size > MAX_WARM_EDITORS) { + const oldest = editorWarmIds.values().next().value + if (oldest === undefined) break + editorWarmIds.delete(oldest) + } +} + +// Register the global open_preview tool handler once at module load. It +// dispatches to the *calling* session (sessionId threaded from the tool ctx), +// falling back to the UI-active session only when none was passed — so a +// backgrounded session's tool call opens its OWN preview, not the one the user +// happens to be viewing. Outside a session there is no calling/active id and +// the tool returns a polite error. +setOpenPreviewHandler(({ sessionId: callerSessionId, kind, path }) => { + const sessionId = callerSessionId ?? sessionState.currentSessionId + if (!sessionId) { + return 'Error: no active session to open the preview in.' + } + const current = sessionState.sessions.find((s) => s.id === sessionId)?.target + if (current && current.kind === kind && current.path === path) { + return `Preview is already open showing ${kind} "${path}".` + } + const target: SessionTarget = { kind, path } + setSessionTarget(sessionId, target) + promoteEditorWarm(sessionId) + return `Opened ${kind} preview for ${path} in the side panel.` +}) + +// Companion to the open_preview handler: report whether the calling session's +// preview is open and which item it shows, so the assistant can avoid +// re-opening a preview already showing the item it just edited. +setGetPreviewStatusHandler((callerSessionId) => { + const sessionId = callerSessionId ?? sessionState.currentSessionId + if (!sessionId) return 'No active session; the preview panel is unavailable.' + const target = sessionState.sessions.find((s) => s.id === sessionId)?.target + if (!target) return 'No preview is currently open in the side panel.' + return `The preview is currently open showing ${target.kind} "${target.path}".` +}) + +// After a chat deploy, reload the calling session's preview — only if it's open +// showing that exact item. +setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => { + const sessionId = callerSessionId ?? sessionState.currentSessionId + if (!sessionId) return + const session = sessionState.sessions.find((s) => s.id === sessionId) + const runtime = runtimes.get(sessionId) + if (!session?.workspace_id || !runtime) return + const open = + (kind === 'script' && runtime.loadedScriptPath === path) || + (kind === 'flow' && runtime.loadedPath === path) || + (kind === 'raw_app' && runtime.loadedRawAppPath === path) + if (!open) return + runtime.syncPreviewWithDeployed(session.workspace_id, kind, path) +}) + +export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus { + const m = runtime.manager + if (m.loading) return 'streaming' + if (m.instructions.trim().length > 0) return 'draft' + const last = m.displayMessages[m.displayMessages.length - 1] + if (last?.role === 'tool' && last.needsConfirmation) return 'needs-confirmation' + if (last?.role === 'user' && last.error) return 'error' + if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user' + return 'idle' +} diff --git a/frontend/src/lib/components/sessions/sessionScope.svelte.ts b/frontend/src/lib/components/sessions/sessionScope.svelte.ts new file mode 100644 index 0000000000..a69074fdf8 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionScope.svelte.ts @@ -0,0 +1,32 @@ +import { derived, type Readable } from 'svelte/store' +import { workspaceStore, userWorkspaces, type UserWorkspace } from '$lib/stores' +import { findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy' + +// Walk up parent_workspace_id chain to find the root of the fork family +// containing `id`. Falls back to the workspace itself if it has no parent +// (or its parent isn't in the user's list). +function findFamilyRoot(id: string, all: UserWorkspace[]): UserWorkspace | undefined { + let current = all.find((w) => w.id === id) + while (current?.parent_workspace_id) { + const parent = all.find((w) => w.id === current!.parent_workspace_id) + if (!parent) break + current = parent + } + return current +} + +// Set of workspace ids a session must belong to for the user to see it in +// the sidebar list. The whole fork family is visible from any node: when +// the user is inside fork A whose root is R, sessions belonging to R or +// any sibling fork of A are listed too. Recomputes when the user switches +// workspace or when the workspace list refreshes. +export const visibleWorkspaceIds: Readable> = derived( + [workspaceStore, userWorkspaces], + ([ws, all]) => { + if (!ws) return new Set() + const root = findFamilyRoot(ws, all) ?? ({ id: ws } as UserWorkspace) + const ids = new Set([root.id]) + for (const d of findWorkspaceDescendants(root.id, all)) ids.add(d.id) + return ids + } +) diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts new file mode 100644 index 0000000000..6c1411eb35 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -0,0 +1,501 @@ +import { BROWSER } from 'esm-env' +import { get } from 'svelte/store' +import { createLongHash } from '$lib/editorLangUtils' +import { random_adj } from '$lib/components/random_positive_adjetive' +import { + userWorkspaces, + usersWorkspaceStore, + workspaceStore, + type UserWorkspace +} from '$lib/stores' +import { switchWorkspace } from '$lib/storeUtils' + +// Switch the global workspace iff the target differs from the active one +// and is non-empty. Centralises the "session needs its workspace in focus" +// rule so picker, deep-link, and workspace-bar paths agree on the same +// semantic. No-op for `undefined` / empty. +export function syncWorkspaceTo(workspaceId: string | undefined): void { + if (!workspaceId) return + if (workspaceId === get(workspaceStore)) return + switchWorkspace(workspaceId) +} +import { WorkspaceService, type WorkspaceComparison } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte' + +// Kinds the in-session editor pane can host. Legacy drag-and-drop apps are +// intentionally not previewable — only code-based 'raw_app' apps are. +export type SessionTarget = { kind: 'flow' | 'script' | 'raw_app'; path: string } + +// Useful for filtering dropdowns / pickers to "items the side panel can open". +export const EDITOR_TARGET_KINDS: ReadonlySet = new Set([ + 'flow', + 'script', + 'raw_app' +]) + +// Lifecycle status for a fork session. Git-parallel: +// in_sync — fork is up to date with parent (or only behind — treated +// the same since the user has no unmerged work either way). +// ahead — fork has unmerged changes vs parent (branch ahead). +// diverged — fork has unmerged changes AND parent has moved (branch +// diverged from upstream — potential conflicts). +// unavailable — fork workspace is no longer in the user's list (deleted, +// archived, or access revoked). Read-only fallback. +// +// `undefined` is the loading / not-applicable state (root session, +// comparison not yet fetched). +export type ForkStatus = 'in_sync' | 'ahead' | 'diverged' | 'unavailable' + +// Whether the session points at a workspace that is itself a fork (i.e. +// has a parent). Independent of comparison-fetch state — used by the +// sidebar to pick between a root (Building) icon and a fork-status icon +// before the comparison has loaded. +// +// Sessions whose committed workspace is no longer in the user's list are +// still treated as forks (the "unavailable" terminal state) so we don't +// flip them back to Building once access is lost. +export function isForkSession(session: Session, allWorkspaces: UserWorkspace[]): boolean { + const wsId = session.workspace_id ?? session.pending_workspace_id + if (!wsId) return false + const ws = allWorkspaces.find((w) => w.id === wsId) + if (!ws) return !!session.workspace_id + return !!ws.parent_workspace_id +} + +export function deriveForkStatus( + session: Session, + allWorkspaces: UserWorkspace[], + comparison: WorkspaceComparison | undefined +): ForkStatus | undefined { + const wsId = session.workspace_id ?? session.pending_workspace_id + if (!wsId) return undefined + const ws = allWorkspaces.find((w) => w.id === wsId) + // Committed fork workspaces that disappear from the user's list + // (deleted, archived, or access lost) are flagged unavailable so + // the UI can render a terminal state without trying to switch into + // them. Drafts whose pending workspace also vanished get the same + // treatment. + if (!ws) return session.workspace_id ? 'unavailable' : undefined + if (!ws.parent_workspace_id) return undefined + if (!comparison) return undefined + const ahead = comparison.summary?.total_ahead ?? 0 + const behind = comparison.summary?.total_behind ?? 0 + if (ahead > 0 && behind > 0) return 'diverged' + if (ahead > 0) return 'ahead' + return 'in_sync' +} + +export type PendingFork = { + // Existing workspace to fork from (drives routing/scope pre-send). + parent_workspace_id: string + // Slug the new fork will use, e.g. `wm-fork-foo`. + id: string + // Display name shown in the workspace bar. + name: string +} + +export type Session = { + id: string + name: string + // Committed strictly at first user-message send. Undefined for drafts + // that have never been sent — those scope by `pending_workspace_id` + // instead and don't show the fork bar. + workspace_id?: string + // Pre-send draft workspace, picked via SessionWorkspaceBar. Drives + // scope/editor/display while workspace_id is undefined; gets copied + // into workspace_id at first send and then becomes irrelevant. + pending_workspace_id?: string + // Pre-send intent to create a new fork. The actual API call is + // deferred to first send (via commitSessionWorkspace) so cancelling + // the draft doesn't leave an orphan fork behind. + pending_fork?: PendingFork + chatId?: string + target?: SessionTarget + summary?: string + createdAt: number + // User-archived sessions are hidden from the sidebar by default + // (toggleable via the picker filter). Archive is reversible — distinct + // from delete, which removes the session entirely. + archived?: boolean + // In-memory-only flag: the session exists but isn't written to + // localStorage until the user sends their first message. Avoids + // piling abandoned drafts across `+` clicks — createSession reuses + // the existing transient if one is already open. + transient?: boolean +} + +const STORAGE_KEY = 'windmill_sessions' + +// New users (empty/cleared/private-browsing localStorage) start with no +// sessions — the sidebar + /sessions page render their empty states and the +// user creates the first session with `+`. Do NOT seed placeholder sessions +// here: hardcoded example paths won't resolve for other users and render as +// "session not found". +const defaultSessions: Session[] = [] + +function loadSessions(): Session[] { + if (!BROWSER) return defaultSessions + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed) && parsed.length > 0) { + // Drop empty-string workspace_id (older sessions used '' as a + // missing-value marker) so the undefined-until-first-send invariant + // holds for legacy drafts. Also migrate the deprecated + // 'rawapp' target.kind to the canonical 'raw_app'. + let mutated = false + for (const s of parsed) { + if (s.workspace_id === '') { + delete s.workspace_id + mutated = true + } + if (s.target?.kind === 'rawapp') { + s.target.kind = 'raw_app' + mutated = true + } + } + if (mutated) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)) + } catch (e) { + console.error('Failed to persist normalised sessions', e) + } + } + return parsed as Session[] + } + } + } catch (e) { + console.error('Failed to load sessions from localStorage', e) + } + return defaultSessions +} + +export const sessionState = $state<{ + sessions: Session[] + currentSessionId: string | undefined +}>({ + sessions: loadSessions(), + currentSessionId: undefined +}) + +export function persistSessions() { + if (!BROWSER) return + try { + // Transient (unsent) sessions stay in memory only. They get + // materialised — and from then on written to storage — when the + // user sends their first message. + const toPersist = $state.snapshot(sessionState.sessions).filter((s) => !s.transient) + localStorage.setItem(STORAGE_KEY, JSON.stringify(toPersist)) + } catch (e) { + console.error('Failed to persist sessions', e) + } +} + +export function findSessionByName(name: string): Session | undefined { + return sessionState.sessions.find((s) => s.name === name) +} + +// Walk up parent_workspace_id to the family root, given a starting +// workspace id. Returns the input id if no parent chain is visible. +function familyRootId(id: string | undefined, all: UserWorkspace[]): string | undefined { + if (!id) return undefined + let cur = all.find((w) => w.id === id) + while (cur?.parent_workspace_id) { + const parent = all.find((w) => w.id === cur!.parent_workspace_id) + if (!parent) break + cur = parent + } + return cur?.id ?? id +} + +export function createSession(): Session { + // Reuse the existing transient session (if any) so the user can hit + // the "+" button repeatedly without piling drafts. The transient + // becomes a real session at first-message-send time. + const existingTransient = sessionState.sessions.find((s) => s.transient) + if (existingTransient) { + sessionState.currentSessionId = existingTransient.id + return existingTransient + } + const existingNumbers = sessionState.sessions + .map((s) => /^session-(\d+)$/.exec(s.name)?.[1]) + .map((n) => (n ? parseInt(n, 10) : 0)) + const next = (existingNumbers.length ? Math.max(...existingNumbers) : 0) + 1 + // Default to the family root rather than wherever the user happens + // to be — sessions usually start from "the canonical workspace" and + // the picker lets them switch to a fork later. + const currentWs = get(workspaceStore) + const root = familyRootId(currentWs ?? undefined, get(userWorkspaces)) + const pending = root ?? currentWs + // Friendly default summary so the header reads like "Zippy session" + // rather than "Untitled session" — assigned at create time, the user + // can still rename it (or it gets overwritten by an editor target). + const adj = random_adj() + const summary = `${adj.charAt(0).toUpperCase() + adj.slice(1)} session` + const session: Session = { + id: createLongHash(), + name: `session-${next}`, + summary, + pending_workspace_id: pending && pending.length > 0 ? pending : undefined, + createdAt: Date.now(), + transient: true + } + sessionState.sessions = [session, ...sessionState.sessions] + sessionState.currentSessionId = session.id + // persistSessions() filters out transients — this call is a no-op for + // the new draft, but kept so any other session mutations get flushed. + persistSessions() + return session +} + +// Promote an in-memory transient session to a persisted one. No-op when +// the session isn't transient. Called by the chat manager's beforeSend +// hook so the session is only written to localStorage once the user +// commits to it by sending their first message. +export function materializeTransient(id: string): void { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s || !s.transient) return + delete s.transient + persistSessions() +} + +export function setSessionPendingWorkspace(id: string, workspace_id: string) { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return + const changed = s.pending_workspace_id !== workspace_id || s.pending_fork !== undefined + s.pending_workspace_id = workspace_id + // Picking an existing workspace cancels any pending fork intent. + s.pending_fork = undefined + if (changed) persistSessions() +} + +// Records the user's intent to create a new fork without firing the API +// call yet. Routing/scope stay on the parent workspace until commit. +export function setSessionPendingFork(id: string, fork: PendingFork) { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return + s.pending_fork = { ...fork } + s.pending_workspace_id = fork.parent_workspace_id + persistSessions() +} + +// One-shot commit: locks in workspace_id at first user-message send. +// If a pending fork is set, materialises it via the API first, then +// switches the global workspace to the freshly created fork. Falls back +// to the pending pick, then the active workspace. Clears pending so it +// doesn't shadow later reads. +export async function commitSessionWorkspace( + id: string, + fallback?: string +): Promise { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return undefined + if (s.workspace_id) return s.workspace_id + + if (s.pending_fork) { + const fork = s.pending_fork + const newId = await materializeFork(fork) + if (!newId) { + // Real failure (not a recovered duplicate). Drop the pending + // fork so the session falls through to the workspace-pick + // fallback on the next call and the unavailable-banner UX + // can take over instead of looping on the same broken intent. + s.pending_fork = undefined + persistSessions() + return undefined + } + if (get(workspaceStore) !== newId) switchWorkspace(newId) + s.workspace_id = newId + s.pending_fork = undefined + s.pending_workspace_id = undefined + persistSessions() + return newId + } + + const ws = s.pending_workspace_id ?? fallback + if (!ws) return undefined + s.workspace_id = ws + s.pending_workspace_id = undefined + // `pending_workspace_id` defaults to the family root when created from + // inside a fork, so the committed workspace can differ from the active + // workspaceStore. Without this sync, the very first AI request's + // `logAiChat` and tool calls read the stale fork from workspaceStore + // while the session metadata says root. Mirrors the `switchWorkspace` + // in the pending_fork branch above. + if (get(workspaceStore) !== ws) syncWorkspaceTo(ws) + persistSessions() + return ws +} + +// Effective workspace for scope/routing — committed if set, otherwise the +// pre-send pending pick (which defaults to the workspace at create time). +// Pending forks route via their parent until creation lands. +export function getEffectiveWorkspaceId(session: Session): string | undefined { + return session.workspace_id ?? session.pending_workspace_id +} + +// Canonical mutation for session.target. Persists, optionally seeds the +// session summary, and centralises the path so callers don't reach into +// session.target directly. +export function setSessionTarget(id: string, target: SessionTarget, summary?: string): void { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return + s.target = target + if (!s.summary && summary) s.summary = summary + persistSessions() +} + +export function selectSession(id: string) { + sessionState.currentSessionId = id +} + +export function renameSession(id: string, newSummary: string) { + const trimmed = newSummary.trim() + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return + s.summary = trimmed.length > 0 ? trimmed : undefined + persistSessions() +} + +// Create a new fork workspace via the API, refresh the user-workspaces +// store, and return the new fork id. Used by both the first-send commit +// path (commitSessionWorkspace) and the move-session-to-a-new-fork path +// in the unavailable-session banner. Returns undefined on failure (a +// user-facing toast is already emitted). +// +// Self-heal: if `fork.id` is already present in the user-workspaces +// store, the previous create succeeded (whose response we apparently +// lost). Adopt it silently instead of re-POSTing — the API would +// otherwise reject with workspace_pkey. Likewise, if the API returns a +// duplicate-key error we refresh the store and adopt the existing row. +export async function materializeFork(fork: PendingFork): Promise { + if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id + try { + await WorkspaceService.createWorkspaceFork({ + workspace: fork.parent_workspace_id, + requestBody: { id: fork.id, name: fork.name } + }) + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + sendUserToast(`Created fork ${fork.name}`) + return fork.id + } catch (e: any) { + const msg = String(e?.body ?? e?.message ?? e) + if (/workspace_pkey|duplicate key/i.test(msg)) { + // Self-heal: the create likely already succeeded. Refresh + adopt the + // existing row. Guard this refresh — a second network failure here must + // NOT rethrow out of materializeFork (callers rely on the + // toast-and-return-undefined contract; an uncaught throw would bypass + // it and propagate up through commitSessionWorkspace/beforeSend). + try { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id + } catch (refreshErr) { + console.error('Failed to refresh workspaces during fork self-heal', refreshErr) + } + } + sendUserToast(`Could not create fork: ${msg}`, true) + return undefined + } +} + +// Re-assign a committed session to a different workspace. Used to rescue +// sessions whose original workspace was deleted / archived / had access +// revoked — the chat history (stored in IndexedDB keyed by session id) is +// preserved; only the workspace pointer changes. Drops pending fields +// since the session is already past the draft stage by definition. +export function moveSessionToWorkspace(id: string, newWorkspaceId: string) { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return + if (s.workspace_id === newWorkspaceId) return + s.workspace_id = newWorkspaceId + delete s.pending_workspace_id + delete s.pending_fork + persistSessions() +} + +// Create a brand-new fork and re-assign a committed session to it. Used +// by the unavailable-session banner's "Create new fork" path in the +// move dropdown. On success the global workspace is switched to the +// freshly created fork. +export async function moveSessionToNewFork( + id: string, + fork: PendingFork +): Promise { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return undefined + const newId = await materializeFork(fork) + if (!newId) return undefined + if (get(workspaceStore) !== newId) switchWorkspace(newId) + moveSessionToWorkspace(id, newId) + return newId +} + +export function setSessionArchived(id: string, archived: boolean) { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s) return + const next = archived ? true : undefined + if (s.archived === next) return + if (archived) s.archived = true + else delete s.archived + persistSessions() +} + +export function deleteSession(id: string) { + const idx = sessionState.sessions.findIndex((s) => s.id === id) + if (idx < 0) return + sessionState.sessions = sessionState.sessions.filter((s) => s.id !== id) + if (sessionState.currentSessionId === id) { + sessionState.currentSessionId = sessionState.sessions[0]?.id + } + persistSessions() +} + +export function setSessionChatId(sessionId: string, chatId: string) { + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (s && s.chatId !== chatId) { + s.chatId = chatId + persistSessions() + } +} + +let seedPromise: Promise | undefined + +// One-shot pairing of the user's two most-recently-modified saved chats with +// the two seeded sessions. Idempotent across all callers / SessionWrappers. +export function ensureChatIdsSeeded(historyManager: HistoryManager): Promise { + if (!seedPromise) { + seedPromise = (async () => { + try { + await historyManager.init() + // Read directly from storage so we see chats regardless of this manager's + // own session-scope filter (getPastChats would hide already-tagged ones). + const pastChats = historyManager.getAllSavedChats() + const untagged = pastChats + .filter((c) => !c.sessionId) + .sort((a, b) => b.lastModified - a.lastModified) + // Only seed pre-existing (persisted) sessions. Transient + // sessions are freshly created via the "+" button and must + // start with an empty chat — if the seed catches one (e.g. the + // user clicks "New session" before this one-shot runs), it + // would graft a previous discussion onto the new session. + const seedable = sessionState.sessions.filter((s) => !s.transient) + let mutated = false + for (let i = 0; i < Math.min(seedable.length, untagged.length); i++) { + if (!seedable[i].chatId) { + const chatId = untagged[i].id + const sessionId = seedable[i].id + seedable[i].chatId = chatId + await historyManager.tagChatWithSession(chatId, sessionId) + mutated = true + } + } + if (mutated) persistSessions() + } catch (e) { + console.error('Failed to seed chat ids from history', e) + } + })() + } + return seedPromise +} diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts new file mode 100644 index 0000000000..eda5890580 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi } from 'vitest' +import { get } from 'svelte/store' +import { + commitSessionWorkspace, + deriveForkStatus, + isForkSession, + sessionState, + type Session +} from './sessionState.svelte' +import { workspaceStore, type UserWorkspace } from '$lib/stores' +import type { WorkspaceComparison } from '$lib/gen' + +// Force createWorkspaceFork to fail so we can pin commitSessionWorkspace's +// failure contract (the invariant the beforeSend abort fix relies on). +vi.mock('$lib/gen', async (orig) => { + const actual = await orig() + return { + ...actual, + WorkspaceService: { + ...actual.WorkspaceService, + createWorkspaceFork: vi.fn().mockRejectedValue(new Error('fork creation failed')), + listUserWorkspaces: vi.fn().mockResolvedValue([]) + } + } +}) + +// Minimal fixtures — only the fields these pure helpers read matter; the rest +// is filled via cast so we don't track unrelated schema churn. +function session(over: Partial = {}): Session { + return { id: 's1', name: 'sess', createdAt: 0, ...over } +} +function ws(id: string, parent?: string): UserWorkspace { + return { id, name: id, parent_workspace_id: parent } as unknown as UserWorkspace +} +function comparison(total_ahead: number, total_behind: number): WorkspaceComparison { + return { summary: { total_ahead, total_behind } } as unknown as WorkspaceComparison +} + +describe('isForkSession', () => { + it('is false for a draft with no workspace at all', () => { + expect(isForkSession(session(), [])).toBe(false) + }) + + it('is false when the workspace is a (non-fork) root', () => { + expect(isForkSession(session({ workspace_id: 'root' }), [ws('root')])).toBe(false) + }) + + it('is true when the workspace has a parent (is a fork)', () => { + expect(isForkSession(session({ workspace_id: 'fork' }), [ws('fork', 'root')])).toBe(true) + }) + + it('treats a committed-but-missing workspace as a fork (terminal unavailable state)', () => { + expect(isForkSession(session({ workspace_id: 'gone' }), [])).toBe(true) + }) + + it('is false for a draft whose pending workspace is missing', () => { + expect(isForkSession(session({ pending_workspace_id: 'gone' }), [])).toBe(false) + }) + + it('resolves a draft via its pending workspace', () => { + expect(isForkSession(session({ pending_workspace_id: 'fork' }), [ws('fork', 'root')])).toBe( + true + ) + }) +}) + +describe('deriveForkStatus', () => { + it('is undefined for a draft with no workspace', () => { + expect(deriveForkStatus(session(), [], undefined)).toBeUndefined() + }) + + it('is unavailable when a committed workspace is no longer in the list', () => { + expect(deriveForkStatus(session({ workspace_id: 'gone' }), [], comparison(0, 0))).toBe( + 'unavailable' + ) + }) + + it('is undefined when a draft pending workspace is missing (not yet committed)', () => { + expect( + deriveForkStatus(session({ pending_workspace_id: 'gone' }), [], undefined) + ).toBeUndefined() + }) + + it('is undefined for a non-fork (root) workspace', () => { + expect( + deriveForkStatus(session({ workspace_id: 'root' }), [ws('root')], comparison(3, 3)) + ).toBeUndefined() + }) + + it('is undefined for a fork before the comparison has loaded', () => { + expect( + deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], undefined) + ).toBeUndefined() + }) + + it('is diverged when the fork is both ahead and behind', () => { + expect( + deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(2, 1)) + ).toBe('diverged') + }) + + it('is ahead when the fork has unmerged changes and the parent has not moved', () => { + expect( + deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(2, 0)) + ).toBe('ahead') + }) + + it('is in_sync when neither side is ahead', () => { + expect( + deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(0, 0)) + ).toBe('in_sync') + }) + + it('is in_sync when only the parent moved (behind-only, fork has no local changes)', () => { + expect( + deriveForkStatus(session({ workspace_id: 'fork' }), [ws('fork', 'root')], comparison(0, 2)) + ).toBe('in_sync') + }) +}) + +describe('commitSessionWorkspace — fork-creation failure', () => { + it('returns undefined and drops pending_fork (so beforeSend aborts the send)', async () => { + const id = 'test-commit-fork-fail' + sessionState.sessions.push({ + id, + name: 'fork-fail', + createdAt: 0, + pending_fork: { parent_workspace_id: 'parent_ws', id: 'wm-fork-nope', name: 'nope' } + } as Session) + try { + const committed = await commitSessionWorkspace(id, 'parent_ws') + // Not committed → undefined. This is what makes beforeSend throw rather + // than letting the first message ship to the parent workspace. + expect(committed).toBeUndefined() + const s = sessionState.sessions.find((x) => x.id === id) + expect(s?.workspace_id).toBeUndefined() + expect(s?.pending_fork).toBeUndefined() + } finally { + const i = sessionState.sessions.findIndex((x) => x.id === id) + if (i >= 0) sessionState.sessions.splice(i, 1) + } + }) +}) + +describe('commitSessionWorkspace — workspaceStore sync (non-fork branch)', () => { + it('syncs workspaceStore to the committed workspace when they differ', async () => { + // Repro: user is sitting in a fork workspace (wm-fork-x) and creates a + // new session whose pending_workspace_id defaults to the family root. + // Without the syncWorkspaceTo call in commitSessionWorkspace's non-fork + // branch, the session metadata says root while the active workspace + // stays on the fork — so AIChatManager.chatRequest's logAiChat and tool + // calls would target the wrong workspace. + const id = 'test-commit-ws-sync' + const prev = get(workspaceStore) + workspaceStore.set('wm-fork-x') + sessionState.sessions.push({ + id, + name: 'ws-sync', + createdAt: 0, + pending_workspace_id: 'root_ws' + } as Session) + try { + const committed = await commitSessionWorkspace(id, undefined) + expect(committed).toBe('root_ws') + const s = sessionState.sessions.find((x) => x.id === id) + expect(s?.workspace_id).toBe('root_ws') + expect(s?.pending_workspace_id).toBeUndefined() + expect(get(workspaceStore)).toBe('root_ws') + } finally { + const i = sessionState.sessions.findIndex((x) => x.id === id) + if (i >= 0) sessionState.sessions.splice(i, 1) + workspaceStore.set(prev) + } + }) + + it('is a no-op on workspaceStore when it already matches the committed workspace', async () => { + const id = 'test-commit-ws-match' + const prev = get(workspaceStore) + workspaceStore.set('root_ws') + sessionState.sessions.push({ + id, + name: 'ws-match', + createdAt: 0, + pending_workspace_id: 'root_ws' + } as Session) + try { + const committed = await commitSessionWorkspace(id, undefined) + expect(committed).toBe('root_ws') + expect(get(workspaceStore)).toBe('root_ws') + } finally { + const i = sessionState.sessions.findIndex((x) => x.id === id) + if (i >= 0) sessionState.sessions.splice(i, 1) + workspaceStore.set(prev) + } + }) +}) diff --git a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts new file mode 100644 index 0000000000..aec1f9e47a --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts @@ -0,0 +1,60 @@ +import type { SessionRuntime } from './sessionRuntime.svelte' + +// Per-user, per-session "last seen" marker — count of displayMessages the +// last time the user was actually on that session's page. Compared against +// the runtime's current message count to derive an unread badge. +// +// Stored as a single localStorage entry holding Record. +// Module-level $state for cross-session reactivity; can't use +// `useLocalStorageValue` here because its internal $effect requires a +// component-initialization context (we run at import time). +const LS_KEY = 'windmill_sessions_last_seen_counts' + +function readInitial(): Record { + if (typeof window === 'undefined') return {} + try { + const raw = localStorage.getItem(LS_KEY) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} + +const lastSeen = $state<{ val: Record }>({ val: readInitial() }) + +function persist(): void { + if (typeof window === 'undefined') return + try { + localStorage.setItem(LS_KEY, JSON.stringify(lastSeen.val)) + } catch (e) { + console.error('sessionUnread: localStorage write failed', e) + } +} + +// Mark the session as seen up to `count` messages. No-op when already at +// or past that count (idempotent — call freely from $effects). +export function markSessionSeen(sessionId: string, count: number) { + const current = lastSeen.val[sessionId] ?? 0 + if (current >= count) return + lastSeen.val = { ...lastSeen.val, [sessionId]: count } + persist() +} + +// Drop the session entry entirely (used on delete so we don't leak +// stale ids into localStorage indefinitely). +export function forgetSessionSeen(sessionId: string) { + if (!(sessionId in lastSeen.val)) return + const next = { ...lastSeen.val } + delete next[sessionId] + lastSeen.val = next + persist() +} + +// Number of unread messages for a session. Undefined / unloaded runtime +// returns 0 — until messages are hydrated we don't know what's new. +export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number { + if (!runtime) return 0 + const seen = lastSeen.val[sessionId] ?? 0 + const total = runtime.manager.displayMessages.length + return Math.max(0, total - seen) +} diff --git a/frontend/src/lib/components/sidebar/MenuButton.svelte b/frontend/src/lib/components/sidebar/MenuButton.svelte index 05451cb4a2..3499f530a5 100644 --- a/frontend/src/lib/components/sidebar/MenuButton.svelte +++ b/frontend/src/lib/components/sidebar/MenuButton.svelte @@ -122,7 +122,12 @@ {:else} {/if} @@ -140,9 +145,7 @@ title={label} > {label} - + {shortcut}
@@ -162,7 +165,7 @@
{#if isCollapsed && notificationsCount > 0} -
+
{:else if notificationsCount > 0} diff --git a/frontend/src/lib/components/sidebar/SideBarNotification.svelte b/frontend/src/lib/components/sidebar/SideBarNotification.svelte index 33ccb39dcb..327a15d69a 100644 --- a/frontend/src/lib/components/sidebar/SideBarNotification.svelte +++ b/frontend/src/lib/components/sidebar/SideBarNotification.svelte @@ -2,15 +2,15 @@ import Notification from '$lib/components/common/alert/Notification.svelte' interface Props { - notificationCount?: number; - small?: boolean; + notificationCount?: number + small?: boolean } - let { notificationCount = 0, small = false }: Props = $props(); + let { notificationCount = 0, small = false }: Props = $props() {#if !small} {:else} -
+
{/if} diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index a9f1764018..dc90a32f7e 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -4,6 +4,7 @@ superadmin, usedTriggerKinds, userStore, + usersWorkspaceStore, userWorkspaces, workspaceStore, isCriticalAlertsUIOpen, @@ -41,13 +42,17 @@ Database, Pyramid, Trash2, - MailIcon + MailIcon, + ChevronDown, + ChevronRight } from 'lucide-svelte' + import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + import { slide } from 'svelte/transition' import UserMenu from './UserMenu.svelte' import DiscordIcon from '../icons/brands/Discord.svelte' import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { clearStores } from '$lib/storeUtils' + import { clearStores, switchWorkspace } from '$lib/storeUtils' import Toggle from '$lib/components/Toggle.svelte' import { goto } from '$lib/navigation' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' @@ -113,6 +118,11 @@ async function deleteFork() { const workspace = $workspaceStore ?? '' + // Capture the parent before delete so we can land the user there + // instead of dropping them back on the workspace-picker menu. + // Only valid if the parent is still in the user's workspace list. + const parentId = $userWorkspaces.find((w) => w.id === workspace)?.parent_workspace_id + const parentStillAccessible = !!(parentId && $userWorkspaces.find((w) => w.id === parentId)) const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name) if (dbsToDrop.length > 0) { @@ -138,8 +148,27 @@ await WorkspaceService.deleteWorkspace({ workspace }) sendUserToast('You deleted the workspace') - clearStores() - goto('/user/workspaces') + if (parentStillAccessible && parentId) { + // Refresh the workspace list before landing on the parent. + // `clearStores()` would null `usersWorkspaceStore`, which the + // sidebar's `visibleSessions` filter reads via `$userWorkspaces` + // — with an empty list, every committed session falls into the + // "workspace_id set but not in user's list" branch and renders + // as "Fork — no longer available" until a hard reload. + try { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + } catch (e) { + // A transient list-refresh failure must not strand the user on the + // just-deleted workspace — still switch + navigate (the list reloads + // on the next page load). + console.error('Failed to refresh workspaces after delete', e) + } + switchWorkspace(parentId) + await goto('/') + } else { + clearStores() + await goto('/user/workspaces') + } } let deleteForkedChildren = $state(false) @@ -173,6 +202,12 @@ loadAvailableNativeTriggers() + const triggersCollapsed = useLocalStorageValue( + 'windmill_triggers_section_collapsed', + false, + 'boolean' + ) + onMount(async () => { if (lastOpened) { // @ts-ignore @@ -612,188 +647,214 @@ 'grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 justify-between gap-2' )} > -
+
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each}
-
Triggers
- - {#snippet children({ createMenu })} - {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - {#if extraTriggerLinks.length > 0 && !$userStore?.operator} - - {#snippet triggr({ trigger })} - - - - {/snippet} - {#snippet children({ item })} - {#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)} - -
- {#if subItem.icon} - - {/if} - {subItem.label} -
-
- {/each} - {/snippet} -
- {/if} - {/snippet} -
-
-
-
- - {#snippet children({ createMenu })} -
- - - {#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - {#if menuLink.subItems} - {@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)} - - {#snippet triggr({ trigger })} - - {/snippet} - - {#snippet children({ item })} - {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} - { - subItem?.['action']?.() - }} - aiId={subItem.aiId} - aiDescription={subItem.aiDescription} - > -
- {#if subItem.icon} - - {/if} - {subItem.label} - {#if subItem?.['notificationCount']} -
- -
- {/if} -
-
- {/each} - {/snippet} -
- {:else} - - {#snippet children({})} - - {/snippet} - - {/if} - {/each} + {#if isCollapsed} +
+ Triggers
- -
- {#each thirdMenuLinks as menuLink (menuLink)} - {#if menuLink.subItems} - - {#snippet triggr({ trigger })} - - {/snippet} - {#snippet children({ item })} - {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} - (triggersCollapsed.val = !triggersCollapsed.val)} + class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none" + aria-expanded={!triggersCollapsed.val} + > + Triggers + {#if triggersCollapsed.val} + + {:else} + + {/if} + + {/if} + {#if isCollapsed || !triggersCollapsed.val} +
+ + {#snippet children({ createMenu })} + {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} + + {/each} + {#if extraTriggerLinks.length > 0 && !$userStore?.operator} + + {#snippet triggr({ trigger })} + -
- {#if subItem.icon} - - {/if} - - {subItem.label} -
- - {/each} - {#if recentChangelogs.length > 0} -
- Latest changelogs - {#each recentChangelogs as changelog} - + +
+ {/snippet} + {#snippet children({ item })} + {#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)} +
- {changelog.label} + {#if subItem.icon} + + {/if} + {subItem.label}
{/each} - {/if} - {/snippet} -
- {/if} - {/each} + {/snippet} +
+ {/if} + {/snippet} +
- {/snippet} - -
- + {/if} +
+
+ + + {#snippet children({ createMenu })} +
+ + + {#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)} + {#if menuLink.subItems} + {@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)} + + {#snippet triggr({ trigger })} + + {/snippet} + + {#snippet children({ item })} + {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} + { + subItem?.['action']?.() + }} + aiId={subItem.aiId} + aiDescription={subItem.aiDescription} + > +
+ {#if subItem.icon} + + {/if} + {subItem.label} + {#if subItem?.['notificationCount']} +
+ +
+ {/if} +
+
+ {/each} + {/snippet} +
+ {:else} + + {#snippet children({})} + + {/snippet} + + {/if} + {/each} +
+ +
+ {#each thirdMenuLinks as menuLink (menuLink)} + {#if menuLink.subItems} + + {#snippet triggr({ trigger })} + + {/snippet} + {#snippet children({ item })} + {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} + +
+ {#if subItem.icon} + + {/if} + + {subItem.label} +
+
+ {/each} + {#if recentChangelogs.length > 0} +
+ Latest changelogs + {#each recentChangelogs as changelog} + +
+ {changelog.label} +
+
+ {/each} + {/if} + {/snippet} +
+ {/if} + {/each} +
+ {/snippet} +
+
+
+ import { workspaceMenuHref } from './workspaceMenuHref' import { isPremiumStore, superadmin, @@ -58,22 +59,30 @@ '/apps/get/' ] const isOnEditPage = editPages.some((editPage) => page.route.id?.includes(editPage) ?? false) + // An AI session is scoped to its (forked) workspace, so it makes no sense + // to keep showing it after the user switches workspace — go home instead. + const isOnSessionPage = page.route.id?.includes('/sessions') ?? false - if (!isOnEditPage) { - switchWorkspace(id) - if (page.url.searchParams.get('workspace')) { - page.url.searchParams.set('workspace', id) - } - } else { - switchWorkspace(id) + switchWorkspace(id) + if (isOnEditPage || isOnSessionPage) { await goto('/') + } else if (page.url.searchParams.get('workspace')) { + page.url.searchParams.set('workspace', id) } } + // An AI session is scoped to its (forked) workspace, so switching workspace + // should leave for home (the link's navigation wins over onClick's + // preventDefault; onClick still performs the switch). Pure logic + + // new-tab/workspace-param handling lives in workspaceMenuHref (unit-tested). function workspaceHref(id: string): string { - const params = new URLSearchParams(page.url.searchParams) - params.set('workspace', id) - return `${page.url.pathname}?${params.toString()}` + return workspaceMenuHref({ + routeId: page.route.id, + base, + pathname: page.url.pathname, + searchParams: page.url.searchParams, + id + }) } function onWorkspaceItemClick(e: MouseEvent, workspace: { id: string; disabled?: boolean }) { diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts new file mode 100644 index 0000000000..2291d2d125 --- /dev/null +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { workspaceMenuHref } from './workspaceMenuHref' + +describe('workspaceMenuHref', () => { + it('on a session route, keeps the workspace id (so new-tab lands in the right workspace)', () => { + expect( + workspaceMenuHref({ + routeId: '/(root)/(logged)/sessions', + base: '', + pathname: '/sessions', + searchParams: new URLSearchParams('session_name=foo'), + id: 'wm-fork-bar' + }) + ).toBe('/?workspace=wm-fork-bar') + }) + + it('respects the base prefix on a session route', () => { + expect( + workspaceMenuHref({ + routeId: '/(root)/(logged)/sessions', + base: '/wm', + pathname: '/wm/sessions', + searchParams: new URLSearchParams(), + id: 'ws2' + }) + ).toBe('/wm/?workspace=ws2') + }) + + it('off a session route, swaps the workspace param on the current path', () => { + expect( + workspaceMenuHref({ + routeId: '/(root)/(logged)/scripts/edit/[...path]', + base: '', + pathname: '/scripts/edit/u/me/x', + searchParams: new URLSearchParams('workspace=old&foo=1'), + id: 'new_ws' + }) + ).toBe('/scripts/edit/u/me/x?workspace=new_ws&foo=1') + }) + + it('adds the workspace param when none was present', () => { + expect( + workspaceMenuHref({ + routeId: '/(root)/(logged)/runs', + base: '', + pathname: '/runs', + searchParams: new URLSearchParams(), + id: 'w' + }) + ).toBe('/runs?workspace=w') + }) +}) diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts new file mode 100644 index 0000000000..5e5ec11325 --- /dev/null +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts @@ -0,0 +1,21 @@ +// Href for a workspace-switch link in the sidebar WorkspaceMenu. +// +// On an AI-session route, switching workspace leaves for home — but we keep the +// `?workspace=` param so a modifier/middle click (open in new tab, which +// bypasses the onClick fast-path) still lands in the *clicked* workspace's home +// rather than the default one. Everywhere else, stay on the current path and +// just swap the `workspace` query param. +export function workspaceMenuHref(args: { + routeId: string | null | undefined + base: string + pathname: string + searchParams: URLSearchParams + id: string +}): string { + if (args.routeId?.includes('/sessions')) { + return `${args.base}/?workspace=${args.id}` + } + const params = new URLSearchParams(args.searchParams) + params.set('workspace', args.id) + return `${args.pathname}?${params.toString()}` +} diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 215bda6226..463ed01021 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -26,6 +26,7 @@ import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { base } from '$lib/base' @@ -139,7 +140,7 @@ initialConfig = structuredClone($state.snapshot(getAzureConfig())) } originalConfig = structuredClone($state.snapshot(getAzureConfig())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load Azure trigger: ${err.body}`, true) } finally { @@ -346,6 +347,15 @@ {#snippet actions()} {@render actionsButtons()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 795d5174ae..13c026fef3 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -30,6 +30,7 @@ import { saveEmailTriggerFromCfg } from './utils' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -136,7 +137,7 @@ initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) } originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load email trigger: ${err}`, true) } finally { @@ -472,6 +473,15 @@ {#snippet actions()} {@render saveButton()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 603ab34380..478844616f 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -28,6 +28,7 @@ import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { base } from '$lib/base' @@ -141,7 +142,7 @@ initialConfig = structuredClone($state.snapshot(getGcpConfig())) } originalConfig = structuredClone($state.snapshot(getGcpConfig())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true) } finally { @@ -363,6 +364,15 @@ {#snippet actions()} {@render actionsButtons()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 787d50451a..9b4622497f 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -56,6 +56,7 @@ import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import UserSettings from '$lib/components/UserSettings.svelte' @@ -235,7 +236,7 @@ initialConfig = structuredClone($state.snapshot(getRouteConfig())) } originalConfig = structuredClone($state.snapshot(getRouteConfig())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load route: ${err}`, true) } finally { @@ -1004,6 +1005,15 @@ {#snippet actions()} {@render saveButton()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index df29dad5df..5136302a53 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -25,6 +25,7 @@ import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import TriggerFilters from '../TriggerFilters.svelte' @@ -163,7 +164,7 @@ initialConfig = structuredClone($state.snapshot(getSaveCfg())) } originalConfig = structuredClone($state.snapshot(getSaveCfg())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load Kafka trigger: ${err}`, true) } finally { @@ -413,6 +414,15 @@ {#snippet actions()} {@render actionsButtons('sm')} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 4e8d760ecc..a55b112ad8 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -40,6 +40,7 @@ import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' interface Props { useDrawer?: boolean @@ -159,7 +160,7 @@ initialConfig = structuredClone($state.snapshot(getSaveCfg())) } originalConfig = structuredClone($state.snapshot(getSaveCfg())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load mqtt trigger: ${err.body}`, true) } finally { @@ -387,6 +388,15 @@ {#snippet actions()} {@render actionsSnippet()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index ae6529be66..15e78694e1 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -24,6 +24,7 @@ import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -147,7 +148,7 @@ initialConfig = structuredClone($state.snapshot(getSaveCfg())) } originalConfig = structuredClone($state.snapshot(getSaveCfg())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load nats trigger: ${err}`, true) } finally { @@ -371,6 +372,15 @@ {#snippet actions()} {@render actionsSnippet()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index e17c3df8f6..403a929e93 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -46,6 +46,7 @@ import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import { capitalize } from '$lib/utils' interface Props { @@ -254,7 +255,7 @@ initialConfig = structuredClone($state.snapshot(getSaveCfg())) } originalConfig = structuredClone($state.snapshot(getSaveCfg())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load postgres trigger: ${err.body}`, true) } finally { @@ -545,6 +546,15 @@ on:close={drawer.closeDrawer} > {#snippet actions()}{@render actionsSnippet()}{/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render content()} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 113cc69801..e807301046 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -39,6 +39,7 @@ import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import { twMerge } from 'tailwind-merge' import PermissionedAsLine from '../PermissionedAsLine.svelte' @@ -156,7 +157,7 @@ if (!defaultCfg) { initialConfig = structuredClone($state.snapshot(getScheduleCfg())) } - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } finally { clearTimeout(loadingTimeout) drawerLoading = false @@ -301,6 +302,11 @@ drawer?.openDrawer() runnable = undefined edit = false + // No deployed baseline for a brand-new schedule. The editor instance + // is reused across open() calls, so clear any baseline left by a prior + // openEdit — otherwise the "unsaved changes" banner / dirty check would + // compare against a stale config. + initialConfig = undefined itemKind = (s?.is_flow ?? nis_flow) ? 'flow' : 'script' initialScriptPath = initial_script_path ?? '' path = initNewPath @@ -1350,6 +1356,15 @@ {@render saveButton()}
{/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render content()} diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 8dc2d4d009..02cc4e057f 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -32,6 +32,7 @@ import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' interface Props { useDrawer?: boolean @@ -145,7 +146,7 @@ initialConfig = structuredClone($state.snapshot(getSaveCfg())) } originalConfig = structuredClone($state.snapshot(getSaveCfg())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load sqs trigger: ${err.body}`, true) } finally { @@ -365,6 +366,15 @@ {#snippet actions()} {@render actionsSnippet()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts index d8e868559f..88c1d62232 100644 --- a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -1,6 +1,5 @@ import { untrack } from 'svelte' import { UserDraft, localDraftDiffers, type UserDraftItemKind } from '$lib/userDraft.svelte' -import { notifyRestoredFromLocal } from '$lib/userDraftToast' type Cfg = Record @@ -28,11 +27,27 @@ export interface TriggerDraftSync { /** The local autosave for the active (workspace, path), if any. */ readonly draft: Cfg | undefined /** - * Restore-on-open: if a local autosave diverges from the just-loaded - * backend config, overlay it and toast a "Reset to deployed" action. - * Call right after the backend load, before clearing `drawerLoading`. + * Whether the form currently diverges from the deployed baseline — i.e. a + * live local draft exists. Drives the "unsaved changes" banner. False while + * loading and for brand-new triggers (no deployed baseline yet). */ - maybeRestore(path: string): Promise + readonly hasDraft: boolean + /** The deployed baseline the dirty check compares against. */ + readonly deployed: Cfg | undefined + /** The current form config (the live local draft). */ + readonly current: Cfg | undefined + /** + * Restore-on-open: if a local autosave diverges from the just-loaded + * backend config, overlay it onto the form. Call right after the backend + * load, before clearing `drawerLoading`, so the drawer opens on the draft + * without a deployed→draft flash. The banner (via `hasDraft`) surfaces it. + */ + maybeRestore(): Promise + /** + * Drop the local draft for `path` and reset the form to the deployed + * baseline. Backs the banner's "Discard" action. + */ + resetToDeployed(path: string): Promise /** * Clear the draft for `path` and reset the handle's in-memory cell to * `fallback`. Use after a successful deploy, passing the just-saved cfg — @@ -63,6 +78,16 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft }) const handle = $derived(handles[0]) + // Live "is there a local draft?" — the form diverges from the deployed + // baseline. Gated on `!drawerLoading` (the baseline isn't settled yet + // mid-load) and on a non-null baseline (a brand-new trigger has none, so + // "unsaved changes" / discard-to-deployed is meaningless there). + const hasDraft = $derived( + !opts.drawerLoading() && + opts.deployed() != null && + localDraftDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg) + ) + function discard(path: string, fallback: Cfg | undefined): void { UserDraft.discard(opts.itemKind, path, fallback, { workspace: opts.workspace() ?? undefined @@ -102,19 +127,25 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft get draft() { return handle?.draft }, - async maybeRestore(path: string) { + get hasDraft() { + return hasDraft + }, + get deployed() { + return opts.deployed() + }, + get current() { + return opts.getCfg() + }, + async maybeRestore() { const d = handle?.draft if (!localDraftDiffers(d, opts.getCfg() as Cfg)) return - // Snapshot the just-loaded backend config so "Reset to deployed" - // can re-apply it, then overlay the local autosave. - const deployedCfg = structuredClone($state.snapshot(opts.getCfg())) as Cfg + // Overlay the local autosave on the just-loaded backend config. await opts.applyCfg(d) - notifyRestoredFromLocal(false, true, { - onResetToDeployed: async () => { - discard(path, deployedCfg) - await opts.applyCfg(deployedCfg) - } - }) + }, + async resetToDeployed(path: string) { + const deployedCfg = structuredClone($state.snapshot(opts.deployed())) as Cfg + discard(path, deployedCfg) + await opts.applyCfg(deployedCfg) }, discard } diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index d75669180a..e63c87071f 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -43,6 +43,7 @@ import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' + import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { capitalize } from '$lib/utils' @@ -191,7 +192,7 @@ initialConfig = structuredClone($state.snapshot(getSaveCfg())) } originalConfig = structuredClone($state.snapshot(getSaveCfg())) - await draftSync.maybeRestore(ePath) + await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load websocket trigger: ${err}`, true) } finally { @@ -455,6 +456,15 @@ {#snippet actions()} {@render actionsButtons()} {/snippet} + {#snippet banner()} + draftSync.deployed} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} {@render config()} diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 902a90c9d1..2ae15d931b 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,16 +1,16 @@ { "gitSyncTest": "hub/28184/git-repo-test-read-write-windmill", - "gitInitRepo": "hub/28219/git-sync%3A-init-repository-windmill", - "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", + "gitInitRepo": "hub/28229/git-sync%3A-init-repository-windmill", + "slackErrorHandler": "hub/28241/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", - "slackRecoveryHandler": "hub/9080/slack/schedule-recovery-handler-slack", - "slackSuccessHandler": "hub/28220/slack/schedule-success-handler-slack", + "slackRecoveryHandler": "hub/28239/slack/schedule-recovery-handler-slack", + "slackSuccessHandler": "hub/28240/slack/schedule-success-handler-slack", "teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams", "teamsRecoveryHandler": "hub/11593/schedule-recovery-handler-teams", "teamsSuccessHandler": "hub/11596/schedule-success-handler-teams", "slackReport": "hub/9084/slack", "discordReport": "hub/9085/discord", - "smtpReport": "hub/9086/smtp", - "appReport": "hub/28076/app-report", + "smtpReport": "hub/28242/smtp", + "appReport": "hub/28243/app-report", "cloneRepoToS3forGitRepoViewer": "hub/28216/clone_repo_and_upload_to_instance_storage" } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 3ebebc2cd1..66896d3117 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -624,6 +624,12 @@ export const UserDraft = { * `fallback` in-memory (so reactive readers see it immediately) and * skips re-persisting it, leaving the LS slot empty until the next real * edit. Pass the deployed baseline as `fallback`. + * + * `fallback` is deep-cloned before being installed — otherwise a caller + * who passes their own live `$state` baseline (e.g. resource/variable + * editors' `initialStates[ws]`) would end up with `handle.draft` and the + * baseline pointing at the *same* proxy; subsequent edits would mutate + * both sides in lock-step and the dirty check would never fire. */ discard( itemKind: UserDraftItemKind, @@ -634,6 +640,7 @@ export const UserDraft = { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) + const safeFallback = snapshotDraftValue(fallback) if (entry) { // Drop any queued debounced write owned by this live entry before // resetting the in-memory value. Otherwise a timer from the old @@ -646,7 +653,7 @@ export const UserDraft = { // stray `save_draft({value: fallback})` from the reactive sync // effect, racing the explicit delete below. entry.skipNextSync = true - entry.state.setWithoutPersist(wrap(fallback) as StoredDraft | undefined) + entry.state.setWithoutPersist(wrap(safeFallback) as StoredDraft | undefined) } try { localStorage.removeItem(localStorageKey(ws, itemKind, path)) diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index e2dd97418f..05679b823b 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -172,6 +172,93 @@ describe('UserDraft live editor draft registry', () => { expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined() }) + // Pins the slot-collision contract behind the SessionWrapper + // `isActiveSession` gate. Without the gate, two warm-mounted session + // editors on the same (workspace, kind) — e.g. `/sessions` keeping 3 + // warm sessions and two of them have script editors open in the same + // workspace — both call setLiveEditorDraft and the hidden one can + // clobber the visible one's claim. The fix in + // {Script,Flow,RawApp}EditorView returns early when `isActiveSession` + // is false, so only the visible session writes to the slot. + it('collides per (workspace, kind) when two callers both set — the hidden session can hijack', () => { + UserDraft.setLiveEditorDraft({ + workspace: 'ws_collide', + itemKind: 'script', + storagePath: 'session_a_path', + effectivePath: 'u/me/a' + }) + UserDraft.setLiveEditorDraft({ + workspace: 'ws_collide', + itemKind: 'script', + storagePath: 'session_b_path', + effectivePath: 'u/me/b' + }) + // Last write wins — exactly the bug Codex flagged: a hidden warm + // session B mounted after the active A overwrites A's claim. + expect(UserDraft.getLiveEditorDraft('script', { workspace: 'ws_collide' })).toMatchObject({ + storagePath: 'session_b_path', + effectivePath: 'u/me/b' + }) + }) + + it('with the active-session gate, only the visible session claims the slot', () => { + // Simulate the effect bodies in {Script,Flow,RawApp}EditorView: each + // returns early when isActiveSession is false. Session A is active, + // Session B is warm-mounted but hidden. + function registerIfActive(opts: { + isActive: boolean + workspace: string + storagePath: string + effectivePath: string + }) { + if (!opts.isActive) return + UserDraft.setLiveEditorDraft({ + workspace: opts.workspace, + itemKind: 'flow', + storagePath: opts.storagePath, + effectivePath: opts.effectivePath + }) + } + registerIfActive({ + isActive: true, + workspace: 'ws_gate', + storagePath: 'session_a_path', + effectivePath: 'u/me/a' + }) + registerIfActive({ + isActive: false, + workspace: 'ws_gate', + storagePath: 'session_b_path', + effectivePath: 'u/me/b' + }) + expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_gate' })).toMatchObject({ + storagePath: 'session_a_path', + effectivePath: 'u/me/a' + }) + }) + + it('cleanup on the deactivating session is a no-op once the new active session has claimed the slot', () => { + // Active-session swap: A becomes hidden (cleanup runs), B becomes + // active and registers. Even if Svelte flushes B's effect before + // A's cleanup, A's cleanup is keyed on its own storagePath and is + // guarded so it doesn't clobber B's claim. Verified here by running + // the cleanups in reverse order. + UserDraft.setLiveEditorDraft({ + workspace: 'ws_swap', + itemKind: 'raw_app', + storagePath: 'session_b_path', + effectivePath: 'u/me/b' + }) + // A's cleanup runs after — should be a no-op. + UserDraft.clearLiveEditorDraft('raw_app', { + workspace: 'ws_swap', + storagePath: 'session_a_path' + }) + expect(UserDraft.getLiveEditorDraft('raw_app', { workspace: 'ws_swap' })).toMatchObject({ + storagePath: 'session_b_path' + }) + }) + it('can remove persisted global draft storage without blanking the live editor', () => { const draft = { path: 'u/me/live_script', content: 'export async function main() {}' } localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft)) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 8fb6475806..2e6302fb6c 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1186,9 +1186,22 @@ export function isCodeInjection(expr: string | undefined): boolean { return dynamicTemplateRegex.test(expr) } -export function urlParamsToObject(params: URLSearchParams): Record { +// Query params Windmill consumes internally and that should not be exposed to +// app logic via the `query` context. Only params we actually own are listed +// here — the `wm_` prefix is a naming convention, not a reserved namespace, so +// we don't strip it wholesale (that would break apps reading their own `wm_*` +// params). `wm_coep` is a transport flag for cross-origin isolation headers. +export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep']) + +export function urlParamsToObject( + params: URLSearchParams, + opts?: { stripReserved?: boolean } +): Record { const result: Record = {} params.forEach((value, key) => { + if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { + return + } result[key] = value }) return result diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index c6fca60e37..01461b78cb 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -65,6 +65,7 @@ import { Menubar } from '$lib/components/meltComponents' import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte' + import SessionPicker from '$lib/components/sessions/SessionPicker.svelte' import { DEFAULT_HUB_BASE_URL } from '$lib/hub' import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte' import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte' @@ -330,6 +331,9 @@ } let devOnly = $derived(page.url.pathname.startsWith(base + '/scripts/dev')) + // Sessions own their own chat pane; suppress the global Ask-AI chat on the /sessions route + // so it doesn't render a second chat overlay on top of the session. + let inSessionRoute = $derived(page.url.pathname.startsWith(base + '/sessions')) async function loadDefaultScripts(workspace: string, user: UserExt | undefined) { if (!user?.operator) { @@ -677,6 +681,8 @@ />
+ + { diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index cbdb755d77..460b2957e0 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -56,7 +56,7 @@ name: $userStore?.name, username: $userStore?.username, groups: $userStore?.groups, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={$workspaceStore ?? ''} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 335fdbca2e..4fbcad66c7 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -20,10 +20,15 @@ import OtherUsersDraftsModal from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' import type { Trigger } from '$lib/components/triggers/utils' - import { untrack } from 'svelte' + import { tick, untrack } from 'svelte' import type { stepState } from '$lib/components/stepHistoryLoader.svelte' import { page } from '$app/state' - import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte' + import { + UserDraft, + checkStaleness, + type UserDraftMeta, + type UserDraftHandle + } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' let version: undefined | number = $state(undefined) @@ -44,7 +49,8 @@ let savedFlow: Flow | undefined = $state(undefined) - const flowDraftPath = page.params.path ?? '' + // Derived so client-side nav (breadcrumb) re-keys the handle to the new path. + let flowDraftPath = $derived(page.params.path ?? '') // `?nodraft=true` is the callers' way of saying "skip the local autosave // on this load." Wipe the UserDraft entry and strip the flag from the @@ -57,7 +63,27 @@ window.history.replaceState(window.history.state, '', url.toString()) } - const flowHandle = UserDraft.use('flow', flowDraftPath) + // `useMany` keyed off the reactive `flowDraftPath` re-keys the handle on nav; + // `flowHandle` proxies the current handle so `flowStore` keeps a fixed ref. + const flowHandles = UserDraft.useMany(() => [{ itemKind: 'flow', path: flowDraftPath }]) + const flowHandle: UserDraftHandle = { + get draft() { + return flowHandles[0]?.draft + }, + set draft(value) { + const handle = flowHandles[0] + if (handle) handle.draft = value + }, + get meta() { + return flowHandles[0]?.meta ?? {} + }, + setDraftAndMeta(value, meta) { + flowHandles[0]?.setDraftAndMeta(value, meta) + }, + setMeta(meta, opts) { + flowHandles[0]?.setMeta(meta, opts) + } + } function emptyFlow(): Flow { return { @@ -84,6 +110,10 @@ let loading = $state(false) + // Remounts FlowBuilder on nav: false while a reload runs, true once data is + // ready, so it mounts fresh instead of reusing the previous flow's state. + let renderEditor = $state(false) + let selectedId: string = $state('settings-metadata') let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined) @@ -132,6 +162,11 @@ const tok = ++loadFlowToken loading = true let flow: Flow + // Builder-dependent setup is captured here and applied AFTER the builder + // remounts (see end of loadFlow): during a reload renderEditor is false, + // so flowBuilder is unmounted and direct calls would no-op. + let draftTriggersToApply: Trigger[] | undefined = undefined + let applyPrimarySchedule = false // Currently there is no way to get version of flow with flow. // So we have to request it here const v = ( @@ -208,6 +243,14 @@ if (tok !== loadFlowToken) return loading = false selectedId = page.url.searchParams.get('selected') ?? 'settings-metadata' + // Remount the builder first, then apply builder-dependent setup once it + // has mounted — otherwise (during a reload) these would no-op on the + // unmounted builder and editor state restoration would be skipped. + renderEditor = true + await tick() + if (tok !== loadFlowToken) return + if (applyPrimarySchedule) flowBuilder?.setPrimarySchedule(savedPrimarySchedule) + flowBuilder?.setDraftTriggers(draftTriggersToApply) flowBuilder?.loadFlowState() } @@ -216,7 +259,18 @@ // to another (e.g. via the workspace picker) reloads the new flow. page.params.path if ($workspaceStore) { - untrack(() => loadFlow()) + untrack(() => { + nobackenddraft = false // fresh nav reconsiders the backend draft + renderEditor = false // remount the builder for the navigated-to flow + loadFlow().catch((e: any) => { + // A failed load must NOT leave renderEditor stuck false — otherwise + // the editor pane disappears and never remounts. Surface the error + // and remount so the user isn't stranded on a blank pane. + console.error('Failed to load flow', e) + sendUserToast(`Failed to load flow: ${e?.body ?? e?.message ?? e}`, true) + renderEditor = true + }) + }) } }) @@ -264,7 +318,7 @@

Flow not found at path {page.params.path}

The flow you are looking for does not exist.

-{:else} +{:else if renderEditor} { UserDraft.remove('flow', flowDraftPath) diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index 77ad35ffae..a6be7b1044 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -2,10 +2,16 @@ import CompareWorkspaces from '$lib/components/CompareWorkspaces.svelte' import { WorkspaceService, type WorkspaceComparison } from '$lib/gen' import { page } from '$app/state' - import { userWorkspaces } from '$lib/stores' + import { userWorkspaces, usersWorkspaceStore } from '$lib/stores' import { untrack } from 'svelte' import CenteredPage from '$lib/components/CenteredPage.svelte' import PageHeader from '$lib/components/PageHeader.svelte' + import Button from '$lib/components/common/button/Button.svelte' + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import { Archive, Trash2 } from 'lucide-svelte' + import { sendUserToast } from '$lib/toast' + import { switchWorkspace } from '$lib/storeUtils' + import { goto } from '$lib/navigation' let comparison: WorkspaceComparison | undefined = $state(undefined) @@ -21,25 +27,15 @@ return } - // loading = true - // error = undefined - try { - // Compare with parent workspace const result = await WorkspaceService.compareWorkspaces({ workspace: parentWorkspaceId, targetWorkspaceId: currentWorkspaceId }) comparison = result - // isVisible = result.summary.total_diffs > 0 } catch (e) { console.error('Failed to compare workspaces:', e) - // error = 'Failed to check for changes' - // Still show banner if there's an error, but with error message - // isVisible = true - } finally { - // loading = false } } @@ -48,19 +44,93 @@ untrack(() => checkForChanges()) }) + + // Fork lifecycle actions — placed in the page header so they're available + // regardless of merge state. Both go through a confirmation modal because + // archive is reversible-ish but delete is irreversible, and either way the + // user is about to navigate away from this page. + let archiveConfirmOpen = $state(false) + let deleteConfirmOpen = $state(false) + let acting = $state(false) + + async function afterForkGone() { + // Mirror SidebarContent.deleteFork (B1): refresh the workspace list + // rather than letting `clearStores()` null it, then land the user on + // the parent if still accessible. + try { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + } catch (e) { + console.error('Failed to refresh workspaces', e) + } + if (parentWorkspaceId && $userWorkspaces.find((w) => w.id === parentWorkspaceId)) { + switchWorkspace(parentWorkspaceId) + await goto('/') + } else { + await goto('/user/workspaces') + } + } + + async function confirmArchive() { + archiveConfirmOpen = false + if (!currentWorkspaceId) return + acting = true + try { + await WorkspaceService.archiveWorkspace({ workspace: currentWorkspaceId }) + sendUserToast(`Archived fork ${currentWorkspaceId}`) + await afterForkGone() + } catch (e: any) { + sendUserToast(`Failed to archive fork: ${e?.body ?? e}`, true) + } finally { + acting = false + } + } + + async function confirmDelete() { + deleteConfirmOpen = false + if (!currentWorkspaceId) return + acting = true + try { + await WorkspaceService.deleteWorkspace({ workspace: currentWorkspaceId }) + sendUserToast(`Deleted fork ${currentWorkspaceId}`) + await afterForkGone() + } catch (e: any) { + sendUserToast(`Failed to delete fork: ${e?.body ?? e}`, true) + } finally { + acting = false + } + } + + const isFork = $derived(!!parentWorkspaceId && currentWorkspaceId?.startsWith('wm-fork-')) - + + {#if isFork} +
+ + +
+ {/if} +
{#if currentWorkspaceId && parentWorkspaceId} - - - - - - - - {/if} {#if !currentWorkspaceId} @@ -69,3 +139,33 @@ workspace {currentWorkspaceId} has no parent workspace {/if}
+ + (archiveConfirmOpen = false)} +> +

+ Archive forked workspace {currentWorkspaceId}? It will be hidden from the workspace picker; a superadmin can restore it from instance + settings later. +

+
+ + (deleteConfirmOpen = false)} +> +

+ Permanently delete forked workspace {currentWorkspaceId}? This cannot be undone. Any sessions still bound to this fork will show as "Fork — no longer + available" in the sidebar. +

+
diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index 9de5cc2265..8383f8b5e3 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -4,7 +4,7 @@ import { page } from '$app/state' import { defaultScripts, initialArgsStore, workspaceStore } from '$lib/stores' import ScriptBuilder from '$lib/components/ScriptBuilder.svelte' - import { editPathFor, invalidate } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import type { Schema } from '$lib/common' import { cleanValueProperties, @@ -273,7 +273,11 @@ ? 'wac_typescript' : 'script')} onDeploy={(e) => { - if ($workspaceStore) invalidate($workspaceStore, 'script') + // "Deploy & Stay here" / lib: stay on the editor (just confirm). + if (e.stay) { + sendUserToast('Deployed') + return + } goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} onNavigate={(item) => goto(editPathFor(item))} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index f09e65b9bc..8fdf2cff93 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -20,7 +20,12 @@ import { get } from 'svelte/store' import { untrack } from 'svelte' import { page } from '$app/state' - import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte' + import { + UserDraft, + checkStaleness, + type UserDraftMeta, + type UserDraftHandle + } from '$lib/userDraft.svelte' import { notifyRestoredFromLocal } from '$lib/userDraftToast' type EditableScript = NewScript & { draft_triggers?: Trigger[] } @@ -30,14 +35,38 @@ let initialArgs = get(initialArgsStore) ?? {} if (get(initialArgsStore)) $initialArgsStore = undefined - let topHash = page.url.searchParams.get('topHash') ?? undefined + // Derived so client-side nav (breadcrumb) re-reads the URL, not mount-time values. + let topHash = $derived(page.url.searchParams.get('topHash') ?? undefined) - let hash = page.url.searchParams.get('hash') ?? undefined + let hash = $derived(page.url.searchParams.get('hash') ?? undefined) // When viewing a specific historical hash we don't want to load or write a // local draft — that view is read-only relative to drafts. - const draftPath = hash ? '' : (page.params.path ?? '') - const scriptHandle = UserDraft.use('script', draftPath) + let draftPath = $derived(hash ? '' : (page.params.path ?? '')) + + // `useMany` keyed off the reactive `draftPath` re-keys the handle on nav; + // `scriptHandle` proxies the current handle so `bind:script` stays a fixed lvalue. + const scriptHandles = UserDraft.useMany(() => [ + { itemKind: 'script', path: draftPath } + ]) + const scriptHandle: UserDraftHandle = { + get draft() { + return scriptHandles[0]?.draft + }, + set draft(value) { + const handle = scriptHandles[0] + if (handle) handle.draft = value + }, + get meta() { + return scriptHandles[0]?.meta ?? {} + }, + setDraftAndMeta(value, meta) { + scriptHandles[0]?.setDraftAndMeta(value, meta) + }, + setMeta(meta, opts) { + scriptHandles[0]?.setMeta(meta, opts) + } + } $effect(() => { if (hash || !$workspaceStore) return @@ -85,6 +114,11 @@ let savedScript: Script | NewScript | undefined = $state(undefined) let fullyLoaded = $state(false) + // Remounts ScriptBuilder on nav: false while a reload runs, true once data is + // ready. A synchronous `{#key}` swap instead races Monaco's init against the + // torn-down container (mirrors how the raw-app editor clears `files`). + let renderEditor = $state(false) + let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined) // Local-draft staleness modal: opened when the remote (deployed or DB @@ -277,6 +311,7 @@ scriptBuilder?.setCode(scriptHandle.draft.content) } fullyLoaded = true + renderEditor = true } $effect(() => { @@ -284,7 +319,17 @@ // to another (e.g. via the workspace picker) reloads the new script. page.params.path if ($workspaceStore) { - untrack(() => loadScript()) + untrack(() => { + renderEditor = false // remount the builder for the navigated-to script + loadScript().catch((e: any) => { + // A failed load must NOT leave renderEditor stuck false — otherwise + // the editor pane disappears and never remounts. Surface the error + // and remount so the user isn't stranded on a blank pane. + console.error('Failed to load script', e) + sendUserToast(`Failed to load script: ${e?.body ?? e?.message ?? e}`, true) + renderEditor = true + }) + }) } }) @@ -357,7 +402,7 @@ onLoadLatest={onUrlConflictUseUrl} onKeepDraft={onUrlConflictKeepLocal} /> -{#if scriptHandle.draft} +{#if scriptHandle.draft && renderEditor} { + // "Deploy & Stay here" / lib: stay on the editor (just confirm). + if (e.stay) { + sendUserToast('Deployed') + return + } UserDraft.remove('script', draftPath) if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte new file mode 100644 index 0000000000..a733c325d6 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -0,0 +1,176 @@ + + +{#if !globalEnabled} +
+ Sessions are gated on the global-AI dev flag. Enable with + localStorage.setItem('wm_dev_global_ai', '1') and reload. +
+{:else if !sessionName} +
No session selected — pick one in the sidebar.
+{:else if !sessionByName} + +
+
+

Session not found

+

+ No session named {sessionName} exists. It may have been + deleted, or this link was created in a different browser. +

+
+ +
+{:else} +
+ {#each warmSessions as s (s.id)} +
+ +
+ {/each} +
+{/if} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index aa873f83a9..e71f0bb71e 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -30,10 +30,12 @@ enterpriseLicense, superadmin, userStore, + userWorkspaces, usersWorkspaceStore, workspaceStore, isCriticalAlertsUIOpen } from '$lib/stores' + import { switchWorkspace } from '$lib/storeUtils' import { sendUserToast } from '$lib/toast' import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' @@ -1542,11 +1544,32 @@ unifiedSize="md" btnClasses="mt-2" on:click={async () => { - await WorkspaceService.archiveWorkspace({ workspace: $workspaceStore ?? '' }) - sendUserToast(`Archived workspace ${$workspaceStore}`) - workspaceStore.set(undefined) - usersWorkspaceStore.set(undefined) - goto('/user/workspaces') + const ws = $workspaceStore ?? '' + // Land on the parent workspace if this is a fork and the + // parent is still accessible — otherwise fall back to the + // workspace picker. + const parentId = $userWorkspaces.find((w) => w.id === ws)?.parent_workspace_id + const parentStillAccessible = !!( + parentId && $userWorkspaces.find((w) => w.id === parentId) + ) + await WorkspaceService.archiveWorkspace({ workspace: ws }) + sendUserToast(`Archived workspace ${ws}`) + if (parentStillAccessible && parentId) { + // Refresh the list so the just-archived workspace drops out before + // we land on the parent. Guarded: a refresh failure must not block + // the switch (the list reloads on next page load). + try { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + } catch (e) { + console.error('Failed to refresh workspaces after archive', e) + } + switchWorkspace(parentId) + await goto('/') + } else { + workspaceStore.set(undefined) + usersWorkspaceStore.set(undefined) + await goto('/user/workspaces') + } }} > Archive workspace diff --git a/lsp/Pipfile b/lsp/Pipfile index a8f34d6189..4b216ab6fd 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.712.0" +wmill = ">=1.713.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index cb0f7d33d9..fc74ed6a20 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.712.0 + version: 1.713.1 title: OpenFlow Spec contact: name: Ruben Fiszel @@ -60,6 +60,9 @@ components: 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 diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 17ed31216b..e6af8d10ce 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.712.0' + ModuleVersion = '1.713.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index abbbed9857..70cdeb996d 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.712.0" +version = "1.713.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/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index 806e279767..6a2fe07ba2 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -295,4 +295,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 9ce0d56e01..19e69543e4 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2546,7 +2546,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands @@ -3524,13 +3524,14 @@ const result: wmill.S3Object = await wmill.writeS3File( export const LANG_BUNNATIVE = `# TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes \`fetch\` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with \`//native\` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. \`./helper.ts\`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on \`fetch\` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, \`node:*\` modules, child processes, native addons) will not work on the native worker; use the regular \`bun\` language for those. ## Structure Export a single **async** function called \`main\`: \`\`\`typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -3546,6 +3547,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the \`RT\` namespace for resource types: \`\`\`typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -3557,9 +3559,10 @@ Before using a resource type, check the \`rt.d.ts\` file in the project root to ## Imports -**No imports allowed.** Use the globally available \`fetch\` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides \`fetch\` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (\`node:fs\`, \`child_process\`, the \`Bun\` API, native modules) belongs in a regular \`bun\` script instead. Use the globally available \`fetch\` for HTTP: \`\`\`typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -3568,13 +3571,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: \`\`\`typescript +//native type Event = { kind: | "webhook" @@ -3607,6 +3611,7 @@ Windmill provides built-in support for S3-compatible storage operations. The \`w ### Receiving an S3Object as a script parameter \`\`\`typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -3618,6 +3623,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations \`\`\`typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -4108,85 +4114,6 @@ omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, being buffered as the script return value. `; -export const LANG_NATIVETS = `# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -\`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -\`\`\` -`; - export const LANG_PHP = `# PHP ## Structure diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index b7b97326a2..7515b2bec4 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -239,13 +239,14 @@ const result: wmill.S3Object = await wmill.writeS3File( # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those. ## Structure Export a single **async** function called `main`: ```typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -261,6 +262,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the `RT` namespace for resource types: ```typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -272,9 +274,10 @@ Before using a resource type, check the `rt.d.ts` file in the project root to se ## Imports -**No imports allowed.** Use the globally available `fetch` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP: ```typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -283,13 +286,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: ```typescript +//native type Event = { kind: | "webhook" @@ -322,6 +326,7 @@ Windmill provides built-in support for S3-compatible storage operations. The `wm ### Receiving an S3Object as a script parameter ```typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -333,6 +338,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations ```typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -823,85 +829,6 @@ omit to use the workspace default), `format` (`json` (default), `parquet`, or being buffered as the script return value. -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called `main`: - -```typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -``` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the `RT` namespace for resource types: - -```typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -``` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`. - -## Imports - -**No imports allowed.** Use the globally available `fetch` function: - -```typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -``` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: - -```typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -``` - - # PHP ## Structure diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index fd8eacb9c0..a8b57a93e4 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -381,4 +381,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 89b1bc6f05..3b790959f2 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -1,6 +1,6 @@ --- name: write-script-bunnative -description: MUST use when writing Bun Native scripts. +description: MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker. --- ## CLI Commands @@ -40,13 +40,14 @@ Use `wmill resource-type list --schema` to discover available resource types. # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those. ## Structure Export a single **async** function called `main`: ```typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -62,6 +63,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the `RT` namespace for resource types: ```typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -73,9 +75,10 @@ Before using a resource type, check the `rt.d.ts` file in the project root to se ## Imports -**No imports allowed.** Use the globally available `fetch` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP: ```typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -84,13 +87,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: ```typescript +//native type Event = { kind: | "webhook" @@ -123,6 +127,7 @@ Windmill provides built-in support for S3-compatible storage operations. The `wm ### Receiving an S3Object as a script parameter ```typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -134,6 +139,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations ```typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md deleted file mode 100644 index 42ad9448a8..0000000000 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ /dev/null @@ -1,675 +0,0 @@ ---- -name: write-script-nativets -description: MUST use when writing Native TypeScript scripts. ---- - -## CLI Commands - -Place scripts in a folder. - -After writing, tell the user which command fits what they want to do: - -- `wmill script preview ` — **default when iterating on a local script.** Runs the local file without deploying. -- `wmill script run ` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- `wmill generate-metadata` — generate `.script.yaml` and `.lock` files for the script you modified. -- `wmill sync push` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". - -### Preview vs run — choose by intent, not habit - -If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use `script preview`. Do NOT push the script to then `script run` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes. - -Only use `script run` when: -- The user explicitly says "run the deployed version" / "run what's on the server". -- There is no local script being edited (you're just invoking an existing script). - -Only use `sync push` when: -- The user explicitly asks to deploy, publish, push, or ship. -- The preview has already validated the change and the user wants it in the workspace. - -### After writing — offer to test, don't wait passively - -If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run `wmill script preview` with sample args?"). Do not present a multi-option menu. - -If the user already asked to test/run/try the script in their original request, skip the offer and just execute `wmill script preview -d ''` directly — pick plausible args from the script's declared parameters. The shape varies by language: `main(...)` for code languages, the SQL dialect's own placeholder syntax (`$1` for PostgreSQL, `?` for MySQL/Snowflake, `@P1` for MSSQL, `@name` for BigQuery, etc.), positional `$1`, `$2`, … for Bash, `param(...)` for PowerShell. - -`wmill script preview` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). `wmill sync push` and `wmill generate-metadata` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. - -For a **visual** open-the-script-in-the-dev-page preview (rather than `script preview`'s run-and-print-result), use the `preview` skill. - -Use `wmill resource-type list --schema` to discover available resource types. - -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called `main`: - -```typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -``` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the `RT` namespace for resource types: - -```typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -``` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`. - -## Imports - -**No imports allowed.** Use the globally available `fetch` function: - -```typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -``` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: - -```typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -``` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -workerHasInternalServer(): boolean - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format `$res:path` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to `getStatePath()`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to `getStatePath()`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * ```typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * ``` - * - * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * ```typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * ``` - * - * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * ```typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * ``` - * - * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Permanently delete a file from S3 by key. - * - * ```typescript - * await wmill.deleteS3File({ s3: "path/to/file.txt" }) - * ``` - * - * @param s3object - S3 object identifying the file to delete (must have `s3` set) - * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) - */ -async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails. - * - * **Usage Example:** - * ```typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * ``` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails. - * - * **Usage Example:** - * ```typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * ``` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use `step()` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling `fn(item)`, which should be a task(). - * Items are dispatched in batches of `concurrency` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise - -/** - * Commit Kafka offsets for a trigger with auto_commit disabled. - * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) - * @param topic - Kafka topic name (from event.topic) - * @param partition - Partition number (from event.partition) - * @param offset - Message offset to commit (from event.offset) - */ -async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql` - * SELECT * FROM friends - * WHERE name = ${name} AND age = ${age}::int - * `.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql` - * SELECT * FROM friends - * WHERE name = ${name} AND age = ${age} - * `.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction diff --git a/system_prompts/languages/bunnative.md b/system_prompts/languages/bunnative.md index 977c974737..9daa8a9a46 100644 --- a/system_prompts/languages/bunnative.md +++ b/system_prompts/languages/bunnative.md @@ -1,12 +1,13 @@ # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those. ## Structure Export a single **async** function called `main`: ```typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -22,6 +23,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the `RT` namespace for resource types: ```typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -33,9 +35,10 @@ Before using a resource type, check the `rt.d.ts` file in the project root to se ## Imports -**No imports allowed.** Use the globally available `fetch` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP: ```typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -44,13 +47,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +`windmill-client` is available for Windmill-specific primitives such as the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). Use `fetch` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: ```typescript +//native type Event = { kind: | "webhook" @@ -83,6 +87,7 @@ Windmill provides built-in support for S3-compatible storage operations. The `wm ### Receiving an S3Object as a script parameter ```typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -94,6 +99,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations ```typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 diff --git a/system_prompts/languages/nativets.md b/system_prompts/languages/nativets.md deleted file mode 100644 index 5df6ad2279..0000000000 --- a/system_prompts/languages/nativets.md +++ /dev/null @@ -1,77 +0,0 @@ -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called `main`: - -```typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -``` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the `RT` namespace for resource types: - -```typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -``` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`. - -## Imports - -**No imports allowed.** Use the globally available `fetch` function: - -```typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -``` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter: - -```typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -``` diff --git a/system_prompts/utils.py b/system_prompts/utils.py index 152df2e1d9..45b7a365ba 100644 --- a/system_prompts/utils.py +++ b/system_prompts/utils.py @@ -94,14 +94,14 @@ LANGUAGE_METADATA = { 'description': 'MUST use when writing Deno/TypeScript scripts.', 'use_cases': 'TypeScript with Deno stdlib, secure sandboxed execution' }, - 'nativets': { - 'name': 'Native TypeScript', - 'description': 'MUST use when writing Native TypeScript scripts.', - 'use_cases': 'simple API calls, lightweight TypeScript, no dependencies' - }, + # 'nativets' is intentionally omitted: it is a legacy duplicate of + # 'bunnative' (a Bun script with a leading //native marker). No + # write-script-nativets skill is generated so agents always author native + # TypeScript as 'bunnative'. It remains in TS_SDK_LANGUAGES below so the + # TypeScript SDK still attaches when editing existing nativets scripts. 'bunnative': { 'name': 'Bun Native', - 'description': 'MUST use when writing Bun Native scripts.', + 'description': 'MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker.', 'use_cases': 'simple Bun scripts, lightweight, no dependencies' }, 'python3': { @@ -186,7 +186,9 @@ LANGUAGE_METADATA = { }, } -# Languages that use TypeScript SDK +# Languages that use TypeScript SDK. 'nativets' is kept here (despite having no +# write-script skill — see LANGUAGE_METADATA) so the TS SDK still attaches when +# editing pre-existing legacy nativets scripts. TS_SDK_LANGUAGES = ['bun', 'deno', 'nativets', 'bunnative'] # Languages that use Python SDK diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 434effa5e0..d267b2dc58 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.712.0", + "version": "1.713.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 c429b01434..6065a2dde3 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.712.0", + "version": "1.713.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 9a8f9c885c..ee288bb10c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.712.0 +1.713.1