diff --git a/.agents/skills/ai-chat/SKILL.md b/.agents/skills/ai-chat/SKILL.md index f670b2ffb7..490c459db9 100644 --- a/.agents/skills/ai-chat/SKILL.md +++ b/.agents/skills/ai-chat/SKILL.md @@ -11,6 +11,15 @@ authoring and the full run reference. Run the affected mode **before** your change and **after**, same model(s), same cases. +## Where guidance goes + +What the model should know about Windmill itself (flow shapes, groups, data tables, app +access, secrets…) belongs in `system_prompts/base/` or `system_prompts/languages/`, which also +feed the CLI's skills — not in a `*/core.ts` prompt builder, where only this chat would see it. +The builders add tool plumbing and runtime values only. Scope a sentence to one consumer with +`` / ``, keep chat tool names out of the shared files, and +rerun `python system_prompts/generate.py`. `system_prompts/README.md` has the mechanics. + ## Measure the window first, and cumulative second Optimize **`finalContextTokens`** (window occupancy — what drives overflow and diff --git a/.agents/skills/svelte-frontend/SKILL.md b/.agents/skills/svelte-frontend/SKILL.md index 6aceedc25c..ddeb8e59a1 100644 --- a/.agents/skills/svelte-frontend/SKILL.md +++ b/.agents/skills/svelte-frontend/SKILL.md @@ -119,8 +119,8 @@ Form components (TextInput, Toggle, Select, etc.) should use the unified size sy New user-facing UX is the main source of `feature_usage` counters — propose them in the plan, not as a separate question, and read `docs/feature-telemetry.md` first. `logFeatureUsage()` from `$lib/utils/featureUsage` is only half the change: the `(feature, kind)` pair must also be -registered in the backend allowlist or every event is silently discarded, and the disclosure copy -in `InstanceSettings.svelte` must name what you added. +registered in the backend allowlist or every event is silently discarded. The disclosure copy in +`InstanceSettings.svelte` stays at the category level; see the recipe's step 3. ## Svelte MCP Server diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d43bcaaa00..6046035ab7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.818.0" + ".": "1.819.0" } diff --git a/AGENTS.md b/AGENTS.md index 858e9329ff..f4bbfec6ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,10 @@ Open-source platform for internal tools, workflows, API integrations, background - **Agent workers**: `docs/agent-worker-e2e.md` — building and running one locally. An agent reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain `cargo run`; a normal build cannot start one at all. +- **External instance data tables**: `docs/external-instance-datatables.md` — the cluster Windmill + administers behind `external_instance` data tables and Ducklake catalogs: its invariants (one + lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership) + and how to run one locally - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option` @@ -48,6 +52,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Brand/UI guidelines**: `frontend/brand-guidelines.md` - **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does. - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. +- **AI guidance** (chat prompts and CLI skills): what the AI knows about Windmill itself — how flows, scripts, apps, pipelines and resources work — is written once, in `system_prompts/base/` and `system_prompts/languages/`, and `system_prompts/generate.py` builds both the chat's `$system_prompts` exports and the CLI's skills from it. Add or change such guidance there, never inline in one consumer: a sentence only one side should see goes in a `` / `` block, and a new file reaches both through the `TOPICS` table. The chat prompt builders under `frontend/src/lib/components/copilot/chat/` add only tool plumbing and runtime values, and shared markdown names no chat tool, since it reaches every chat mode. Rerun `generate.py` after editing; `system_prompts/README.md` has the details. - **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise). - **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server. diff --git a/CHANGELOG.md b/CHANGELOG.md index 46fe10f2e0..93b0e0dd9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [1.819.0](https://github.com/windmill-labs/windmill/compare/v1.818.0...v1.819.0) (2026-09-28) + + +### Features + +* add hub sync button to the resource types tab ([#11375](https://github.com/windmill-labs/windmill/issues/11375)) ([d76a962](https://github.com/windmill-labs/windmill/commit/d76a962331784672934749e2237f83259cbbc633)) +* add provenance claims to job OIDC tokens ([#11369](https://github.com/windmill-labs/windmill/issues/11369)) ([990a726](https://github.com/windmill-labs/windmill/commit/990a72640928336a4583e55376f37aa3c9eb17ea)) + + +### Bug Fixes + +* complete a canceled flow whose worker died between two steps ([#11366](https://github.com/windmill-labs/windmill/issues/11366)) ([c2d8997](https://github.com/windmill-labs/windmill/commit/c2d899754977ab95936b1084817da98d7e5d68f4)) +* **frontend:** apply operator write locks from the session's operating workspace ([#11395](https://github.com/windmill-labs/windmill/issues/11395)) ([5e59cef](https://github.com/windmill-labs/windmill/commit/5e59cefd1fa579148a06bb687aa60d1de21826b5)) +* gate batch rerun on job read access, scope started_at to workspace ([#11387](https://github.com/windmill-labs/windmill/issues/11387)) ([14a2619](https://github.com/windmill-labs/windmill/commit/14a2619ad24d98592821b12b2e6a54cbcfbc3d09)) +* hold interpolated references and captures to the token path scopes ([#11391](https://github.com/windmill-labs/windmill/issues/11391)) ([cedd6dc](https://github.com/windmill-labs/windmill/commit/cedd6dc901918d3001229fa95a5fe86e369b7bc4)) +* judge IPv4 embedded in IPv6 and pin the object storage test connect ([#11389](https://github.com/windmill-labs/windmill/issues/11389)) ([ec6ec1b](https://github.com/windmill-labs/windmill/commit/ec6ec1b06febd235e8a0d1ae8f5175fd929b79e7)) +* keep smtp_clicktracking_off when syncing instance config ([#11372](https://github.com/windmill-labs/windmill/issues/11372)) ([60ef821](https://github.com/windmill-labs/windmill/commit/60ef82196e014d39b0f497a9b8a88048d6e9792b)) +* keep test panel controls off the args form in debug mode ([#11382](https://github.com/windmill-labs/windmill/issues/11382)) ([22b5a1c](https://github.com/windmill-labs/windmill/commit/22b5a1cd62a483ab42ce7192b3b8dabe7738b99e)) +* let custom workspace error handlers send email with the instance SMTP ([#11365](https://github.com/windmill-labs/windmill/issues/11365)) ([e2be584](https://github.com/windmill-labs/windmill/commit/e2be584ca51bfa7bbe927e7c81c5355817649e7b)) +* list only the paths the caller can read in path autocomplete ([#11388](https://github.com/windmill-labs/windmill/issues/11388)) ([f4dcaf3](https://github.com/windmill-labs/windmill/commit/f4dcaf3e45a90951b6c2da71ac0d7af51eb65d89)) +* never double-process a slow canceled flow in the zombie sweep ([#11368](https://github.com/windmill-labs/windmill/issues/11368)) ([d7a61de](https://github.com/windmill-labs/windmill/commit/d7a61de23f98a09c881121d9595ec3c11101d04f)) +* only let a job's own token claim run lineage ([#11367](https://github.com/windmill-labs/windmill/issues/11367)) ([90f9e59](https://github.com/windmill-labs/windmill/commit/90f9e59321a16ce65ae25b49bda7e632beb313e8)) +* only restart a flow on a version of its own path and workspace ([#11376](https://github.com/windmill-labs/windmill/issues/11376)) ([893e64f](https://github.com/windmill-labs/windmill/commit/893e64f63056ba42b0fe0bcf07c6078830d3669c)) +* redesign run not found page and fix switching to the right workspace ([#11374](https://github.com/windmill-labs/windmill/issues/11374)) ([d9c7d71](https://github.com/windmill-labs/windmill/commit/d9c7d71f072b0562bb3b1642cfdc961a8948671b)) +* run an AI agent tool on the worker its own tag selects ([#11370](https://github.com/windmill-labs/windmill/issues/11370)) ([649c43e](https://github.com/windmill-labs/windmill/commit/649c43e7c162ee47f4896330595647e73d4e8888)) +* scope flow resume to its workspace and minting to the job's run ([#11392](https://github.com/windmill-labs/windmill/issues/11392)) ([163a4ff](https://github.com/windmill-labs/windmill/commit/163a4ffa4e60b1eb312476da617665a71bdae040)) +* scope workspace dependencies create to the path workspace ([#11385](https://github.com/windmill-labs/windmill/issues/11385)) ([3eaf288](https://github.com/windmill-labs/windmill/commit/3eaf2888c002e7697b105fe56d8358e099809e8f)) +* stop the schedule enabled toggle from showing unsaved changes ([#11390](https://github.com/windmill-labs/windmill/issues/11390)) ([3838cd6](https://github.com/windmill-labs/windmill/commit/3838cd6ee08c16556abdf62d0bbf4c73542a6d9c)) + + +### Performance Improvements + +* advance a flow step with one v2_job_status update ([#11357](https://github.com/windmill-labs/windmill/issues/11357)) ([65cba2d](https://github.com/windmill-labs/windmill/commit/65cba2dbb7bf5de88ee474f3849eba3b1bcd1232)) +* complete a job in one statement on the common path ([#11355](https://github.com/windmill-labs/windmill/issues/11355)) ([bebd762](https://github.com/windmill-labs/windmill/commit/bebd762194b2db740d4989d8925e1efb5b348e08)) +* shrink the module graph that gates first paint in dev ([#11373](https://github.com/windmill-labs/windmill/issues/11373)) ([47525b2](https://github.com/windmill-labs/windmill/commit/47525b211a37c2edcccda558d3f2b2300b5cc280)) + ## [1.818.0](https://github.com/windmill-labs/windmill/compare/v1.817.0...v1.818.0) (2026-09-25) diff --git a/CONTEXT.md b/CONTEXT.md index fbc2592cc9..054960607c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,8 +48,26 @@ _Avoid_: thread, session (that names an AI session, a different thing), chat (th One question and the answer to it: the run the question started, the handle that stops it, and the rows it is writing. At most one per conversation, and the chat is held for its whole length — from the moment the question takes the chat, before it has a job, until it is ended. +The server holds the same rule: a question sent to a conversation whose turn is still running +is refused, whoever sends it. Several conversations of one flow can each have a turn running. _Avoid_: request, exchange, message round +**Running turn**: +A turn whose run has not finished. Which conversations have one is the server's to say, so a +chat opened after the turn started — a reload, another tab — still sees it and follows it. +_Avoid_: busy, active, in flight + +**Queued message**: +A question typed into a conversation while its turn runs, sent when that turn ends answered. +At most one per conversation; typing another adds to it. A turn that fails, or is stopped, +hands it back to the composer instead of sending it. +_Avoid_: pending message (a pending message is one already sent and not yet confirmed) + +**Unread**: +The answers that arrived in a conversation while it was not the one shown. Counted per open +chat and forgotten on reload. +_Avoid_: new messages, notifications + **Transcript**: The rows a conversation's chat holds. Not the conversation: it is the newest page plus whatever older pages the reader has scrolled back through, so a question it cannot answer diff --git a/ai_evals/cases/cli.yaml b/ai_evals/cases/cli.yaml index d57425e827..b7abcf7f14 100644 --- a/ai_evals/cases/cli.yaml +++ b/ai_evals/cases/cli.yaml @@ -11,12 +11,6 @@ forbiddenSkills: - write-script-python3 - write-flow - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push @@ -38,12 +32,6 @@ - write-flow forbiddenSkills: - write-script-python3 - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push @@ -65,12 +53,6 @@ forbiddenSkills: - write-script-bun - write-flow - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push @@ -114,12 +96,6 @@ - write-flow requiredSkillsBeforeFirstMutation: - write-flow - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push diff --git a/ai_evals/modes/cli.ts b/ai_evals/modes/cli.ts index 3ce7cf1c16..205cdfa9c4 100644 --- a/ai_evals/modes/cli.ts +++ b/ai_evals/modes/cli.ts @@ -17,8 +17,12 @@ import type { BenchmarkArtifactFile, CliTrace, ModeRunner } from "../core/types" const IGNORE_WORKSPACE_FILES = new Set([ ".claude", + ".agents", "AGENTS.md", + "AGENTS.wmill.md", "CLAUDE.md", + // the guidance has the agent create one for every new folder + "folder.meta.yaml", "rt.d.ts", ".wmill-benchmark-bin", ".wmill-benchmark-wmill-invocations.log", @@ -89,7 +93,7 @@ export function createCliModeRunner( const run = await runPromptAndCapture( renderedPrompt, workspaceDir, - context.evalCase?.runtime?.maxTurns ?? 6, + context.evalCase?.runtime?.maxTurns ?? 12, modelConfig ); const workspaceFiles = await readDirectoryFiles(workspaceDir, { ignore: IGNORE_WORKSPACE_FILES }); diff --git a/backend/.sqlx/query-8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8.json b/backend/.sqlx/query-0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7.json similarity index 74% rename from backend/.sqlx/query-8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8.json rename to backend/.sqlx/query-0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7.json index 0d8d788602..54d1b5757c 100644 --- a/backend/.sqlx/query-8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8.json +++ b/backend/.sqlx/query-0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1)", + "query": "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1) AND workspace_id = $2", "describe": { "columns": [ { @@ -16,7 +16,8 @@ ], "parameters": { "Left": [ - "UuidArray" + "UuidArray", + "Text" ] }, "nullable": [ @@ -24,5 +25,5 @@ true ] }, - "hash": "8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8" + "hash": "0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7" } diff --git a/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json b/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json deleted file mode 100644 index 0dbcf04c6e..0000000000 --- a/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n schedule.path, t.jobs FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY completed_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "jobs", - "type_info": "JsonArray" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946" -} diff --git a/backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json b/backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json similarity index 53% rename from backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json rename to backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json index a40cc12148..bc821f2b7b 100644 --- a/backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json +++ b/backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ", + "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ", "describe": { "columns": [ { @@ -41,7 +41,8 @@ ], "parameters": { "Left": [ - "Uuid" + "Uuid", + "Text" ] }, "nullable": [ @@ -54,5 +55,5 @@ null ] }, - "hash": "3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb" + "hash": "11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092" } diff --git a/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json b/backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json similarity index 95% rename from backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json rename to backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json index 6d948ee8ae..6c4d73699b 100644 --- a/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json +++ b/backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip, labels\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip, labels\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ", "describe": { "columns": [ { @@ -167,6 +167,11 @@ "ordinal": 32, "name": "labels", "type_info": "TextArray" + }, + { + "ordinal": 33, + "name": "late_run_streak", + "type_info": "Int4" } ], "parameters": { @@ -236,8 +241,9 @@ true, true, true, - true + true, + false ] }, - "hash": "2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6" + "hash": "1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8" } diff --git a/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json b/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json deleted file mode 100644 index e8ffcfb95b..0000000000 --- a/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT FROM native_trigger GROUP BY service_name\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "kind!", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "count!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - null - ] - }, - "hash": "1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e" -} diff --git a/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json b/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json new file mode 100644 index 0000000000..2a8d0ab26f --- /dev/null +++ b/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path,\n trigger_kind::text AS trigger_kind,\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)\n -- An app editor preview also runs an app script, at an app path it does not\n -- have to own; only deployed-app runs are stamped with their app.\n WHEN 'appscript' THEN COALESCE(trigger_kind = 'app'\n AND starts_with(runnable_path, trigger || '/'), false)\n ELSE true\n END AS \"origin_verified!\"\n FROM lineage ORDER BY depth", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "trigger_kind", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "origin_verified!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null + ] + }, + "hash": "201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655" +} diff --git a/backend/.sqlx/query-2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b.json b/backend/.sqlx/query-2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b.json new file mode 100644 index 0000000000..d19056a19a --- /dev/null +++ b/backend/.sqlx/query-2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b" +} diff --git a/backend/.sqlx/query-2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c.json b/backend/.sqlx/query-2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c.json new file mode 100644 index 0000000000..13a8e589b4 --- /dev/null +++ b/backend/.sqlx/query-2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_job, root_job, flow_innermost_root_job FROM v2_job\n WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c" +} diff --git a/backend/.sqlx/query-2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4.json b/backend/.sqlx/query-2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4.json new file mode 100644 index 0000000000..46d609ecb4 --- /dev/null +++ b/backend/.sqlx/query-2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT c.id AS \"id!\", u.job_id AS \"job_id!\", u.created_seq AS \"user_seq!\"\n FROM unnest($1::uuid[]) AS c(id)\n CROSS JOIN LATERAL (\n SELECT job_id, created_seq\n FROM flow_conversation_message\n WHERE conversation_id = c.id AND message_type = 'user'\n ORDER BY created_seq DESC\n LIMIT 1\n ) u\n WHERE u.job_id IS NOT NULL\n AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "job_id!", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_seq!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + null, + true, + false + ] + }, + "hash": "2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4" +} diff --git a/backend/.sqlx/query-2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7.json b/backend/.sqlx/query-2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7.json new file mode 100644 index 0000000000..88b7f6411b --- /dev/null +++ b/backend/.sqlx/query-2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM capture WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7" +} diff --git a/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json b/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json new file mode 100644 index 0000000000..e1fe716eab --- /dev/null +++ b/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n schedule.path, t.jobs, schedule.late_run_streak, schedule.missed_occurrences, schedule.last_missed_at FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY completed_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "jobs", + "type_info": "JsonArray" + }, + { + "ordinal": 2, + "name": "late_run_streak", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "missed_occurrences", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "last_missed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [ + false, + null, + false, + false, + true + ] + }, + "hash": "39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05" +} diff --git a/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json b/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json new file mode 100644 index 0000000000..814659877f --- /dev/null +++ b/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE schedule SET late_run_streak = 0\n WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f" +} diff --git a/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json b/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json new file mode 100644 index 0000000000..552f2f57de --- /dev/null +++ b/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT late_run_streak FROM schedule WHERE workspace_id = $1 AND path = $2 FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "late_run_streak", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0" +} diff --git a/backend/.sqlx/query-535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a.json b/backend/.sqlx/query-535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a.json new file mode 100644 index 0000000000..ee7f603da1 --- /dev/null +++ b/backend/.sqlx/query-535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH norm AS (\n SELECT\n j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, j.created_by,\n -- Project effective kind for dispatch: pass script/flow through;\n -- for singlestepflow, read the wrapped runnable's type from\n -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').\n CASE\n WHEN j.kind IN ('script', 'flow') THEN j.kind::text\n WHEN j.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n LIMIT 1),\n 'script'\n )\n END AS norm_kind,\n -- Pinned script hash for script-wrapped singlestepflow lives in\n -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a\n -- version, so this is NULL there (Flow rerun pushes by path).\n (CASE WHEN j.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END) AS ssf_hash\n FROM v2_job j\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND j.kind IN ('script', 'flow', 'singlestepflow')\n )\n SELECT\n n.id,\n n.norm_kind::JOB_KIND AS \"kind!: _\",\n COALESCE(s.path, f.path, n.runnable_path) AS \"script_path!\",\n -- script_hash is unused on the Flow rerun path (path-based push), so\n -- 0 is a safe placeholder when no version is pinned.\n COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n n.args AS input,\n n.created_by,\n -- Pinned schema for script/flow; latest-by-path fallback for\n -- singlestepflow so input_transforms still resolve at rerun time.\n COALESCE(\n s.schema,\n f.schema,\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN\n (SELECT s2.schema FROM script s2\n WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path\n ORDER BY s2.created_at DESC LIMIT 1)\n END),\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow\n LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)\n END)\n ) AS \"schema: _\"\n FROM norm n\n LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'\n LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = n.id\n LEFT JOIN v2_job_queue jq ON jq.id = n.id\n WHERE n.norm_kind IS NOT NULL\n AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL\n AND (\n n.kind = 'singlestepflow'\n OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "kind!: _", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "script_path!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "script_hash!: _", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "scheduled_for!: _", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "input", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "schema: _", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + false, + null, + null, + null, + null, + true, + false, + null + ] + }, + "hash": "535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a" +} diff --git a/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json b/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json new file mode 100644 index 0000000000..72699120db --- /dev/null +++ b/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE schedule SET late_run_streak = late_run_streak + 1,\n missed_occurrences = CASE WHEN late_run_streak = 0 THEN $3\n ELSE missed_occurrences + $3 END,\n last_missed_at = $4\n WHERE workspace_id = $1 AND path = $2\n RETURNING late_run_streak, missed_occurrences", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "late_run_streak", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "missed_occurrences", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb" +} diff --git a/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json b/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json deleted file mode 100644 index e8f746ded0..0000000000 --- a/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH norm AS (\n SELECT\n j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args,\n -- Project effective kind for dispatch: pass script/flow through;\n -- for singlestepflow, read the wrapped runnable's type from\n -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').\n CASE\n WHEN j.kind IN ('script', 'flow') THEN j.kind::text\n WHEN j.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n LIMIT 1),\n 'script'\n )\n END AS norm_kind,\n -- Pinned script hash for script-wrapped singlestepflow lives in\n -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a\n -- version, so this is NULL there (Flow rerun pushes by path).\n (CASE WHEN j.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END) AS ssf_hash\n FROM v2_job j\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND j.kind IN ('script', 'flow', 'singlestepflow')\n )\n SELECT\n n.id,\n n.norm_kind::JOB_KIND AS \"kind!: _\",\n COALESCE(s.path, f.path, n.runnable_path) AS \"script_path!\",\n -- script_hash is unused on the Flow rerun path (path-based push), so\n -- 0 is a safe placeholder when no version is pinned.\n COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n n.args AS input,\n -- Pinned schema for script/flow; latest-by-path fallback for\n -- singlestepflow so input_transforms still resolve at rerun time.\n COALESCE(\n s.schema,\n f.schema,\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN\n (SELECT s2.schema FROM script s2\n WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path\n ORDER BY s2.created_at DESC LIMIT 1)\n END),\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow\n LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)\n END)\n ) AS \"schema: _\"\n FROM norm n\n LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'\n LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = n.id\n LEFT JOIN v2_job_queue jq ON jq.id = n.id\n WHERE n.norm_kind IS NOT NULL\n AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL\n AND (\n n.kind = 'singlestepflow'\n OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "kind!: _", - "type_info": { - "Custom": { - "name": "job_kind", - "kind": { - "Enum": [ - "script", - "preview", - "flow", - "dependencies", - "flowpreview", - "script_hub", - "identity", - "flowdependencies", - "http", - "graphql", - "postgresql", - "noop", - "appdependencies", - "deploymentcallback", - "singlestepflow", - "flowscript", - "flownode", - "appscript", - "aiagent", - "unassigned_script", - "unassigned_flow", - "unassigned_singlestepflow" - ] - } - } - } - }, - { - "ordinal": 2, - "name": "script_path!", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "script_hash!: _", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "scheduled_for!: _", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "input", - "type_info": "Jsonb" - }, - { - "ordinal": 6, - "name": "schema: _", - "type_info": "Json" - } - ], - "parameters": { - "Left": [ - "UuidArray", - "Text" - ] - }, - "nullable": [ - false, - null, - null, - null, - null, - true, - null - ] - }, - "hash": "6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada" -} diff --git a/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json b/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json deleted file mode 100644 index a03fab7081..0000000000 --- a/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, enabled, pwd FROM datatable_role", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "enabled", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "pwd", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - true - ] - }, - "hash": "71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0" -} diff --git a/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json b/backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json similarity index 79% rename from backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json rename to backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json index 5c0e45b0d4..b5afa669f5 100644 --- a/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json +++ b/backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels),\n late_run_streak = 0,\n missed_occurrences = 0,\n last_missed_at = NULL\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ", "describe": { "columns": [ { @@ -167,6 +167,11 @@ "ordinal": 32, "name": "labels", "type_info": "TextArray" + }, + { + "ordinal": 33, + "name": "late_run_streak", + "type_info": "Int4" } ], "parameters": { @@ -233,8 +238,9 @@ true, true, true, - true + true, + false ] }, - "hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56" + "hash": "793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa" } diff --git a/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json b/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json deleted file mode 100644 index c36b6fb361..0000000000 --- a/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $2\n ORDER BY ws.workspace_id, dt.key\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "workspace_id!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "datatable!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629" -} diff --git a/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json b/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json deleted file mode 100644 index 7627d9828d..0000000000 --- a/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Bool", - "Text" - ] - }, - "nullable": [] - }, - "hash": "86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba" -} diff --git a/backend/.sqlx/query-872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752.json b/backend/.sqlx/query-872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752.json new file mode 100644 index 0000000000..3bdcb5e48c --- /dev/null +++ b/backend/.sqlx/query-872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\", 0::BIGINT AS \"suspended!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT, 0::BIGINT FROM native_trigger GROUP BY service_name\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "kind!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "count!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "suspended!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752" +} diff --git a/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json b/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json deleted file mode 100644 index 4b3a2f33e9..0000000000 --- a/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT jsonb_object_keys(value->'databases') FROM global_settings\n WHERE name = 'custom_instance_pg_databases'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "jsonb_object_keys", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6" -} diff --git a/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json b/backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json similarity index 73% rename from backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json rename to backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json index a205bcbbe6..0df21a408a 100644 --- a/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json +++ b/backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2,\n late_run_streak = 0,\n missed_occurrences = 0,\n last_missed_at = NULL\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ", "describe": { "columns": [ { @@ -167,6 +167,11 @@ "ordinal": 32, "name": "labels", "type_info": "TextArray" + }, + { + "ordinal": 33, + "name": "late_run_streak", + "type_info": "Int4" } ], "parameters": { @@ -210,8 +215,9 @@ true, true, true, - true + true, + false ] }, - "hash": "642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe" + "hash": "ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071" } diff --git a/backend/.sqlx/query-cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95.json b/backend/.sqlx/query-cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95.json new file mode 100644 index 0000000000..02e74bd0fb --- /dev/null +++ b/backend/.sqlx/query-cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2 AND path = $3) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95" +} diff --git a/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json b/backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json similarity index 70% rename from backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json rename to backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json index ceb05001e4..f3958e713d 100644 --- a/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json +++ b/backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6", + "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip),\n current_job_id = COALESCE($14, current_job_id), current_job_workspace_id = COALESCE($15, current_job_workspace_id) WHERE worker = $6", "describe": { "columns": [], "parameters": { @@ -17,10 +17,12 @@ "Float4", "Float4", "Bool", + "Varchar", + "Uuid", "Varchar" ] }, "nullable": [] }, - "hash": "0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626" + "hash": "d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d" } diff --git a/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json b/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json deleted file mode 100644 index 49875acd78..0000000000 --- a/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id = $1\n AND dt.key <> $2\n AND NOT dt.value ? 'permissions'\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $3\n ORDER BY dt.key", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "datatable!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40" -} diff --git a/backend/.sqlx/query-f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7.json b/backend/.sqlx/query-f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7.json deleted file mode 100644 index f787574b3a..0000000000 --- a/backend/.sqlx/query-f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS (SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 59eb6a4c6f..4d73a953b8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -781,7 +781,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", "tokio-websockets", "tracing", @@ -1353,7 +1353,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tower 0.5.3", "tracing", ] @@ -1703,9 +1703,9 @@ dependencies = [ [[package]] name = "bigdecimal" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" dependencies = [ "autocfg", "libm", @@ -4010,7 +4010,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-socks", "tokio-util", "tokio-vsock", @@ -6265,7 +6265,7 @@ dependencies = [ "hyper-util", "pin-project-lite", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tower-service", ] @@ -6331,7 +6331,7 @@ dependencies = [ "rustls 0.23.35", "rustls-native-certs 0.8.4", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tower-service", "webpki-roots 1.0.9", ] @@ -7570,7 +7570,7 @@ dependencies = [ "rustls-pki-types", "smtp-proto", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "webpki-roots 0.26.11", ] @@ -10356,7 +10356,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", "tower 0.5.3", "tower-http", @@ -10402,7 +10402,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", "tower 0.5.3", "tower-http", @@ -10569,9 +10569,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.4.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ee366004e5f87c103be91c6c09337811517ae8d2f919ece9da7c21444b85c5" +checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -11529,9 +11529,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -11550,9 +11550,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -13449,9 +13449,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls 0.23.35", "tokio", @@ -13558,7 +13558,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", ] @@ -13651,7 +13651,7 @@ dependencies = [ "rustls-pemfile 2.2.0", "socket2 0.5.10", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tower 0.4.13", "tower-layer", @@ -13684,7 +13684,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "socket2 0.5.10", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tower 0.5.3", "tower-layer", @@ -14792,7 +14792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-nats", @@ -14880,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.818.0" +version = "1.819.0" dependencies = [ "async-stream", "async-trait", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14927,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "argon2", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15090,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15107,7 +15107,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15133,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.818.0" +version = "1.819.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15143,7 +15143,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15160,7 +15160,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15182,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15205,7 +15205,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15221,7 +15221,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15265,7 +15265,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-nats", @@ -15315,7 +15315,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15340,7 +15340,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15368,7 +15368,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15390,7 +15390,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15410,7 +15410,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15452,7 +15452,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15481,7 +15481,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.818.0" +version = "1.819.0" dependencies = [ "lazy_static", "serde", @@ -15493,7 +15493,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.818.0" +version = "1.819.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15517,7 +15517,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15531,7 +15531,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.818.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15551,6 +15551,7 @@ dependencies = [ "strum", "tokio", "tokio-postgres", + "tokio-util", "tracing", "url", "uuid", @@ -15569,7 +15570,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.818.0" +version = "1.819.0" dependencies = [ "chrono", "lazy_static", @@ -15583,7 +15584,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15602,7 +15603,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.818.0" +version = "1.819.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15660,6 +15661,7 @@ dependencies = [ "pin-project-lite", "pkcs1", "postgres-native-tls 0.5.3", + "postgres-protocol", "prometheus", "quick_cache", "rand 0.9.5", @@ -15691,7 +15693,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tokio", "tokio-postgres", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tokio-util", "tonic 0.13.1", @@ -15714,7 +15716,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.818.0" +version = "1.819.0" dependencies = [ "chrono", "futures", @@ -15734,7 +15736,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.818.0" +version = "1.819.0" dependencies = [ "regex", "serde", @@ -15751,7 +15753,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15778,7 +15780,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "futures", @@ -15795,7 +15797,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.818.0" +version = "1.819.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15811,7 +15813,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -15832,7 +15834,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -15863,7 +15865,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "arc-swap", @@ -15888,7 +15890,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-stream", @@ -15906,6 +15908,7 @@ dependencies = [ "lazy_static", "object_store", "quick_cache", + "reqwest 0.12.28", "reqwest 0.13.5", "serde", "serde_json", @@ -15923,7 +15926,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "futures", @@ -15941,7 +15944,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.818.0" +version = "1.819.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15950,7 +15953,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -15962,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -15974,7 +15977,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "gosyn", @@ -15986,7 +15989,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -15998,7 +16001,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -16010,7 +16013,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "nu-parser", @@ -16021,7 +16024,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16032,7 +16035,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16044,7 +16047,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16055,7 +16058,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -16077,7 +16080,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -16089,7 +16092,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16103,7 +16106,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16120,7 +16123,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16133,7 +16136,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde", @@ -16145,7 +16148,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16163,7 +16166,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16179,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16195,7 +16198,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16209,7 +16212,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -16248,7 +16251,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "const_format", @@ -16288,7 +16291,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.818.0" +version = "1.819.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16299,7 +16302,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -16334,7 +16337,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16358,7 +16361,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16391,7 +16394,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16418,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16451,7 +16454,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16471,7 +16474,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16505,7 +16508,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16542,7 +16545,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16565,7 +16568,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16589,7 +16592,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-nats", @@ -16613,7 +16616,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16648,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16676,7 +16679,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16701,7 +16704,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16720,7 +16723,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-once-cell", @@ -16793,7 +16796,7 @@ dependencies = [ "tiberius", "tokio", "tokio-postgres", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tokio-util", "tracing", @@ -16838,7 +16841,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.818.0" +version = "1.819.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1a564f3644..27d8aeac8e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.818.0" +version = "1.819.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.818.0" +version = "1.819.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -511,13 +511,15 @@ quick-xml = { version = "^0.37", features = ["serialize"] } url = { version = "^2" , features = ["serde"]} async-oauth2 = "0.5.1" reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart", "query", "form"] } +# The reqwest object_store builds its HTTP clients with, for a custom `HttpConnector`. +reqwest_object_store = { package = "reqwest", version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] } eventsource-stream = "0.2.3" time = "^0" serde_urlencoded = "^0" astral-tokio-tar = "^0.6.2" tempfile = "^3" x509-parser = "^0.16" -tokio-util = { version = "=0.7.17", features = ["io"] } +tokio-util = { version = "=0.7.17", features = ["io", "rt"] } json-pointer = "^0" itertools = "^0.14.0" regex = "^1" @@ -624,6 +626,7 @@ wasm-bindgen-test = "^0" convert_case = "0.6.0" getrandom = "0.2" tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]} +postgres-protocol = "0.6" rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"} rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } bit-vec = "=0.6.3" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index b258558888..21e445394e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -7a256cf353db7cf64a60a09fa0de7f3a8b27f626 +dfe7b8b9204d21e0264fbea1c6f6eedf9e738d56 diff --git a/backend/migrations/20260917133824_datatable_role_cluster.down.sql b/backend/migrations/20260917133824_datatable_role_cluster.down.sql new file mode 100644 index 0000000000..379f1e3d34 --- /dev/null +++ b/backend/migrations/20260917133824_datatable_role_cluster.down.sql @@ -0,0 +1,22 @@ +-- Roles on the external cluster are live logins there; dropping the column would forget them. +LOCK TABLE datatable_role; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM datatable_role WHERE cluster <> 'instance') THEN + RAISE EXCEPTION 'datatable_role holds roles on the external instance cluster. Delete them in instance settings first.'; + END IF; + -- Before this, only data tables on Windmill's own cluster could be under roles, and a role + -- block left with just `admin` survives deleting every external role. + IF EXISTS ( + SELECT 1 FROM workspace_settings ws, + jsonb_each(CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt + WHERE dt.value->'database'->>'resource_type' = 'external_instance' + AND dt.value ? 'permissions' + ) THEN + RAISE EXCEPTION 'external instance data tables are still under roles. Turn their roles off first.'; + END IF; +END $$; +ALTER TABLE datatable_role DROP CONSTRAINT datatable_role_cluster_name_key; +ALTER TABLE datatable_role ADD CONSTRAINT datatable_role_name_key UNIQUE (name); +ALTER TABLE datatable_role DROP COLUMN cluster; diff --git a/backend/migrations/20260917133824_datatable_role_cluster.up.sql b/backend/migrations/20260917133824_datatable_role_cluster.up.sql new file mode 100644 index 0000000000..d63f06f1f4 --- /dev/null +++ b/backend/migrations/20260917133824_datatable_role_cluster.up.sql @@ -0,0 +1,8 @@ +-- A data table role is a Postgres login on one cluster: Windmill's own ('instance'), or the external +-- instance cluster ('external_instance'). Role names are the cluster's own key, so they are unique +-- per cluster rather than across the instance. +ALTER TABLE datatable_role + ADD COLUMN cluster VARCHAR(20) NOT NULL DEFAULT 'instance' + CHECK (cluster IN ('instance', 'external_instance')); +ALTER TABLE datatable_role DROP CONSTRAINT datatable_role_name_key; +ALTER TABLE datatable_role ADD CONSTRAINT datatable_role_cluster_name_key UNIQUE (cluster, name); diff --git a/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql b/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql new file mode 100644 index 0000000000..b89d6340ae --- /dev/null +++ b/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE schedule DROP COLUMN last_missed_at; +ALTER TABLE schedule DROP COLUMN missed_occurrences; +ALTER TABLE schedule DROP COLUMN late_run_streak; diff --git a/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql b/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql new file mode 100644 index 0000000000..469eed3a93 --- /dev/null +++ b/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql @@ -0,0 +1,7 @@ +-- A late run finished (or, for flows, started) after the next occurrence was due, so that +-- occurrence was missed. late_run_streak counts late runs in a row and is reset by a run on +-- time; missed_occurrences is what the latest streak missed and last_missed_at when, both +-- kept after the streak ends so a recent one stays visible. An edit or toggle clears all. +ALTER TABLE schedule ADD COLUMN late_run_streak INTEGER NOT NULL DEFAULT 0; +ALTER TABLE schedule ADD COLUMN missed_occurrences INTEGER NOT NULL DEFAULT 0; +ALTER TABLE schedule ADD COLUMN last_missed_at TIMESTAMPTZ; diff --git a/backend/migrations/20260929092707_workspace_fork_creation.down.sql b/backend/migrations/20260929092707_workspace_fork_creation.down.sql new file mode 100644 index 0000000000..924855e129 --- /dev/null +++ b/backend/migrations/20260929092707_workspace_fork_creation.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS workspace_fork_creation; diff --git a/backend/migrations/20260929092707_workspace_fork_creation.up.sql b/backend/migrations/20260929092707_workspace_fork_creation.up.sql new file mode 100644 index 0000000000..8e3a678419 --- /dev/null +++ b/backend/migrations/20260929092707_workspace_fork_creation.up.sql @@ -0,0 +1,20 @@ +-- A fork being created in the background, and how that ended. The fork's own workspace row only +-- exists once the copy commits, so this is where a failure is reported to whoever polls for it. +-- `heartbeat_at` is refreshed while the copy runs: an unfinished row that stops being refreshed +-- belongs to a server that went away mid-copy. `step` names the part of the copy it is in. +-- `creation_id` names one attempt: the row is reused when a failed or abandoned one is retried, and +-- whoever polls reads only the attempt it started. +CREATE TABLE workspace_fork_creation ( + fork_workspace_id VARCHAR(50) PRIMARY KEY, + parent_workspace_id VARCHAR(50) NOT NULL, + creation_id UUID NOT NULL, + created_by VARCHAR(255) NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(), + step TEXT, + finished_at TIMESTAMPTZ, + error TEXT +); + +GRANT ALL ON workspace_fork_creation TO windmill_user; +GRANT ALL ON workspace_fork_creation TO windmill_admin; diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index dfa77d4d7b..0ad36d8e81 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.818.0" +version = "1.819.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.818.0" +version = "1.819.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.818.0" +version = "1.819.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.818.0" +version = "1.819.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 91cd754826..597b7621c7 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.818.0" +version = "1.819.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/tests/app_run_mode_lock_strip.rs b/backend/tests/app_run_mode_lock_strip.rs index e2bbf631db..d13d34a6c7 100644 --- a/backend/tests/app_run_mode_lock_strip.rs +++ b/backend/tests/app_run_mode_lock_strip.rs @@ -238,3 +238,65 @@ async fn test_run_mode_rejects_traversal_component(db: Pool) -> anyhow Ok(()) } + +/// A deployed app's inline script (`app_script` id) runs as an `appscript` job whose +/// path job identity reads (OIDC `sub`), so it is derived server-side there too. +#[sqlx::test(fixtures("base"))] +async fn test_app_script_run_derives_path(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + let app_path = "u/test-user/appscript_path"; + let pin = rawscript_pin(CONTENT); + let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN") + .json(&json!({ + "path": app_path, + "summary": "", + "value": {}, + "policy": { + "execution_mode": "viewer", + "triggerables_v2": { format!("comp:{pin}"): { "static_inputs": {}, "one_of_inputs": {} } } + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?); + + let sha = pin.trim_start_matches("rawscript/").to_string(); + let script_id: i64 = sqlx::query_scalar( + "INSERT INTO app_script (app, hash, code, code_sha256) + SELECT id, $2, $3, $2 FROM app WHERE path = $1 RETURNING id", + ) + .bind(app_path) + .bind(&sha) + .bind(CONTENT) + .fetch_one(&db) + .await?; + + let resp = authed( + client().post(format!("{ws}/apps_u/execute_component/{app_path}")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "args": {}, + "component": "comp", + "id": script_id, + "raw_code": { "language": "python3", "content": "", "path": CALLER_PATH } + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 200, "deployed app script run must be accepted: {body}"); + let uuid = uuid::Uuid::parse_str(body.trim())?; + let (_, _, _, _, runnable_path) = job_fields(&db, uuid).await?; + assert_eq!( + runnable_path.as_deref(), + Some(format!("{app_path}/comp").as_str()), + "an app_script run must not take the caller's path" + ); + + Ok(()) +} diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs index b1708465f9..51a6afa565 100644 --- a/backend/tests/batch_rerun.rs +++ b/backend/tests/batch_rerun.rs @@ -615,3 +615,46 @@ async fn list_groups_ssf_flow_has_schema_via_path(db: Pool) -> anyhow: ); Ok(()) } + +/// A member who may run the script but cannot see another user's run of it must not +/// be able to rerun that run: the rerun would hand them a job carrying its args. +#[sqlx::test(fixtures("base", "batch_rerun"))] +async fn batch_rerun_denies_invisible_source_job(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let member = windmill_api_client::create_client( + &format!("http://localhost:{}", server.addr.port()), + "SECRET_TOKEN_2".to_string(), + ); + // `false` is a read (run) grant, not a denial: test-user-2 can run the script. + sqlx::query("UPDATE script SET extra_perms = '{\"u/test-user-2\": false}' WHERE hash = $1") + .bind(SCRIPT_HASH) + .execute(&db) + .await?; + + let original = push_completed(&db, script_payload(), vec![("name", json!("secret"))]).await?; + + let results = batch_rerun( + &member, + json!({ + "job_ids": [original], + "script_options_by_path": {}, + "flow_options_by_path": {}, + }), + ) + .await?; + assert_eq!(results.len(), 1, "the denied job must be reported"); + assert!( + results[0] + .as_ref() + .is_err_and(|e| e.starts_with("Permission denied")), + "rerun must be denied by the read gate: {:?}", + results[0] + ); + let pushed: i64 = + sqlx::query_scalar("SELECT count(*) FROM v2_job WHERE created_by = 'test-user-2'") + .fetch_one(&db) + .await?; + assert_eq!(pushed, 0); + Ok(()) +} diff --git a/backend/tests/fixtures/job_provenance.sql b/backend/tests/fixtures/job_provenance.sql new file mode 100644 index 0000000000..202c8f2df7 --- /dev/null +++ b/backend/tests/fixtures/job_provenance.sql @@ -0,0 +1,32 @@ +-- Add-on fixture for job_provenance.rs (combine with `base.sql`). A deployed script +-- f/t/tool run as a step under three parents: the deployed flow f/t/agent, a flow +-- preview claiming that path, and f/t/agent running the version of another flow. + +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) +VALUES ('test-workspace', 1111111111, 'f/t/tool', 'echo tool', 'bash', 'script', 'test-user', '{}', '', '', ''); + +INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms, versions) +VALUES + ('test-workspace', 'f/t/agent', '', '', '{"modules":[]}', 'test-user', NOW(), '{}', '{}', ARRAY[2222222222::bigint]), + ('test-workspace', 'u/test-user/evil', '', '', '{"modules":[]}', 'test-user', NOW(), '{}', '{}', ARRAY[3333333333::bigint]); + +INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at) +VALUES + (2222222222, 'test-workspace', 'f/t/agent', '{"modules":[]}', '{}', 'test-user', NOW()), + (3333333333, 'test-workspace', 'u/test-user/evil', '{"modules":[]}', '{}', 'test-user', NOW()); + +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000001', 'test-workspace', 'flow', 'f/t/agent', 2222222222, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000002', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000003', 'test-workspace', 'flowpreview', 'f/t/agent', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000004', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000003', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000005', 'test-workspace', 'flow', 'f/t/agent', 3333333333, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000006', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000005', 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- An app script run of f/t/app stamped by a deployed-app run, and one that is not +-- (an app editor preview). +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, trigger_kind, trigger, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000007', 'test-workspace', 'appscript', 'f/t/app/comp', 'app', 'f/t/app', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000008', 'test-workspace', 'appscript', 'f/t/app/comp', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'); diff --git a/backend/tests/flow_conversation_running_turn.rs b/backend/tests/flow_conversation_running_turn.rs new file mode 100644 index 0000000000..392ff6f5cf --- /dev/null +++ b/backend/tests/flow_conversation_running_turn.rs @@ -0,0 +1,87 @@ +//! A conversation answers one message at a time: while the run its newest user message started +//! is queued or running, the list reports that turn and a run into the conversation is refused. +//! +//! Uses runtime `sqlx::query` (not the compile-time macros) so no offline query cache is +//! needed, matching v2_job_delete_orphans.rs. + +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::error::Error; +use windmill_common::flow_conversations::{get_or_create_conversation_with_id, running_turns}; +use windmill_test_utils::*; + +const WS: &str = "test-workspace"; +const CONV: Uuid = Uuid::from_u128(0x5eed); + +/// Takes the conversation for a turn, as a run does, without keeping what it writes. +async fn take_conversation(db: &Pool) -> windmill_common::error::Result { + let mut tx = db.begin().await?; + let result = + get_or_create_conversation_with_id(&mut tx, WS, "f/flow", "test-user", "t", CONV, false) + .await; + tx.rollback().await?; + result.map(|c| c.id) +} + +#[sqlx::test(fixtures("base"))] +async fn test_a_conversation_refuses_a_turn_while_one_runs( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let job_id = Uuid::new_v4(); + sqlx::query("INSERT INTO v2_job (id, workspace_id, kind) VALUES ($1, $2, 'flow')") + .bind(job_id) + .bind(WS) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, $2, now())", + ) + .bind(job_id) + .bind(WS) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by) + VALUES ($1, $2, 'f/flow', 'test-user')", + ) + .bind(CONV) + .bind(WS) + .execute(&db) + .await?; + let user_seq: i64 = sqlx::query_scalar( + "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id) + VALUES ($1, 'user', 'hi', $2) RETURNING created_seq", + ) + .bind(CONV) + .bind(job_id) + .fetch_one(&db) + .await?; + + let running = running_turns(&db, &[CONV]).await?; + let turn = running + .get(&CONV) + .expect("the queued run is the conversation's running turn"); + assert_eq!((turn.job_id, turn.user_seq), (job_id, user_seq)); + + match take_conversation(&db).await { + Err(Error::Generic(status, body)) => { + assert_eq!(status.as_u16(), 409); + assert!( + body.contains(&job_id.to_string()), + "the refusal names the running job: {body}" + ); + } + other => panic!("expected a 409 while the first turn runs, got {other:?}"), + } + + // The run ends: it leaves the queue, and the conversation takes the next message. + sqlx::query("DELETE FROM v2_job_queue WHERE id = $1") + .bind(job_id) + .execute(&db) + .await?; + assert!(running_turns(&db, &[CONV]).await?.is_empty()); + assert_eq!(take_conversation(&db).await?, CONV); + Ok(()) +} diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 26a46d272e..52ebbee7e7 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1609,6 +1609,52 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

) { + clear_settings_and_configs(&db).await; + let cluster = |host: &str, password: &str| serde_json::json!({ "host": host, "port": 5432, "user": "wm_admin", "password": password }); + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES + ('external_instance_pg', $1), + ('external_instance_pg_state', '{\"databases\": {\"dt_a\": {\"success\": true}}}')", + ) + .bind(cluster("pg-a.internal", "one")) + .execute(&db) + .await + .unwrap(); + let current = BTreeMap::from([( + "external_instance_pg".to_string(), + cluster("pg-a.internal", "one"), + )]); + let sync = |value: serde_json::Value| { + let desired = BTreeMap::from([("external_instance_pg".to_string(), value)]); + let (db, current) = (db.clone(), current.clone()); + async move { + windmill_common::instance_config::sync_global_settings_declarative( + &db, ¤t, &desired, + ) + .await + } + }; + + let err = sync(cluster("pg-b.internal", "one")) + .await + .expect_err("another host must be refused while dt_a is registered"); + assert!(err.to_string().contains("dt_a"), "got: {err}"); + assert_eq!( + get_global_setting(&db, "external_instance_pg").await, + Some(cluster("pg-a.internal", "one")) + ); + + sync(cluster("PG-A.internal ", "two")) + .await + .expect("a new login on the same cluster must sync"); +} + /// The accent color is interpolated into a stylesheet every user loads, so the operator /// path must refuse anything but `#rrggbb` just like the settings API does. #[sqlx::test(fixtures("base"))] diff --git a/backend/tests/job_provenance.rs b/backend/tests/job_provenance.rs new file mode 100644 index 0000000000..41de4f6f77 --- /dev/null +++ b/backend/tests/job_provenance.rs @@ -0,0 +1,44 @@ +//! `deployed` gates what job OIDC tokens let external trust policies accept, so a job +//! whose code or path the caller chose must never come out deployed. + +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::job_provenance::job_provenance; + +async fn deployed(db: &Pool, job: &str) -> bool { + job_provenance(db, &Uuid::parse_str(job).unwrap(), "test-workspace") + .await + .unwrap() + .unwrap() + .deployed +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn step_of_deployed_flow_is_deployed(db: Pool) { + let p = job_provenance( + &db, + &Uuid::parse_str("3bb0c0de-0000-4000-8000-000000000002").unwrap(), + "test-workspace", + ) + .await + .unwrap() + .unwrap(); + assert!(p.deployed); + assert_eq!(p.root_path.as_deref(), Some("f/t/agent")); +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn step_under_flow_preview_is_not_deployed(db: Pool) { + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000004").await); +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn flow_running_another_flows_version_is_not_deployed(db: Pool) { + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000006").await); +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn app_script_is_deployed_only_from_a_deployed_app_run(db: Pool) { + assert!(deployed(&db, "3bb0c0de-0000-4000-8000-000000000007").await); + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000008").await); +} diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 8c5bfee0eb..9325f25652 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -513,12 +513,12 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul let (status, secret) = get( &authed_base, &format!("job_signature/{STEP_JOB}/0"), - Some("SECRET_TOKEN_2"), + Some("SECRET_TOKEN"), ) .await; assert!( status.is_success(), - "owner must mint a resume secret: {secret}" + "an admin must mint a resume secret: {secret}" ); let secret = secret.trim().trim_matches('"').to_string(); let approval_result = format!( diff --git a/backend/tests/path_autocomplete_scope.rs b/backend/tests/path_autocomplete_scope.rs new file mode 100644 index 0000000000..a4f9485952 --- /dev/null +++ b/backend/tests/path_autocomplete_scope.rs @@ -0,0 +1,62 @@ +//! `GET /path_autocomplete/list_paths` returns only the paths the caller can read, +//! and its cache never serves one caller's list to another. + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +async fn list_paths(port: u16, token: &str) -> anyhow::Result> { + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/path_autocomplete/list_paths" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await?; + assert_eq!(resp.status(), 200, "list_paths: {}", resp.text().await?); + let body: serde_json::Value = resp.json().await?; + Ok(serde_json::from_value(body["paths"].clone())?) +} + +#[sqlx::test(fixtures("base"))] +async fn test_list_paths_is_scoped_to_caller(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) + VALUES ('test-workspace', 'secret', 'secret', '{}', '{}')", + ) + .execute(&db) + .await?; + for path in [ + "f/secret/hidden", + "u/test-user/private", + "u/test-user-2/mine", + ] { + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description) + VALUES ('test-workspace', $1, 'x', false, '')", + ) + .bind(path) + .execute(&db) + .await?; + } + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The admin's call fills the cache first, so a cache shared across callers + // would hand the admin's list to the non-admin. + assert_eq!( + list_paths(port, "SECRET_TOKEN").await?, + [ + "f/secret/hidden", + "u/test-user-2/mine", + "u/test-user/private" + ] + ); + assert_eq!( + list_paths(port, "SECRET_TOKEN_2").await?, + ["u/test-user-2/mine"] + ); + Ok(()) +} diff --git a/backend/tests/preview_restart_source_access.rs b/backend/tests/preview_restart_source_access.rs new file mode 100644 index 0000000000..b6a552af3d --- /dev/null +++ b/backend/tests/preview_restart_source_access.rs @@ -0,0 +1,77 @@ +//! A flow preview restarted from an earlier run copies that run's step results, so it must be +//! refused unless the caller can see every run the request names. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::jobs::JobPayload; +use windmill_test_utils::*; + +fn identity_flow() -> serde_json::Value { + json!({ "modules": [ + { "id": "a", "value": { "type": "identity" } }, + { "id": "b", "value": { "type": "identity" } }, + ]}) +} + +async fn completed_run(db: &Pool, port: u16, username: &str, email: &str) -> String { + RunJob::from(JobPayload::RawFlow { + value: serde_json::from_value(identity_flow()).unwrap(), + path: None, + restarted_from: None, + }) + .as_user(username, email) + .run_until_complete(db, false, port) + .await + .id + .to_string() +} + +#[sqlx::test(fixtures("base"))] +async fn test_preview_restart_requires_source_read_access(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let admin_run = completed_run(&db, port, "test-user", "test@windmill.dev").await; + let own_run = completed_run(&db, port, "test-user-2", "test2@windmill.dev").await; + + let restart = |restarted_from: serde_json::Value| { + reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/jobs/run/preview_flow" + )) + .bearer_auth("SECRET_TOKEN_2") + .json( + &json!({ "args": {}, "value": identity_flow(), "restarted_from": restarted_from }), + ) + .send() + }; + + let resp = restart(json!({ "flow_job_id": admin_run, "step_id": "b" })).await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + + let resp = restart(json!({ + "flow_job_id": own_run, "step_id": "b", + "nested": { "flow_job_id": admin_run, "step_id": "b" }, + })) + .await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + + let resp = restart(json!({ "flow_job_id": own_run, "step_id": "b" })).await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + // A run hidden on its own but inside a flow the caller can read is readable, as on the run page. + let child_run = completed_run(&db, port, "test-user", "test@windmill.dev").await; + sqlx::query("UPDATE v2_job SET parent_job = $1::uuid WHERE id = $2::uuid") + .bind(&own_run) + .bind(&child_run) + .execute(&db) + .await?; + let resp = restart(json!({ + "flow_job_id": own_run, "step_id": "b", + "nested": { "flow_job_id": child_run, "step_id": "b" }, + })) + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + Ok(()) +} diff --git a/backend/tests/restart_flow_version_scope.rs b/backend/tests/restart_flow_version_scope.rs new file mode 100644 index 0000000000..82e8f8e4e7 --- /dev/null +++ b/backend/tests/restart_flow_version_scope.rs @@ -0,0 +1,135 @@ +//! A restart's `flow_version` is caller-supplied while the restarted job keeps the original +//! job's path, so only versions of that same flow may be restarted on. + +use sqlx::{Pool, Postgres}; +use windmill_common::cache; +use windmill_common::error::Error; +use windmill_common::flow_status::FlowStatus; +use windmill_common::jobs::JobPayload; +use windmill_queue::PushIsolationLevel; + +const WS: &str = "test-workspace"; + +async fn push_restart( + db: &Pool, + completed_job_id: uuid::Uuid, + flow_version: i64, +) -> Result { + let args = std::collections::HashMap::new(); + let (uuid, tx) = windmill_queue::push( + db, + PushIsolationLevel::IsolatedRoot(db.clone()), + WS, + JobPayload::RestartedFlow { + completed_job_id, + step_id: "a".into(), + branch_or_iteration_n: None, + flow_version: Some(flow_version), + branch_chosen: None, + nested: None, + }, + windmill_queue::PushArgs::from(&args), + "test-user", + "test@windmill.dev", + "u/test-user".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + None, + false, + None, + None, + None, + ) + .await?; + tx.commit().await?; + Ok(uuid) +} + +#[sqlx::test(fixtures("base", "hello"))] +async fn test_restart_rejects_flow_version_of_another_path( + db: Pool, +) -> anyhow::Result<()> { + // A completed run of f/system/hello_flow (version 1443253234253453). + let version = 1443253234253453; + let flow = cache::flow::fetch_version(&db, version).await?; + let completed_job_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, created_by, + permissioned_as, permissioned_as_email, tag) + VALUES ($1, $2, 'flow', 'f/system/hello_flow', $3, 'test-user', 'u/test-user', + 'test@windmill.dev', 'flow')", + ) + .bind(completed_job_id) + .bind(WS) + .bind(version) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, flow_status) + VALUES ($1, $2, 0, 'success', $3)", + ) + .bind(completed_job_id) + .bind(WS) + .bind(serde_json::to_value(FlowStatus::new(flow.value()))?) + .execute(&db) + .await?; + + // f/system/hello_with_nodes_flow's version, in the same workspace. + let err = push_restart(&db, completed_job_id, 1443253234253454) + .await + .expect_err("a version of another flow must be rejected"); + assert!(matches!(err, Error::BadRequest(_)), "{err:?}"); + + // A version at the same path in another workspace. + sqlx::query( + "INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by) + SELECT 'other-workspace', path, summary, description, value, edited_by FROM flow + WHERE workspace_id = $1 AND path = 'f/system/hello_flow'", + ) + .bind(WS) + .execute(&db) + .await?; + let other_ws_version: i64 = sqlx::query_scalar( + "INSERT INTO flow_version (workspace_id, path, schema, value, created_by) + SELECT 'other-workspace', path, schema, value, created_by FROM flow_version WHERE id = $1 + RETURNING id", + ) + .bind(version) + .fetch_one(&db) + .await?; + let err = push_restart(&db, completed_job_id, other_ws_version) + .await + .expect_err("a version from another workspace must be rejected"); + assert!(matches!(err, Error::BadRequest(_)), "{err:?}"); + + // Another version of the restarted flow itself stays allowed. + let same_path_version: i64 = sqlx::query_scalar( + "INSERT INTO flow_version (workspace_id, path, schema, value, created_by) + SELECT workspace_id, path, schema, value, created_by FROM flow_version WHERE id = $1 + RETURNING id", + ) + .bind(version) + .fetch_one(&db) + .await?; + push_restart(&db, completed_job_id, same_path_version).await?; + Ok(()) +} diff --git a/backend/tests/run_lineage_auth.rs b/backend/tests/run_lineage_auth.rs new file mode 100644 index 0000000000..4f154cdc69 --- /dev/null +++ b/backend/tests/run_lineage_auth.rs @@ -0,0 +1,136 @@ +//! `parent_job` / `root_job` on the run endpoints set the lineage a job is identified by +//! (`WM_FLOW_JOB_ID`, `WM_ROOT_FLOW_JOB_ID`, the OIDC token's flow path). Only a job's own +//! `WM_TOKEN` may claim that job or its ancestors, and a workspace admin any job of the +//! workspace; a lineage anyone else names is dropped from the pushed job. + +use reqwest::StatusCode; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +const ADMIN_JOB: &str = "a0000000-0000-0000-0000-000000000001"; +const ROOT_JOB: &str = "a0000000-0000-0000-0000-000000000002"; +const USER_JOB: &str = "a0000000-0000-0000-0000-000000000003"; + +async fn insert_job( + db: &Pool, + id: &str, + user: &str, + email: &str, + root: Option<&str>, +) -> anyhow::Result<()> { + let root = root.map(Uuid::parse_str).transpose()?; + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, parent_job, root_job, flow_innermost_root_job) + VALUES ($1, 'test-workspace', $2, 'u/' || $2, $3, 'script', 'deno', 'u/x/y', 'deno', + $4, $4, $4)", + ) + .bind(Uuid::parse_str(id)?) + .bind(user) + .bind(email) + .bind(root) + .execute(db) + .await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_run_lineage_must_be_the_callers_own(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query( + "INSERT INTO script (workspace_id, created_by, content, schema, summary, description, + path, hash, language, lock, kind) + VALUES ('test-workspace', 'test-user-3', 'export function main() {}', '{}', '', '', + 'u/test-user-3/probe', 424242, 'deno', '', 'script')", + ) + .execute(&db) + .await?; + insert_job(&db, ADMIN_JOB, "test-user", "test@windmill.dev", None).await?; + insert_job(&db, ROOT_JOB, "test-user-3", "test3@windmill.dev", None).await?; + insert_job( + &db, + USER_JOB, + "test-user-3", + "test3@windmill.dev", + Some(ROOT_JOB), + ) + .await?; + + let server = ApiServer::start(db.clone()).await?; + set_jwt_secret().await; + let job_token = windmill_common::auth::create_token_for_owner( + &db, + "test-workspace", + "u/test-user-3", + "ephemeral-script", + 300, + "test3@windmill.dev", + &Uuid::parse_str(USER_JOB)?, + None, + None, + ) + .await?; + + let client = reqwest::Client::new(); + let cases = [ + // a user's own token claims neither someone else's job nor even its own + ( + "SECRET_TOKEN_3", + Some(ADMIN_JOB), + Some(ADMIN_JOB), + None, + None, + ), + ("SECRET_TOKEN_3", Some(USER_JOB), None, None, None), + // what the SDKs send from inside a job: that job and its root flow + ( + &job_token, + Some(USER_JOB), + Some(ROOT_JOB), + Some(USER_JOB), + Some(ROOT_JOB), + ), + ( + &job_token, + Some(USER_JOB), + Some(ADMIN_JOB), + Some(USER_JOB), + None, + ), + ("SECRET_TOKEN", Some(USER_JOB), None, Some(USER_JOB), None), + ]; + for (token, parent, root, kept_parent, kept_root) in cases { + let query = [("parent_job", parent), ("root_job", root)] + .into_iter() + .filter_map(|(k, v)| v.map(|v| format!("{k}={v}"))) + .collect::>() + .join("&"); + let resp = client + .post(format!( + "http://localhost:{}/api/w/test-workspace/jobs/run/p/u/test-user-3/probe?{query}", + server.addr.port() + )) + .bearer_auth(token) + .json(&serde_json::json!({})) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, StatusCode::CREATED, "{query}: {body}"); + let (stored_parent, stored_root): (Option, Option) = + sqlx::query_as("SELECT parent_job, flow_innermost_root_job FROM v2_job WHERE id = $1") + .bind(Uuid::parse_str(&body)?) + .fetch_one(&db) + .await?; + let expected = |id: Option<&str>| id.map(Uuid::parse_str).transpose(); + assert_eq!( + stored_parent, + expected(kept_parent)?, + "parent_job for {query}" + ); + assert_eq!(stored_root, expected(kept_root)?, "root_job for {query}"); + } + Ok(()) +} diff --git a/backend/tests/wac_approval_urls.rs b/backend/tests/wac_approval_urls.rs index 0cb33e67c0..7883c69b17 100644 --- a/backend/tests/wac_approval_urls.rs +++ b/backend/tests/wac_approval_urls.rs @@ -201,7 +201,10 @@ async fn wac_approval_urls_mint_guards(db: Pool) -> anyhow::Result<()> let client = reqwest::Client::new(); let mint = |ws: &str, job: &str, key: &str| { let url = format!("{base}/w/{ws}/jobs/wac_approval_urls/{job}/{key}"); - client.get(url).header("Authorization", "Bearer SECRET_TOKEN").send() + client + .get(url) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() }; assert_eq!( @@ -227,8 +230,15 @@ async fn wac_approval_urls_mint_guards(db: Pool) -> anyhow::Result<()> let resp = mint("test-workspace", WAC_JOB, APPROVAL_COLLISION_B).await?; let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "colliding key: {body}"); - assert!(body.contains(APPROVAL_COLLISION_A), "error must name the other key: {body}"); + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "colliding key: {body}" + ); + assert!( + body.contains(APPROVAL_COLLISION_A), + "error must name the other key: {body}" + ); Ok(()) } @@ -236,7 +246,9 @@ async fn wac_approval_urls_mint_guards(db: Pool) -> anyhow::Result<()> /// Colliding keys share one resume_job row and one capability, so the mint that /// records them must let exactly one through however the requests interleave. #[sqlx::test(fixtures("base", "wac_approval_urls"))] -async fn wac_concurrent_colliding_mints_admit_exactly_one(db: Pool) -> anyhow::Result<()> { +async fn wac_concurrent_colliding_mints_admit_exactly_one( + db: Pool, +) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let base = format!("http://localhost:{}/api", server.addr.port()); @@ -262,6 +274,92 @@ async fn wac_concurrent_colliding_mints_admit_exactly_one(db: Pool) -> other => anyhow::bail!("unexpected status {other}"), } } - assert_eq!((ok, rejected), (1, 1), "exactly one colliding key may be minted"); + assert_eq!( + (ok, rejected), + (1, 1), + "exactly one colliding key may be minted" + ); + Ok(()) +} + +/// A resume signature skips the step's approval_conditions, so only the suspended job's own +/// run (its `WM_TOKEN`) or a workspace admin may mint one, and a signature made with another +/// workspace's key must not reach a job outside that workspace. +#[sqlx::test(fixtures("base", "wac_approval_urls"))] +async fn resume_signatures_are_bound_to_the_run_and_its_workspace( + db: Pool, +) -> anyhow::Result<()> { + use hmac::Mac; + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api", server.addr.port()); + let client = reqwest::Client::new(); + set_jwt_secret().await; + let job_token = windmill_common::auth::create_token_for_owner( + &db, + "test-workspace", + "u/test-user-2", + "ephemeral-script", + 300, + "test2@windmill.dev", + &uuid::Uuid::parse_str(WAC_JOB)?, + None, + None, + ) + .await?; + + for route in ["job_signature", "resume_urls"] { + for (token, allowed) in [ + ("SECRET_TOKEN_2", false), + (&job_token, true), + ("SECRET_TOKEN", true), + ] { + let status = client + .get(format!("{base}/w/test-workspace/jobs/{route}/{WAC_JOB}/7")) + .bearer_auth(token) + .send() + .await? + .status(); + assert_eq!( + status.is_success(), + allowed, + "{route} with a {} token: {status}", + if token == job_token { "job" } else { token } + ); + } + } + + let mut mac = hmac::Hmac::::new_from_slice(b"test-key-2")?; + mac.update(uuid::Uuid::parse_str(WAC_JOB)?.as_bytes()); + mac.update(&5u32.to_be_bytes()); + let foreign = hex::encode(mac.finalize().into_bytes()); + for op in ["resume", "cancel"] { + let status = client + .post(format!( + "{base}/w/test-workspace-2/jobs_u/{op}/{WAC_JOB}/5/{foreign}" + )) + .json(&serde_json::json!({})) + .send() + .await? + .status(); + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "{op} from test-workspace-2" + ); + } + let (suspend, resumes): (i32, i64) = sqlx::query_as( + "SELECT suspend, (SELECT count(*) FROM resume_job WHERE job = $1::uuid) + FROM v2_job_queue WHERE id = $1::uuid", + ) + .bind(WAC_JOB) + .fetch_one(&db) + .await?; + assert_eq!( + (suspend, resumes), + (1, 0), + "the job must still be suspended" + ); Ok(()) } diff --git a/backend/tests/worker_ping_ip.rs b/backend/tests/worker_ping_ip.rs index 8f98398b40..772f4193c3 100644 --- a/backend/tests/worker_ping_ip.rs +++ b/backend/tests/worker_ping_ip.rs @@ -1,5 +1,9 @@ use sqlx::{Pool, Postgres}; -use windmill_common::{external_ip::UNKNOWN_IP, worker::insert_ping_query}; +use uuid::Uuid; +use windmill_common::{ + external_ip::UNKNOWN_IP, + worker::{insert_ping_query, update_worker_ping_main_loop_query}, +}; async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> anyhow::Result<()> { insert_ping_query( @@ -42,3 +46,47 @@ async fn unresolved_ip_keeps_the_reclaimed_rows_address(db: Pool) -> a assert_eq!(ip, UNKNOWN_IP); Ok(()) } + +async fn main_loop_ping( + db: &Pool, + worker: &str, + last_job: Option<(Uuid, &str)>, +) -> anyhow::Result<()> { + update_worker_ping_main_loop_query( + worker, + &[], + None, + None, + Some(1), + None, + None, + None, + None, + None, + None, + false, + None, + last_job, + db, + ) + .await +} + +/// The main-loop ping sends a job only once, so most pings carry none. Those must keep the last +/// job the row holds, whether this ping or the job poller wrote it. +#[sqlx::test] +async fn main_loop_ping_without_a_job_keeps_the_last_one(db: Pool) -> anyhow::Result<()> { + insert_ping(&db, "wk-last-job", None).await?; + let job_id = Uuid::new_v4(); + main_loop_ping(&db, "wk-last-job", Some((job_id, "admins"))).await?; + main_loop_ping(&db, "wk-last-job", None).await?; + let (current_job_id, workspace_id): (Option, Option) = sqlx::query_as( + "SELECT current_job_id, current_job_workspace_id FROM worker_ping WHERE worker = $1", + ) + .bind("wk-last-job") + .fetch_one(&db) + .await?; + assert_eq!(current_job_id, Some(job_id)); + assert_eq!(workspace_id.as_deref(), Some("admins")); + Ok(()) +} diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index 0ec295c23f..7320721ea5 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -14,7 +14,7 @@ pub use windmill_common::flow_conversations::FlowConversation; use windmill_common::{ db::{UserDB, DB}, error::{JsonResult, Result}, - flow_conversations::MessageType, + flow_conversations::{running_turns, MessageType, RunningTurn}, utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination}, }; @@ -81,7 +81,7 @@ async fn list_conversations( Path(w_id): Path, Query(pagination): Query, Query(query): Query, -) -> JsonResult> { +) -> JsonResult> { let (per_page, offset) = paginate(pagination); let mut tx = user_db.clone().begin(&authed).await?; @@ -123,9 +123,27 @@ async fn list_conversations( let conversations = sqlx::query_as::(&sql) .fetch_all(&mut *tx) .await?; + let ids: Vec = conversations.iter().map(|c| c.id).collect(); + let mut running = running_turns(&mut *tx, &ids).await?; tx.commit().await?; - Ok(Json(conversations)) + Ok(Json( + conversations + .into_iter() + .map(|conversation| ListedConversation { + running_turn: running.remove(&conversation.id), + conversation, + }) + .collect(), + )) +} + +#[derive(Serialize)] +pub struct ListedConversation { + #[serde(flatten)] + pub conversation: FlowConversation, + /// Lets a chat that opens after a turn started follow it, and tell which chats are busy. + pub running_turn: Option, } async fn delete_conversation( diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 4948a0098a..ddb94f8c2b 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -391,7 +391,11 @@ async fn concurrent_role_creations_both_survive(db: Pool) -> anyhow::R assert_eq!(a.0, 200, "{}", a.1); assert_eq!(b.0, 200, "{}", b.1); - let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?; + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; let recorded: Vec<&str> = catalog.values().map(|r| r.name.as_str()).collect(); for name in &names { assert!( @@ -460,7 +464,11 @@ async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled( let body = resp.text().await?; assert_eq!(status, 400, "{body}"); - let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?; + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; let role = catalog .get(&id) .expect("a failed delete keeps the entry to retry"); diff --git a/backend/windmill-api-integration-tests/tests/fork_background.rs b/backend/windmill-api-integration-tests/tests/fork_background.rs new file mode 100644 index 0000000000..24ef3468ae --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fork_background.rs @@ -0,0 +1,136 @@ +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +async fn wait_for_fork(client: &reqwest::Client, base_url: &str, creation_id: &str) -> Value { + for _ in 0..100 { + let status: Value = client + .get(format!("{base_url}/fork_creation_status/{creation_id}")) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + if status["status"] != "running" { + return status; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("fork creation {creation_id} still running"); +} + +/// A fork created in the background is reported by `fork_creation_status`, including a failure +/// that happens after the request has already returned. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_created_in_background(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let client = reqwest::Client::new(); + let create = |id: &str| { + client + .post(format!("{base_url}/create_fork?background=true")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "id": id, "name": id })) + .send() + }; + + let resp = create("wm-fork-bg-ok").await?; + assert_eq!(resp.status(), 200); + let ok_creation = resp.text().await?; + let status = wait_for_fork(&client, &base_url, &ok_creation).await; + assert_eq!(status, json!({ "status": "completed" })); + // Nobody but the user who started a creation reads it. + let resp = client + .get(format!("{base_url}/fork_creation_status/{ok_creation}")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .send() + .await?; + assert_eq!(resp.status(), 404); + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'wm-fork-bg-ok')") + .fetch_one(&db) + .await?; + assert!(exists); + // Under CE's workspace cap. + sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'wm-fork-bg-ok'") + .execute(&db) + .await?; + + // Hold the fork's write lock so the request is validated and returns, then commit a workspace + // with the same id: the background write fails on it. + let mut blocker = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:wm-fork-bg-err'))") + .execute(&mut *blocker) + .await?; + let resp = create("wm-fork-bg-err").await?; + assert_eq!(resp.status(), 200); + let err_creation = resp.text().await?; + let resp = create("wm-fork-bg-err").await?; + assert_eq!(resp.status(), 400, "a second creation while the first is in flight"); + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('wm-fork-bg-err', 'x', 'x')") + .execute(&mut *blocker) + .await?; + blocker.commit().await?; + let status = wait_for_fork(&client, &base_url, &err_creation).await; + assert_eq!(status["status"], "failed"); + assert!( + status["error"].as_str().unwrap().contains("workspace_pkey"), + "{status}" + ); + + // A retry of the same id is a new attempt: the failed one does not read its outcome. + sqlx::query("DELETE FROM workspace WHERE id = 'wm-fork-bg-err'") + .execute(&db) + .await?; + let resp = create("wm-fork-bg-err").await?; + assert_eq!(resp.status(), 200); + let retry_creation = resp.text().await?; + assert_eq!( + wait_for_fork(&client, &base_url, &retry_creation).await, + json!({ "status": "completed" }) + ); + let resp = client + .get(format!("{base_url}/fork_creation_status/{err_creation}")) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await?; + assert_eq!(resp.status(), 404); + + // An attempt whose server stopped heartbeating before its fork committed reads as failed. + let abandoned = "5c3e1b1e-0000-4000-8000-000000000000"; + sqlx::query( + "INSERT INTO workspace_fork_creation + (fork_workspace_id, parent_workspace_id, created_by, creation_id, heartbeat_at) + VALUES ('wm-fork-gone', 'test-workspace', 'test@windmill.dev', $1::uuid, + now() - interval '2 minutes')", + ) + .bind(abandoned) + .execute(&db) + .await?; + assert_eq!( + wait_for_fork(&client, &base_url, abandoned).await["status"], + "failed" + ); + + // Without the flag the fork is created before the answer, which clients asking for a + // background fork recognise a server without it by. + sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'wm-fork-bg-err'") + .execute(&db) + .await?; + let resp = client + .post(format!("{base_url}/create_fork")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "id": "wm-fork-sync", "name": "wm-fork-sync" })) + .send() + .await?; + assert_eq!(resp.text().await?, "Created forked workspace wm-fork-sync"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 53648ec759..9c167ef70c 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -566,6 +566,113 @@ async fn test_resource_value_cache_is_identity_scoped(db: Pool) -> any Ok(()) } +/// A token scoped to one resource must not read, through the `$var:`/`$res:` references it +/// writes into that resource, what its scopes would refuse to read directly. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_interpolated_references_need_the_token_scopes( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + let resp = authed(client().post(format!("{base}/variables/create"))) + .json(&json!({"path": "u/test-user/secret", "value": "CANARY", "is_secret": true, "description": ""})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let resp = authed(client().post(format!("{base}/resources/create"))) + .json(&json!({"path": "u/test-user/other", "value": {"pw": "OTHER"}, "resource_type": "object", "description": ""})) + .send() + .await?; + assert_eq!(resp.status(), 201); + + let mint = |scopes: serde_json::Value| async move { + let resp = + authed(client().post(format!("http://localhost:{port}/api/users/tokens/create"))) + .json( + &json!({"label": "scoped", "scopes": scopes, "workspace_id": "test-workspace"}), + ) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + resp.text().await.unwrap() + }; + let probe_only = mint(json!(["resources:write:u/test-user/probe"])).await; + let with_var = mint(json!([ + "resources:read:u/test-user/probe", + "variables:read:u/test-user/secret" + ])) + .await; + + let read_probe = |value: serde_json::Value, token: String| { + let base = base.clone(); + let probe_only = probe_only.clone(); + async move { + let resp = client() + .post(format!("{base}/resources/create?update_if_exists=true")) + .bearer_auth(&probe_only) + .json(&json!({"path": "u/test-user/probe", "value": value, "resource_type": "object", "description": ""})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let resp = client() + .get(format!( + "{base}/resources/get_value_interpolated/u/test-user/probe" + )) + .bearer_auth(&token) + .send() + .await + .unwrap(); + (resp.status().as_u16(), resp.text().await.unwrap()) + } + }; + + for value in [ + json!({"v": "$var:u/test-user/secret"}), + json!({"v": "$jsonvar:u/test-user/secret"}), + json!({"v": ["$res:u/test-user/other"]}), + ] { + let (status, body) = read_probe(value.clone(), probe_only.clone()).await; + assert_eq!(status, 403, "{value}: {body}"); + assert!( + !body.contains("CANARY") && !body.contains("OTHER"), + "{body}" + ); + } + + let (status, body) = + read_probe(json!({"v": "$var:u/test-user/secret"}), with_var.clone()).await; + assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#)); + + // The resource's own linked secrets (at its path, or `_`) are part of it. + for (path, value) in [ + ("u/test-user/probe", "LINKED"), + ("u/test-user/probe_key", "KEY"), + ] { + let resp = authed(client().post(format!("{base}/variables/create"))) + .json(&json!({"path": path, "value": value, "is_secret": true, "description": ""})) + .send() + .await?; + assert_eq!(resp.status(), 201); + } + let (status, body) = read_probe( + json!({"pw": "$var:u/test-user/probe", "key": "$var:u/test-user/probe_key"}), + probe_only.clone(), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + serde_json::from_str::(&body)?, + json!({"pw": "LINKED", "key": "KEY"}) + ); + + Ok(()) +} + /// A resource whose value contains a `$WM_*` contextual variable (e.g. `$WM_TOKEN`) is /// job-dependent and must NEVER be cached — even when first read WITHOUT a `job_id`, where the /// placeholder is left unresolved (caching that would serve a stale placeholder to a later job diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies.rs new file mode 100644 index 0000000000..0132a98a61 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies.rs @@ -0,0 +1,57 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +#[allow(unused_imports)] +use windmill_test_utils::*; + +/// An admin of one workspace must not be able to write dependency files into another +/// by naming it in the request body. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_create_rejects_body_workspace_other_than_path( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + // test-user-2 is a plain user of test-workspace and an admin of its own workspace. + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('own-workspace', 'own-workspace', 'test-user-2')") + .execute(&db) + .await?; + sqlx::query("INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES ('own-workspace', 'test2@windmill.dev', 'test-user-2', true, 'Admin')") + .execute(&db) + .await?; + + let (_client, port, _server) = init_client(db.clone()).await; + + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/own-workspace/workspace_dependencies/create" + )) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ + "workspace_id": "test-workspace", + "language": "python3", + "content": "requests==2.28.0" + })) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "expected the mismatched body to be rejected, got {body}" + ); + assert!( + body.contains("does not match"), + "expected the workspace mismatch rejection, got {body}" + ); + + let written: i64 = sqlx::query_scalar("SELECT count(*) FROM workspace_dependencies") + .fetch_one(&db) + .await?; + assert_eq!( + written, 0, + "no dependency file may be written in either workspace" + ); + + Ok(()) +} diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 9ad7189434..1a750c38ba 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -95,6 +95,83 @@ pub async fn check_tag_as_written_available_for_workspace( } } +/// Clears a `parent_job` / `root_job` the caller cannot claim. They identify the pushed job +/// (`WM_FLOW_JOB_ID`, `WM_ROOT_FLOW_JOB_ID`, the OIDC token's flow path), so only a job's own +/// `WM_TOKEN` may name that job or its ancestors, and a workspace admin any job of the workspace. +/// Cleared rather than refused: the SDKs send them from inside a job whatever token they hold. +pub async fn drop_unclaimable_run_lineage( + db: &DB, + w_id: &str, + run_query: &mut RunJobQuery, + authed: &ApiAuthed, +) -> error::Result<()> { + let referenced: Vec = [run_query.parent_job, run_query.root_job] + .into_iter() + .flatten() + .collect(); + let unclaimable = unclaimable_run_lineage(db, w_id, referenced, authed).await?; + for field in [&mut run_query.parent_job, &mut run_query.root_job] { + if field.is_some_and(|id| unclaimable.contains(&id)) { + tracing::warn!( + "ignoring parent_job/root_job {field:?} that {} cannot claim in {w_id}", + authed.username + ); + *field = None; + } + } + Ok(()) +} + +/// The jobs of `referenced` that `authed` cannot claim as its own run lineage: anything but the +/// token's own job and that job's `parent_job`, `root_job` and `flow_innermost_root_job`, or for +/// a workspace admin anything outside the workspace. +pub async fn unclaimable_run_lineage( + db: &DB, + w_id: &str, + mut referenced: Vec, + authed: &ApiAuthed, +) -> error::Result> { + referenced.sort(); + referenced.dedup(); + if let Some(token_job) = authed.job_id { + referenced.retain(|id| *id != token_job); + if !referenced.is_empty() { + if let Some(lineage) = sqlx::query!( + "SELECT parent_job, root_job, flow_innermost_root_job FROM v2_job + WHERE id = $1 AND workspace_id = $2", + token_job, + w_id + ) + .fetch_optional(db) + .await? + { + let ancestors = [ + lineage.parent_job, + lineage.root_job, + lineage.flow_innermost_root_job, + ]; + referenced.retain(|id| !ancestors.contains(&Some(*id))); + } + } + } + if referenced.is_empty() { + return Ok(referenced); + } + let in_workspace = if authed.is_admin { + sqlx::query_scalar!( + "SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2", + &referenced, + w_id, + ) + .fetch_all(db) + .await? + } else { + vec![] + }; + referenced.retain(|id| !in_workspace.contains(id)); + Ok(referenced) +} + #[cfg(feature = "enterprise")] pub async fn check_license_key_valid() -> error::Result<()> { use windmill_common::ee_oss::LICENSE_KEY_VALID; @@ -776,6 +853,8 @@ pub async fn run_flow<'c>( bool, Option>, )> { + let mut run_query = run_query; + drop_unclaimable_run_lineage(db, w_id, &mut run_query, authed).await?; let on_behalf_of = flow_version_info.on_behalf_of(w_id, &db).await?; let FlowVersionInfo { version, @@ -1017,6 +1096,8 @@ pub async fn push_script_job_by_path_into_queue<'c>( let script_path = script_path.to_path(); check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() }; let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) = diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 78cd6eb378..7a523c89fa 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -409,7 +409,8 @@ async fn create_schedule( paused_until, cron_version, dynamic_skip, - labels + labels, + late_run_streak "#, w_id, ns.path, @@ -621,7 +622,10 @@ async fn edit_schedule( edited_by = $25, edited_at = now(), permissioned_as = $26, - labels = COALESCE($27, labels) + labels = COALESCE($27, labels), + late_run_streak = 0, + missed_occurrences = 0, + last_missed_at = NULL WHERE path = $19 AND workspace_id = $20 RETURNING workspace_id, @@ -656,7 +660,8 @@ async fn edit_schedule( paused_until, cron_version, dynamic_skip, - labels + labels, + late_run_streak "#, es.schedule, es.timezone, @@ -1004,6 +1009,9 @@ async fn list_schedule( pub struct ScheduleWJobs { pub path: String, pub jobs: Option>, + pub late_run_streak: i32, + pub missed_occurrences: i32, + pub last_missed_at: Option>, } async fn list_schedule_with_jobs( @@ -1020,7 +1028,7 @@ async fn list_schedule_with_jobs( // - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause. // - both `workspace_id = $1` checks are required to hit both indexes. "SELECT - schedule.path, t.jobs FROM schedule, + schedule.path, t.jobs, schedule.late_run_streak, schedule.missed_occurrences, schedule.last_missed_at FROM schedule, LATERAL(SELECT ARRAY( SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms) FROM v2_job_completed c JOIN v2_job j USING (id) @@ -1157,7 +1165,10 @@ pub async fn set_enabled( r#" UPDATE schedule SET enabled = $1, - email = $2 + email = $2, + late_run_streak = 0, + missed_occurrences = 0, + last_missed_at = NULL WHERE path = $3 AND workspace_id = $4 RETURNING workspace_id, @@ -1192,7 +1203,8 @@ pub async fn set_enabled( paused_until, cron_version, dynamic_skip, - labels + labels, + late_run_streak "#, payload.enabled, authed.email, diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index bc52d85d54..71cbcc8460 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -42,7 +42,6 @@ use axum::{ routing::{get, post}, Json, Router, }; -use serde_json::json; use serde::{Deserialize, Serialize}; use windmill_ai::ai_cache::bump_instance_ai_config_revision; @@ -61,6 +60,7 @@ use windmill_common::{ ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, + EXTERNAL_INSTANCE_PG_SETTING, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, @@ -181,6 +181,22 @@ pub fn global_service() -> Router { "/refresh_custom_instance_user_pwd", post(refresh_custom_instance_user_pwd), ) + .route( + "/external_instance_pg/status", + get(get_external_instance_pg_status), + ) + .route( + "/external_instance_pg/setup", + post(setup_external_instance_pg), + ) + .route( + "/external_instance_pg/databases", + get(list_external_instance_pg_databases), + ) + .route( + "/external_instance_pg/databases/{name}", + post(create_external_instance_pg_database).delete(drop_external_instance_pg_database), + ) .route( "/setup_custom_instance_pg_database/{name}", post(setup_custom_instance_pg_database), @@ -303,7 +319,9 @@ pub async fn test_email( use windmill_object_store::ObjectSettings; #[cfg(feature = "parquet")] -use windmill_object_store::build_object_store_from_settings; +use windmill_object_store::{ + build_object_store_from_settings, build_public_object_store_from_settings, +}; #[cfg(feature = "parquet")] pub async fn test_s3_bucket( @@ -338,9 +356,15 @@ pub async fn test_s3_bucket( })?; } - let client = build_object_store_from_settings(test_s3_bucket, Some(&db)) - .await? - .store; + // The restricted client re-judges every address it connects to: the checks above resolve the + // endpoint separately from the connect, which a rebinding name answers differently. + let client = if restrict { + build_public_object_store_from_settings(test_s3_bucket).await? + } else { + build_object_store_from_settings(test_s3_bucket, Some(&db)) + .await? + .store + }; let run = async { let mut list = client.list(Some( @@ -546,7 +570,7 @@ async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> { // Reject if any resolved address is non-public, which also defeats the simplest DNS-rebinding // attempts (a name resolving to both a public and a private address). for addr in addrs { - if is_forbidden_ip(addr.ip()) { + if windmill_common::ssrf::is_private_ip(&addr.ip()) { // The resolved address stays out of the message: it is the server's resolver's // answer, and this message is only ever shown to the caller being constrained. return Err(error::Error::NotAuthorized(format!( @@ -599,51 +623,6 @@ fn extract_host(endpoint: &str) -> Option { } } -#[cfg(feature = "parquet")] -fn is_forbidden_ip(ip: std::net::IpAddr) -> bool { - use std::net::{IpAddr, Ipv4Addr}; - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() // 169.254.0.0/16, incl. the cloud metadata endpoint - || v4.is_unspecified() - || v4.is_broadcast() - || v4.is_documentation() - || v4.is_multicast() - || v4.octets()[0] == 0 // 0.0.0.0/8 - || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT - } - IpAddr::V6(v6) => { - // Any IPv4 embedded in an IPv6 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible - // ::/96, or NAT64 64:ff9b::/96) is re-checked against the IPv4 rules, so e.g. - // 64:ff9b::169.254.169.254 cannot route to the metadata endpoint in a NAT64 network. - let seg = v6.segments(); - let is_v4_compatible = seg[0..6] == [0, 0, 0, 0, 0, 0]; - let is_nat64 = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0]; - if let Some(v4) = v6.to_ipv4_mapped() { - return is_forbidden_ip(IpAddr::V4(v4)); - } - if is_v4_compatible || is_nat64 { - let embedded = Ipv4Addr::new( - (seg[6] >> 8) as u8, - (seg[6] & 0xff) as u8, - (seg[7] >> 8) as u8, - (seg[7] & 0xff) as u8, - ); - if is_forbidden_ip(IpAddr::V4(embedded)) { - return true; - } - } - v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - || (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local - || (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local - } - } -} - #[cfg(feature = "parquet")] async fn get_object_storage_usage( Extension(db): Extension, @@ -890,6 +869,14 @@ pub async fn set_global_setting_internal( ))); } + if key == EXTERNAL_INSTANCE_PG_SETTING { + return windmill_common::external_instance_pg::write_external_instance_pg_setting( + db, + Some(&value), + ) + .await; + } + run_setting_pre_write_hook(db, &key, &value).await?; match value { @@ -1285,7 +1272,7 @@ async fn set_instance_config( let desired_map = desired.global_settings.to_settings_map(); if !desired_map.is_empty() { let current_map = current.global_settings.to_settings_map(); - let settings_diff = + let mut settings_diff = instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge); let ai_config_changed = settings_diff .upserts @@ -1314,8 +1301,15 @@ async fn set_instance_config( } for (key, value) in &settings_diff.upserts { - run_setting_pre_write_hook(&db, key, value).await?; + if key != EXTERNAL_INSTANCE_PG_SETTING { + run_setting_pre_write_hook(&db, key, value).await?; + } } + windmill_common::external_instance_pg::write_external_instance_pg_from_diff( + &db, + &mut settings_diff, + ) + .await?; instance_config::apply_settings_diff(&db, &settings_diff) .await @@ -1680,6 +1674,8 @@ struct CustomInstanceDb { tag: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] used_by_workspaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, } #[derive(Deserialize, Debug, Serialize, Default)] @@ -1722,7 +1718,40 @@ async fn list_custom_instance_pg_databases( )) })?; - if windmill_api_auth::is_super_admin_authed(&db, &authed).await? { + if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { + // A fork copy's name gives away the workspace it was reserved for, so every pending fork on + // the instance would be listed. Kept for members of that workspace, and wherever the + // caller's workspaces use it, e.g. the fork it was finalized into. + let reserved_visible: BTreeSet = sqlx::query_scalar( + r#"SELECT e.k FROM global_settings gs + CROSS JOIN LATERAL jsonb_each(gs.value->'databases') AS e(k, v) + WHERE gs.name = 'custom_instance_pg_databases' AND e.v->>'workspace_id' IS NOT NULL + AND (EXISTS (SELECT 1 FROM usr WHERE usr.email = $1 + AND usr.workspace_id = e.v->>'workspace_id') + OR EXISTS (SELECT 1 FROM usr JOIN workspace_settings ws + ON ws.workspace_id = usr.workspace_id + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt + WHERE usr.email = $1 + AND dt.value->'database'->>'resource_type' = 'instance' + AND dt.value->'database'->>'resource_path' = e.k))"#, + ) + .bind(&authed.email) + .fetch_all(&db) + .await? + .into_iter() + .collect(); + result.retain(|dbname, entry| { + entry.workspace_id.is_none() || reserved_visible.contains(dbname) + }); + // Which workspace reserved a copy is still only for superadmins. + for entry in result.values_mut() { + entry.workspace_id = None; + } + return Ok(Json(result)); + } + { // Enrich each database with the list of workspaces referencing it through // either a ducklake catalog or a datatable database whose resource_type is // 'instance'. Not stored in DB to avoid drift. @@ -1778,6 +1807,139 @@ async fn refresh_custom_instance_user_pwd( Ok(Json(())) } +async fn get_external_instance_pg_status( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult { + require_super_admin(&db, &authed).await?; + Ok(Json( + windmill_common::external_instance_pg::external_instance_pg_status(&db).await?, + )) +} + +#[derive(Deserialize)] +struct SetupExternalInstancePgBody { + #[serde(default)] + rotate_passwords: bool, +} + +async fn setup_external_instance_pg( + authed: ApiAuthed, + Extension(db): Extension, + Json(body): Json, +) -> JsonResult { + require_super_admin(&db, &authed).await?; + let report = windmill_common::external_instance_pg::setup_external_instance_pg_unchecked( + &db, + body.rotate_passwords, + ) + .await?; + let rotated = body.rotate_passwords.to_string(); + let success = report.success.to_string(); + windmill_audit::audit_oss::audit_log( + &db, + &authed, + "settings.setup_external_instance_pg", + windmill_audit::ActionKind::Update, + "global", + Some(&authed.email), + Some( + [ + ("rotate_passwords", rotated.as_str()), + ("success", success.as_str()), + ] + .into(), + ), + ) + .await?; + Ok(Json(report)) +} + +#[derive(Serialize)] +struct ExternalInstancePgDatabase { + #[serde(flatten)] + status: windmill_common::instance_config::CustomInstanceDb, + used_by_workspaces: Vec, +} + +async fn list_external_instance_pg_databases( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult> { + require_super_admin(&db, &authed).await?; + let databases = windmill_common::external_instance_pg::external_instance_databases(&db).await?; + let mut usages = + windmill_common::external_instance_pg::external_instance_database_usages(&db).await?; + Ok(Json( + databases + .into_iter() + .map(|(name, status)| { + let used_by_workspaces = usages.remove(&name).unwrap_or_default(); + ( + name, + ExternalInstancePgDatabase { + status, + used_by_workspaces: used_by_workspaces.into_iter().collect(), + }, + ) + }) + .collect(), + )) +} + +async fn create_external_instance_pg_database( + authed: ApiAuthed, + Extension(db): Extension, + Path(dbname): Path, + Json(body): Json, +) -> JsonResult<()> { + require_super_admin(&db, &authed).await?; + let tag = body.tag.as_deref().unwrap_or("datatable"); + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::create_external_instance_database_unchecked( + &db, &mut tx, &dbname, tag, None, + ) + .await?; + tx.commit().await?; + windmill_audit::audit_oss::audit_log( + &db, + &authed, + "settings.create_external_instance_pg_database", + windmill_audit::ActionKind::Create, + "global", + Some(&authed.email), + Some([("dbname", dbname.as_str()), ("tag", tag)].into()), + ) + .await?; + Ok(Json(())) +} + +async fn drop_external_instance_pg_database( + authed: ApiAuthed, + Extension(db): Extension, + Path(dbname): Path, +) -> JsonResult<()> { + require_super_admin(&db, &authed).await?; + // A data table naming a dropped database fails on every job, far from the drop that caused it. + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::drop_external_instance_database_unchecked( + &mut tx, &dbname, None, + ) + .await?; + tx.commit().await?; + windmill_audit::audit_oss::audit_log( + &db, + &authed, + "settings.drop_external_instance_pg_database", + windmill_audit::ActionKind::Delete, + "global", + Some(&authed.email), + Some([("dbname", dbname.as_str())].into()), + ) + .await?; + Ok(Json(())) +} + #[derive(Deserialize)] struct SetupCustomInstanceDbBody { tag: Option, @@ -1789,18 +1951,46 @@ async fn setup_custom_instance_pg_database( Path(dbname): Path, Json(body): Json, ) -> JsonResult { + // Before anything is recorded: the status written below replaces the registry entry, and with it + // the workspace a fork copy is reserved for. + require_super_admin(&db, &authed).await?; + windmill_common::workspaces::ensure_instance_pg_available(&db).await?; + // Fork cleanup checks and drops the database and its entry under this lock. Held from before + // the setup creates the database to after its entry is written, neither lands on the other's + // half-done state: a dropped database with its entry written back, or the reverse. + let mut tx = db.begin().await?; + windmill_common::datatable_roles::lock_instance_databases_governance(&mut tx, [dbname.trim()]) + .await?; let mut logs = CustomInstanceDbLogs::default(); let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await; let success = result.is_ok(); let error = result.err().map(|e| e.to_string()); - let status = - CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] }; + let status = CustomInstanceDb { + logs, + success, + error, + tag: body.tag, + used_by_workspaces: vec![], + workspace_id: None, + }; let status_json = serde_json::to_value(&status).map_err(to_anyhow)?; - // Save that the database was setup successfully - sqlx::query!( - r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#, - json!({ dbname: status_json }) - ).execute(&db).await?; + // The fork reservation is carried over inside the write, from whatever the row holds then: a + // rename migrating it while the setup above ran would otherwise be overwritten with the value + // this request started from, stranding the copy under the archived workspace. + let saved = sqlx::query_scalar::<_, serde_json::Value>( + r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', + COALESCE(value->'databases', '{}'::jsonb) + || jsonb_build_object($1::text, $2::jsonb || jsonb_build_object( + 'workspace_id', value->'databases'->$1::text->'workspace_id'))) + WHERE name = 'custom_instance_pg_databases' + RETURNING value->'databases'->$1::text"#, + ) + .bind(&dbname) + .bind(&status_json) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + let status: CustomInstanceDb = serde_json::from_value(saved).map_err(to_anyhow)?; Ok(Json(status)) } @@ -2582,8 +2772,7 @@ mod tests { #[cfg(all(test, feature = "parquet"))] mod object_storage_test_hardening { - use super::{extract_host, is_forbidden_ip, validate_object_storage_test}; - use std::net::IpAddr; + use super::{extract_host, validate_object_storage_test}; use windmill_object_store::ObjectSettings; // IP literals (not hostnames) keep validate_public_endpoint deterministic — `lookup_host` @@ -2644,40 +2833,6 @@ mod object_storage_test_hardening { } } - fn ip(s: &str) -> IpAddr { - s.parse().unwrap() - } - - #[test] - fn forbids_internal_ips() { - for s in [ - "127.0.0.1", // loopback - "169.254.169.254", // cloud metadata (link-local) - "10.0.0.5", // private - "172.16.3.4", // private - "192.168.1.10", // private - "0.0.0.0", // unspecified - "100.64.0.1", // CGNAT - "::1", // IPv6 loopback - "fe80::1", // IPv6 link-local - "fc00::1", // IPv6 unique local - "::ffff:127.0.0.1", // IPv4-mapped loopback - "::ffff:169.254.169.254", // IPv4-mapped metadata - "::169.254.169.254", // IPv4-compatible metadata - "64:ff9b::169.254.169.254", // NAT64-embedded metadata - "64:ff9b::a9fe:a9fe", // NAT64-embedded metadata (hex form) - ] { - assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden"); - } - } - - #[test] - fn allows_public_ips() { - for s in ["8.8.8.8", "1.1.1.1", "52.95.110.1", "2606:4700:4700::1111"] { - assert!(!is_forbidden_ip(ip(s)), "{s} should be allowed"); - } - } - #[test] fn extracts_host_from_endpoint() { let cases = [ diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index 7ca1f692ad..9f05a26f28 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -50,6 +50,7 @@ sha2.workspace = true sqlx.workspace = true tokio.workspace = true tokio-postgres.workspace = true +tokio-util.workspace = true tracing.workspace = true url.workspace = true uuid.workspace = true diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs index 04dc3d4e80..ebab6214ce 100644 --- a/backend/windmill-api-workspaces/src/datatable_acl.rs +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -6,7 +6,7 @@ * LICENSE-AGPL for a copy of the license. */ -//! Ownership and grants on the objects of an instance data table. +//! Ownership and grants on the objects of a data table on a cluster Windmill manages. //! //! [`datatable_permissions`](crate::datatable_permissions) decides who may connect as which role; //! this decides what each role may then touch. Every change is a real `GRANT`, `REVOKE`, @@ -33,7 +33,7 @@ use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::datatable_roles::{ lock_role_catalog, quote_ident, read_role_catalog, read_role_catalog_tx, DatatableRoleCatalog, - ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER, + DatatableRoleCluster, ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER, }; use windmill_common::error::{pg_error_message, Error, JsonResult, Result}; use windmill_common::workspaces::{resolve_governing_datatable, DataTable, GoverningDatatable}; @@ -297,22 +297,22 @@ fn role_names(catalog: &DatatableRoleCatalog) -> Vec { names } -fn ensure_instance(governing: &GoverningDatatable) -> Result<()> { - if governing.is_instance() { - return Ok(()); - } - Err(Error::BadRequest(format!( - "Data table '{}' is backed by a Postgres resource, so its access is managed on that \ - server directly. Only a data table on the Windmill instance's own database has data \ - table roles to grant to.", - governing.name - ))) +/// The cluster whose roles the data table's grants name. +fn ensure_managed(governing: &GoverningDatatable) -> Result { + governing.role_cluster().ok_or_else(|| { + Error::BadRequest(format!( + "Data table '{}' is backed by a Postgres resource, so its access is managed on that \ + server directly. Only a data table on a database Windmill manages has data table \ + roles to grant to.", + governing.name + )) + }) } /// The data table's `admin` connection, and the notices Postgres sends on it. /// -/// Authorization: connects as `custom_instance_user` with the instance's own credentials and checks -/// nothing. Callers MUST have authorized the request first — a request about to be refused must +/// Authorization: connects as `custom_instance_user` with the cluster's stored credentials and +/// checks nothing. Callers MUST have authorized the request first — a request about to be refused must /// not get as far as this connection. async fn connect_as_admin_unchecked( db: &DB, @@ -322,21 +322,33 @@ async fn connect_as_admin_unchecked( mpsc::UnboundedReceiver, String, )> { - ensure_instance(governing)?; + let cluster = ensure_managed(governing)?; // Built from the authorized entry, never by resolving the settings again: a save in between // could point the entry at a resource on another server and back, and this connection would // then alter a database the later checks of the entry never see. - let mut pg = PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?; - pg.dbname = governing + let dbname = governing .datatable .database .as_ref() .expect("a governing entry owns a database") .resource_path .clone(); - pg.user = Some(CUSTOM_INSTANCE_USER.to_string()); - pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?); - let dbname = pg.dbname.clone(); + let pg = match cluster { + DatatableRoleCluster::Instance => { + let mut pg = + PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?; + pg.dbname = dbname.clone(); + pg.user = Some(CUSTOM_INSTANCE_USER.to_string()); + pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?); + pg + } + DatatableRoleCluster::ExternalInstance => { + windmill_common::external_instance_pg::external_instance_connection_unchecked( + db, &dbname, false, + ) + .await? + } + }; let (client, notices) = connect_with_notices(db, &pg).await?; Ok((client, notices, dbname)) } @@ -1085,12 +1097,12 @@ async fn get_datatable_acl( let target: AclTarget = query.try_into()?; let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; ensure_reaches_governing_datatable(&db, &w_id, &datatable_name, &governing, &authed).await?; - ensure_instance(&governing)?; + let cluster = ensure_managed(&governing)?; let editable = ensure_governs_datatable(&db, &authed, &w_id, &governing) .await .is_ok(); let roles = if editable { - role_names(&read_role_catalog(&db).await?) + role_names(&read_role_catalog(&db, cluster).await?) } else { vec![] }; @@ -1376,7 +1388,7 @@ async fn authorize_acl_change( ) -> Result { let governing = resolve_governing_datatable(db, w_id, datatable_name).await?; ensure_governs_datatable(db, authed, w_id, &governing).await?; - ensure_instance(&governing)?; + ensure_managed(&governing)?; Ok(governing) } @@ -1386,8 +1398,14 @@ static APPLY_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1) /// provisioned before data table roles gave `custom_instance_user` none. Adds that option to its /// database and `public` privileges, and nothing else: default privileges are left alone, since a /// schema's change of owner is planned against them. Best-effort, as a grant it fails to enable is -/// refused when it runs. -async fn ensure_grant_options(client: &tokio_postgres::Client, db: &DB, dbname: &str) { +/// refused when it runs. An external instance database was created with the options, so one +/// missing there is someone's deliberate revoke and is left alone. +async fn ensure_grant_options( + client: &tokio_postgres::Client, + db: &DB, + cluster: DatatableRoleCluster, + dbname: &str, +) { let held = client .query_one( "SELECT has_database_privilege(current_database(), 'CONNECT WITH GRANT OPTION') @@ -1399,7 +1417,7 @@ async fn ensure_grant_options(client: &tokio_postgres::Client, db: &DB, dbname: ) .await .is_ok_and(|row| row.get::<_, bool>(0)); - if held { + if held || cluster != DatatableRoleCluster::Instance { return; } if let Err(e) = grant_options_as_server(db, dbname).await { @@ -1625,7 +1643,8 @@ async fn plan_datatable_acl( ) -> JsonResult { crate::datatable_acl_oss::ensure_datatable_acl_available()?; let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; - let catalog = read_role_catalog(&db).await?; + let cluster = ensure_managed(&governing)?; + let catalog = read_role_catalog(&db, cluster).await?; let (client, _notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; Ok(Json( build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?, @@ -1649,6 +1668,7 @@ async fn apply_datatable_acl( // connection could wait forever on a pool that concurrent applies, queued on the same locks, // have exhausted. let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; + let cluster = ensure_managed(&governing)?; // Applies queue on an instance-wide lock while each holds a direct connection to the instance's // Postgres; unbounded, the queue alone could exhaust its connection limit. One at a time per // server, and the ones waiting hold no connection at all. @@ -1657,7 +1677,7 @@ async fn apply_datatable_acl( .await .map_err(|e| Error::internal_err(format!("ACL apply slot closed: {e}")))?; let (mut client, mut notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; - ensure_grant_options(&client, &db, &dbname).await; + ensure_grant_options(&client, &db, cluster, &dbname).await; // Held until the change is committed: a role renamed or dropped meanwhile would change what // the plan names, and a settings save could move the entry onto another database. Taken in the @@ -1672,7 +1692,7 @@ async fn apply_datatable_acl( .fetch_optional(&mut *tx) .await? .flatten(); - let catalog = read_role_catalog_tx(&mut tx).await?; + let catalog = read_role_catalog_tx(&mut tx, cluster).await?; let plan = build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?; if !entry_unchanged(&governing, entry_now) || &plan.statements != confirmed { diff --git a/backend/windmill-api-workspaces/src/datatable_clone.rs b/backend/windmill-api-workspaces/src/datatable_clone.rs index a57a2216e5..6083b7b619 100644 --- a/backend/windmill-api-workspaces/src/datatable_clone.rs +++ b/backend/windmill-api-workspaces/src/datatable_clone.rs @@ -11,8 +11,8 @@ //! The fork request makes its copies before it writes the fork: each is created, filled and — for a //! data table under roles — given the source's owners and grants. What each copy was made from //! stays in the request ([`MadeCopy`]), so the fork's transaction checks it against the source as it -//! is then, and a fork that fails drops the instance copies it made ([`drop_copies_after`]) and names -//! the others, which live on servers of the workspace's own. +//! is then, and a fork that fails drops the copies it made on a cluster Windmill manages +//! ([`drop_copies_after`]) and names the others, which live on servers of the workspace's own. use std::collections::BTreeSet; @@ -22,8 +22,8 @@ use windmill_common::error::{pg_error_message, Error, Result}; use windmill_common::utils::require_admin; use windmill_common::worker::CLOUD_HOSTED; use windmill_common::workspaces::{ - get_datatable_resource_from_db_unchecked, DataTableDatabase, DataTableForkBehavior, - GoverningDatatable, + get_datatable_resource_from_db_unchecked, DataTableCatalogResourceType, DataTableDatabase, + DataTableForkBehavior, GoverningDatatable, }; use windmill_common::{PgDatabase, DB}; @@ -312,27 +312,38 @@ pub(crate) async fn drop_copies_after(db: &DB, copies: Vec, error: Err } async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) -> Result<()> { - if source_database.resource_type - == windmill_common::workspaces::DataTableCatalogResourceType::Instance - { - match windmill_common::drop_unused_instance_database(db, dbname).await? { - windmill_common::Cleanup::Dropped => Ok(()), - windmill_common::Cleanup::InUse(users) => Err(Error::BadRequest(format!( - "kept, since workspaces {} now use it", - users.join(", ") - ))), - windmill_common::Cleanup::Waiter(pid) => Err(Error::BadRequest(format!( - "kept, since a request (pid {pid}) is still waiting to name it" - ))), + match source_database.resource_type { + DataTableCatalogResourceType::Instance => { + match windmill_common::drop_unused_instance_database(db, dbname).await? { + windmill_common::Cleanup::Dropped => Ok(()), + windmill_common::Cleanup::InUse(users) => Err(Error::BadRequest(format!( + "kept, since workspaces {} now use it", + users.join(", ") + ))), + windmill_common::Cleanup::Waiter(pid) => Err(Error::BadRequest(format!( + "kept, since a request (pid {pid}) is still waiting to name it" + ))), + } } - } else { + // Refused, and the copy kept, when anything names it by then. + DataTableCatalogResourceType::ExternalInstance => { + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::drop_external_instance_database_unchecked( + &mut tx, dbname, None, + ) + .await?; + tx.commit().await?; + Ok(()) + } + DataTableCatalogResourceType::Postgresql => { // On a server of the workspace's own, where a resource edited meanwhile can already name it // and nothing locks such an edit: dropping it could take someone's data. - Err(Error::BadRequest( - "kept on its PostgreSQL server, where it may already be in use; drop it there once it \ - is not, before forking the same data table under this id again" - .to_string(), - )) + Err(Error::BadRequest( + "kept on its PostgreSQL server, where it may already be in use; drop it there once \ + it is not, before forking the same data table under this id again" + .to_string(), + )) + } } } @@ -349,10 +360,10 @@ async fn make_copy( request.name )) })?; - let is_instance = governing.is_instance(); + let managed = source_database.resource_type.is_windmill_managed(); // Resolved from the snapshot, not read again: the connection the copy is made on is then // exactly what these rows describe, which the fork's own clone of them is checked against. - let (server, connection): (PgDatabase, Option) = if is_instance { + let (server, connection): (PgDatabase, Option) = if managed { let server = serde_json::from_value( get_datatable_resource_from_db_unchecked(db, parent_w_id, request.name).await?, ) @@ -383,22 +394,44 @@ async fn make_copy( // Dumped before the database exists, so a source that cannot be read leaves nothing behind. // Ownership never carries over: the restore runs as the target's connection user. Grants do, - // except on the instance, where the replay below is what reproduces them. + // except on a cluster Windmill manages, which plants its own and where the replay below is what + // reproduces the roles'. let dump = pg_dump_database( &server, PgDumpOptions { schema_only: request.behavior == DataTableForkBehavior::SchemaOnly, no_owner: true, - no_acl: is_instance, + no_acl: managed, ..Default::default() }, ) .await?; - if is_instance { - windmill_common::create_custom_instance_database(db, request.dbname, "datatable").await?; - } else { - create_database_on_server(db, &server, request.dbname).await?; + match source_database.resource_type { + DataTableCatalogResourceType::Instance => { + windmill_common::create_custom_instance_database( + db, + request.dbname, + "datatable", + Some(parent_w_id), + ) + .await? + } + DataTableCatalogResourceType::ExternalInstance => { + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::create_external_instance_database_unchecked( + db, + &mut tx, + request.dbname, + "datatable", + Some(parent_w_id), + ) + .await?; + tx.commit().await?; + } + DataTableCatalogResourceType::Postgresql => { + create_database_on_server(db, &server, request.dbname).await? + } } let target = PgDatabase { dbname: request.dbname.to_string(), ..server.clone() }; @@ -481,11 +514,16 @@ async fn fill( let replayed = now.datatable.permissions.is_some(); if replayed { crate::datatable_replay_oss::ensure_replay()?; - let catalog = read_role_catalog_tx(&mut tx).await?; + // The copy is on the source's cluster, so its roles come from that cluster's catalog. + let cluster = governing + .role_cluster() + .ok_or_else(|| Error::internal_err("a replayed clone is on a managed database"))?; + let catalog = read_role_catalog_tx(&mut tx, cluster).await?; // The replay leaves `CONNECT` to the catalog, and creating the database only tried to set // it: a copy `PUBLIC` could still connect to would admit logins the source turns away. windmill_common::datatable_roles::converge_connect_grants_with( db, + cluster, &target.dbname, &catalog, ) diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index df1fac203b..f762fda175 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -11,7 +11,7 @@ //! to keep that file focused on core workspace configuration. use crate::workspaces::{ - is_instance_datatable, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison, + managed_datatable_kind, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison, PgDumpOptions, }; @@ -1669,7 +1669,9 @@ async fn generate_initial_datatable_migration( // without what a replay elsewhere cannot run: the replaying user owns none of this // database's objects, and the grants Windmill plants in an instance database (`ALTER // DEFAULT PRIVILEGES FOR ROLE ...`) fail even replaying onto the same server. - let no_acl = is_instance_datatable(&db, &w_id, &datatable_name).await?; + let no_acl = managed_datatable_kind(&db, &w_id, &datatable_name) + .await? + .is_some(); let dump_file = pg_dump_database( &pg_db, PgDumpOptions { diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 185fe17b25..31d049d3c0 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -176,6 +176,10 @@ pub fn workspaced_service() -> Router { .route("/leave", post(leave_workspace)) .route("/get_workspace_name", get(get_workspace_name)) .route("/create_fork", post(create_workspace_fork)) + .route( + "/fork_creation_status/{creation_id}", + get(get_fork_creation_status), + ) .route("/attach_dev_workspace", post(attach_dev_workspace)) .route("/detach_dev_workspace", post(detach_dev_workspace)) .route("/get_dev_workspace", get(get_dev_workspace)) @@ -2300,7 +2304,8 @@ struct DataTableTables { schemas: TableListMap, #[serde(skip_serializing_if = "Option::is_none")] error: Option, - /// On the instance database: the only kind that can be under roles or have its access edited. + /// On a database Windmill manages, on its own cluster or the external one: the only kinds that + /// can be under roles or have their access edited. instance: bool, permissioned: bool, /// The roles this caller may connect as, by name; empty when not under roles. @@ -2557,7 +2562,7 @@ async fn list_one_datatable_tables( }; let result: Result<()> = async { let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?; - entry.instance = governing.is_instance(); + entry.instance = governing.role_cluster().is_some(); let usable = crate::datatable_permissions_oss::usable_datatable_roles(db, authed, w_id, &governing) .await?; @@ -3082,6 +3087,25 @@ pub(crate) async fn resolve_pg_source_checked( w_id: &str, source: &str, ) -> Result { + Ok( + resolve_pg_source_checked_with_kind(db, user_db, authed, w_id, source) + .await? + .0, + ) +} + +/// [`resolve_pg_source_checked`], also reporting the kind of Windmill-managed database behind the +/// source, `None` for a user resource. Callers that guard a managed connection MUST take the kind +/// from here rather than ask separately: between two reads a save can flip the entry, leaving the guards of one kind +/// applied to the connection of the other. +pub(crate) async fn resolve_pg_source_checked_with_kind( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + source: &str, +) -> Result<(PgDatabase, Option)> { + let mut managed_kind = None; let db_resource = if let Some(name) = source.strip_prefix("datatable://") { windmill_common::workspaces::ensure_datatable_admin_access( db, @@ -3090,7 +3114,13 @@ pub(crate) async fn resolve_pg_source_checked( &DatatableAccess::Authed(authed.to_authed_ref()), ) .await?; - get_datatable_resource_from_db_unchecked(db, w_id, name).await? + let (connection, kind) = + windmill_common::workspaces::get_datatable_connection_and_kind_unchecked( + db, w_id, name, + ) + .await?; + managed_kind = kind; + connection } else if let Some(path) = source.strip_prefix("$res:") { let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed( authed, @@ -3123,27 +3153,37 @@ pub(crate) async fn resolve_pg_source_checked( ))); }; - serde_json::from_value(db_resource) - .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e))) + let pg: PgDatabase = serde_json::from_value(db_resource) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; + Ok((pg, managed_kind)) } -/// Whether the data table `name` is backed by the Windmill instance's own PostgreSQL -/// rather than a user resource. -pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Result { +/// The kind of the database backing the data table `name` when Windmill manages it (on its own +/// cluster or the external one), `None` when it is a user resource. +pub(crate) async fn managed_datatable_kind( + db: &DB, + w_id: &str, + name: &str, +) -> Result> { // Resolved rather than read: a pointer entry owns no database of its own, so only the entry it - // lands on can answer. A name that resolves to nothing keeps the historical `false`. + // lands on can answer. A name that resolves to nothing keeps the historical `None`. Ok(resolve_governing_datatable(db, w_id, name) .await .ok() .and_then(|g| g.datatable.database) - .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)) + .map(|d| d.resource_type) + .filter(|kind| kind.is_windmill_managed())) } /// Same, for the `datatable://` / `$res:` form the import endpoints take. -async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result { +async fn managed_datatable_source_kind( + db: &DB, + w_id: &str, + source: &str, +) -> Result> { match source.strip_prefix("datatable://") { - Some(name) => is_instance_datatable(db, w_id, name).await, - None => Ok(false), + Some(name) => managed_datatable_kind(db, w_id, name).await, + None => Ok(None), } } @@ -3242,13 +3282,7 @@ pub(crate) async fn pg_dump_database( if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } - - if let Some(ref sslmode) = pg_db.sslmode { - cmd.env("PGSSLMODE", sslmode); - } - if let Some(options) = pg_db.non_empty_options() { - cmd.env("PGOPTIONS", options); - } + let _root_cert = apply_pg_tls_env(&mut cmd, pg_db)?; let output = cmd .output() @@ -3366,7 +3400,7 @@ async fn comment_out_unsupported_settings( /// A psql invocation against `pg_db`, carrying the connection settings the CLI reads /// from the environment. -fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { +fn psql_command(pg_db: &PgDatabase) -> Result<(tokio::process::Command, Option)> { let mut cmd = tokio::process::Command::new("psql"); cmd.arg("--host") .arg(&pg_db.host) @@ -3383,13 +3417,92 @@ fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } + let root_cert = apply_pg_tls_env(&mut cmd, pg_db)?; + Ok((cmd, root_cert)) +} + +/// Give libpq the TLS settings `PgDatabase::connect` applies. The returned file holds the root +/// certificate `PGSSLROOTCERT` names, so it must outlive the command. +fn apply_pg_tls_env( + cmd: &mut tokio::process::Command, + pg_db: &PgDatabase, +) -> Result> { if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } if let Some(options) = pg_db.non_empty_options() { cmd.env("PGOPTIONS", options); } - cmd + if let Some(pem) = pg_db + .root_certificate_pem + .as_deref() + .filter(|p| !p.is_empty()) + { + let file = DumpFile::new()?; + std::fs::write(&file.path, pem) + .map_err(|e| Error::internal_err(format!("Failed to write root certificate: {e}")))?; + cmd.env("PGSSLROOTCERT", &file.path); + return Ok(Some(file)); + } + // Only a connection that asked to be verified against the system trust store. Without a file, + // libpq's own default would look for `~/.postgresql/root.crt` and refuse a verify-* mode. libpq + // takes the special `system` value with verify-full only, so verify-ca needs the bundle itself. + if pg_db.accept_invalid_certs == Some(false) { + match pg_db.sslmode.as_deref() { + Some("verify-full") => { + cmd.env("PGSSLROOTCERT", "system"); + } + Some("verify-ca") => { + if let Some(bundle) = windmill_common::system_ca_bundle() { + cmd.env("PGSSLROOTCERT", bundle); + } + } + _ => {} + } + } + Ok(None) +} + + +#[cfg(test)] +mod pg_tls_env_tests { + use super::apply_pg_tls_env; + use windmill_common::PgDatabase; + + fn root_cert_env(sslmode: &str) -> Option { + let pg_db = PgDatabase { + host: "db".to_string(), + user: None, + password: None, + port: None, + sslmode: Some(sslmode.to_string()), + dbname: "d".to_string(), + root_certificate_pem: None, + accept_invalid_certs: Some(false), + use_iam_auth: None, + region: None, + options: None, + }; + let mut cmd = tokio::process::Command::new("psql"); + apply_pg_tls_env(&mut cmd, &pg_db).unwrap(); + cmd.as_std() + .get_envs() + .find(|(k, _)| *k == "PGSSLROOTCERT") + .and_then(|(_, v)| v.map(|v| v.to_os_string())) + } + + #[test] + fn system_roots_only_through_verify_full() { + assert_eq!( + root_cert_env("verify-full").as_deref(), + Some("system".as_ref()) + ); + // libpq refuses `sslrootcert=system` with verify-ca, which would fail every dump and restore. + assert_ne!( + root_cert_env("verify-ca").as_deref(), + Some("system".as_ref()) + ); + } } /// GUC names the server backing `pg_db` knows about. @@ -3399,7 +3512,8 @@ fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { /// and an unset mode, where `PgDatabase::connect` would hand a TLS-only server a /// plaintext socket and fail before the import ever starts. async fn server_setting_names(pg_db: &PgDatabase) -> Result> { - let output = psql_command(pg_db) + let (mut cmd, _root_cert) = psql_command(pg_db)?; + let output = cmd .arg("--tuples-only") .arg("--no-align") .arg("--command") @@ -3436,7 +3550,8 @@ pub(crate) async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) let supported_settings = server_setting_names(target_db).await?; comment_out_unsupported_settings(dump_file, &supported_settings).await?; - let output = psql_command(target_db) + let (mut cmd, _root_cert) = psql_command(target_db)?; + let output = cmd .arg("--set") .arg("ON_ERROR_STOP=1") .arg("--single-transaction") @@ -3494,9 +3609,39 @@ async fn create_pg_database( } } - if is_instance_datatable_source(&db, &w_id, &req.source).await? { - windmill_common::create_custom_instance_database(&db, &req.target_dbname, "datatable") + if let Some(source_kind) = managed_datatable_source_kind(&db, &w_id, &req.source).await? { + // Held until the copy is registered, as a rename migrates reservations to the new id under + // it once the old one is archived: a copy registered after that would be reserved for an + // id nothing answers on. + let mut tx = db.begin().await?; + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; + let live = sqlx::query_scalar::<_, bool>("SELECT NOT deleted FROM workspace WHERE id = $1") + .bind(&w_id) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(false); + if !live { + return Err(Error::BadRequest(format!("Workspace '{w_id}' is archived"))); + } + if source_kind == DataTableCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::create_external_instance_database_unchecked( + &db, + &mut tx, + &req.target_dbname, + "datatable", + Some(&w_id), + ) .await?; + } else { + windmill_common::create_custom_instance_database( + &db, + &req.target_dbname, + "datatable", + Some(&w_id), + ) + .await?; + } + tx.commit().await?; } else { let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; @@ -3583,12 +3728,12 @@ pub(crate) async fn ensure_datatable_is_clonable( let governing = resolve_governing_datatable(db, w_id, name).await?; // The copy has to name a database of its own. A resource-backed entry reached through a // pointer names one this workspace does not own, so there is nothing here to repoint. - let is_instance = governing + let is_managed = governing .datatable .database .as_ref() - .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance); - if governing.workspace_id != w_id && !is_instance { + .is_some_and(|d| d.resource_type.is_windmill_managed()); + if governing.workspace_id != w_id && !is_managed { return Err(Error::BadRequest(format!( "Data table '{name}' points at a resource-backed data table in another workspace \ and cannot be copied; fork it from the workspace that owns it." @@ -3642,9 +3787,11 @@ async fn import_pg_database( } let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly; - let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; - let mut target_pg = - resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?; + let mut fork_lock: Option> = None; + let (source_pg, source_kind) = + resolve_pg_source_checked_with_kind(&db, &user_db, &authed, &w_id, &req.source).await?; + let (mut target_pg, target_kind) = + resolve_pg_source_checked_with_kind(&db, &user_db, &authed, &w_id, &req.target).await?; if let Some(ref override_dbname) = req.target_dbname_override { if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { @@ -3654,6 +3801,29 @@ async fn import_pg_database( .to_string(), )); } + // The kind the connection above was built from, never a second read: an entry flipped + // to a resource between the two would keep the managed connection here and lose the + // check that decides which copy it may fill. + if let Some(kind) = target_kind { + // Held until the restore is done: fork finalization takes the first, and every + // save newly naming a database, in any workspace, the second. Nothing may start + // using this database while `psql` is still filling it. + let mut tx = db.begin().await?; + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut tx, + [override_dbname.as_str()], + ) + .await?; + windmill_common::ensure_fork_database_available_to( + &mut tx, + kind, + override_dbname, + &w_id, + ) + .await?; + fork_lock = Some(tx); + } } target_pg.dbname = override_dbname.clone(); } @@ -3663,8 +3833,7 @@ async fn import_pg_database( // what it creates it owns. Grants do, except around an instance data table — Windmill // plants `custom_instance_user` grants in one, which nothing else can replay. Elsewhere // the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access. - let no_acl = is_instance_datatable_source(&db, &w_id, &req.target).await? - || is_instance_datatable_source(&db, &w_id, &req.source).await?; + let no_acl = target_kind.is_some() || source_kind.is_some(); let dump_file = pg_dump_database( &source_pg, @@ -3672,6 +3841,9 @@ async fn import_pg_database( ) .await?; pg_import_dump(&target_pg, &dump_file).await?; + if let Some(tx) = fork_lock { + tx.commit().await?; + } Ok(format!( "Imported from '{}' into '{}'", @@ -3772,39 +3944,76 @@ async fn edit_ducklake_config( ) .await?; - let old_ducklakes = sqlx::query_scalar!( - r#" - SELECT ws.ducklake->'ducklakes' AS ducklake_name - FROM workspace_settings ws - WHERE ws.workspace_id = $1 - "#, - &w_id + // Under the row lock the save writes with, taken before the database locks below as fork + // cleanup takes the two. + let old_ducklakes = sqlx::query_scalar::<_, Option>( + "SELECT ws.ducklake->'ducklakes' FROM workspace_settings ws + WHERE ws.workspace_id = $1 FOR UPDATE", ) + .bind(&w_id) .fetch_one(&mut *tx) .await? .unwrap_or(serde_json::Value::Null); let old_ducklakes: HashMap = serde_json::from_value(old_ducklakes).unwrap_or_default(); - // Check that non-superadmins are not abusing Instance databases - if !is_superadmin { - for (name, dl) in new_config.settings.ducklakes.iter() { - if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance { - let old_dl = old_ducklakes.get(name); - if old_dl.is_none() - || old_dl.unwrap().catalog.resource_type - != DucklakeCatalogResourceType::Instance - || old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path - { - return Err(Error::BadRequest( - "Only superadmins can create or modify ducklakes with Instance databases" - .to_string(), - )); - } - } + // Check that non-superadmins are not abusing Instance databases. An unchanged catalog is left + // alone either way, so a downgraded instance can still save lakes that already name an + // external instance database. + for (name, dl) in new_config.settings.ducklakes.iter() { + let kind = &dl.catalog.resource_type; + if !matches!( + kind, + DucklakeCatalogResourceType::Instance | DucklakeCatalogResourceType::ExternalInstance + ) { + continue; + } + let unchanged = old_ducklakes.get(name).is_some_and(|old| { + &old.catalog.resource_type == kind + && old.catalog.resource_path == dl.catalog.resource_path + }); + if unchanged { + continue; + } + // Before the registration check, whose refusal would otherwise tell a workspace admin + // which databases exist on the cluster. + if !is_superadmin { + return Err(Error::BadRequest( + "Only superadmins can create or modify ducklakes with Instance databases" + .to_string(), + )); + } + if *kind == DucklakeCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::ensure_external_instance_available()?; + windmill_common::external_instance_pg::ensure_external_instance_database_registered( + &mut tx, + &dl.catalog.resource_path, + ) + .await?; + } else { + windmill_common::workspaces::ensure_instance_pg_available(&mut *tx).await?; } } + // Fork cleanup decides nothing uses an instance database under this lock, so a catalog newly + // put on one must not commit between its check and its drop. + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut *tx, + new_config + .settings + .ducklakes + .iter() + .filter(|(name, dl)| { + dl.catalog.resource_type == DucklakeCatalogResourceType::Instance + && old_ducklakes.get(name.as_str()).is_none_or(|old| { + old.catalog.resource_type != DucklakeCatalogResourceType::Instance + || old.catalog.resource_path != dl.catalog.resource_path + }) + }) + .map(|(_, dl)| dl.catalog.resource_path.as_str()), + ) + .await?; + let config: serde_json::Value = serde_json::to_value(&new_config.settings) .map_err(|err| Error::internal_err(err.to_string()))?; @@ -3860,6 +4069,8 @@ async fn edit_datatable_config( let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); let mut tx = db.begin().await?; + // Ahead of the settings row, as fork cleanup of this workspace takes the two. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; windmill_common::lock_instance_databases( &mut tx, new_config.settings.datatables.values().filter_map(|dt| { @@ -3983,6 +4194,7 @@ async fn edit_datatable_config( // so these line up with the `datatable_configured` adoption counts. created_substrates.push(match dt.database.as_ref().map(|d| d.resource_type) { Some(DataTableCatalogResourceType::Instance) => "instance", + Some(DataTableCatalogResourceType::ExternalInstance) => "external_instance", Some(DataTableCatalogResourceType::Postgresql) => "postgresql", None => "reference", }); @@ -4018,18 +4230,20 @@ async fn edit_datatable_config( ))); } // Carrying the block onto a resource-backed entry would produce a data table the chokepoint - // refuses on every job — a save that succeeds and breaks everything afterwards. Refuse it - // instead: turning roles off first is one step, and it keeps discarding an access decision - // something somebody chose rather than a side effect of moving a database. + // refuses on every job — a save that succeeds and breaks everything afterwards — and onto + // the other managed cluster, one whose role ids name nothing in that cluster's catalog. + // Refuse it instead: turning roles off first is one step, and it keeps discarding an access + // decision something somebody chose rather than a side effect of moving a database. + let old_kind = old.and_then(|old| old.database.as_ref()).map(|d| d.resource_type); if dt.permissions.is_some() && dt .database .as_ref() - .is_some_and(|d| d.resource_type != DataTableCatalogResourceType::Instance) + .is_some_and(|d| Some(d.resource_type) != old_kind) { return Err(Error::BadRequest(format!( - "Data table '{name}' is under roles, which only a data table on the instance \ - database can be. Turn its roles off before moving it to a PostgreSQL resource." + "Data table '{name}' is under roles, which belong to the cluster its database is \ + on. Turn its roles off before moving it to another kind of database." ))); } // A pointer names no database of its own, so the form's empty `database` is correct there. @@ -4054,35 +4268,52 @@ async fn edit_datatable_config( // Check that non-superadmins are not abusing Instance databases, which reach a database this // workspace does not own. Pointing an entry at another workspace's data table is not checked // here because it cannot be requested at all: `reference` is overwritten from the stored entry - // above, for every caller. + // above, for every caller. An unchanged entry is left alone either way, so a downgraded + // instance can still save settings that already name an external instance database. // // Compared against the entry the carried fields came from, not the one stored under the same // name: otherwise swapping two names keeps each database in place while moving a clone's // `governed_by` off the copy it governs. - if !is_superadmin { - for (name, dt) in new_config.settings.datatables.iter() { - let old_dt = old_datatables.get( + for (name, dt) in new_config.settings.datatables.iter() { + let Some(database) = dt + .database + .as_ref() + .filter(|d| d.resource_type.is_windmill_managed()) + else { + continue; + }; + let unchanged = old_datatables + .get( rename_src .get(name.as_str()) .copied() .unwrap_or(name.as_str()), - ); - if dt - .database - .as_ref() - .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance) - { - let unchanged = old_dt.and_then(|o| o.database.as_ref()).is_some_and(|o| { - o.resource_type == DataTableCatalogResourceType::Instance - && Some(&o.resource_path) == dt.database.as_ref().map(|d| &d.resource_path) - }); - if !unchanged { - return Err(Error::BadRequest( - "Only superadmins can create or modify data tables with Instance databases" - .to_string(), - )); - } - } + ) + .and_then(|o| o.database.as_ref()) + .is_some_and(|o| { + o.resource_type == database.resource_type + && o.resource_path == database.resource_path + }); + if unchanged { + continue; + } + // Before the registration check, whose refusal would otherwise tell a workspace admin + // which databases exist on the cluster. + if !is_superadmin { + return Err(Error::BadRequest( + "Only superadmins can create or modify data tables with Instance databases" + .to_string(), + )); + } + if database.resource_type == DataTableCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::ensure_external_instance_available()?; + windmill_common::external_instance_pg::ensure_external_instance_database_registered( + &mut tx, + &database.resource_path, + ) + .await?; + } else { + windmill_common::workspaces::ensure_instance_pg_available(&mut *tx).await?; } } @@ -4102,7 +4333,7 @@ async fn edit_datatable_config( // entry through a declared rename alone, and a settings sync never declares one, so an entry // without roles that newly points at such a database — a name added, or an existing one // repointed — would answer everyone there as `admin`. That holds whichever workspace governs it. - let newly_pointed: Vec<(&String, &str)> = new_config + let newly_pointed: Vec<(&String, DataTableCatalogResourceType, &str)> = new_config .settings .datatables .iter() @@ -4112,7 +4343,7 @@ async fn edit_datatable_config( let db = dt .database .as_ref() - .filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?; + .filter(|d| d.resource_type.is_windmill_managed())?; let lookup = rename_src .get(name.as_str()) .copied() @@ -4124,38 +4355,72 @@ async fn edit_datatable_config( old_db.resource_type != db.resource_type || old_db.resource_path != db.resource_path }); - repointed.then_some((name, db.resource_path.as_str())) + repointed.then_some((name, db.resource_type, db.resource_path.as_str())) }) .collect(); // Another workspace turning roles on for the same database holds only its own settings row, so - // without this the scan below could read past its uncommitted write. + // without this the scan below could read past its uncommitted write. Every managed database + // this save newly names is locked, not just the ones the scan is about: fork cleanup takes the + // same lock to decide nothing uses the database it is dropping. + let newly_named: std::collections::BTreeSet<&str> = new_config + .settings + .datatables + .iter() + .filter_map(|(name, dt)| { + let db = dt + .database + .as_ref() + .filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?; + let lookup = rename_src + .get(name.as_str()) + .copied() + .unwrap_or(name.as_str()); + old_datatables + .get(lookup) + .and_then(|old| old.database.as_ref()) + .is_none_or(|old_db| { + old_db.resource_type != db.resource_type + || old_db.resource_path != db.resource_path + }) + .then_some(db.resource_path.as_str()) + }) + .collect(); windmill_common::datatable_roles::lock_instance_databases_governance( &mut *tx, - newly_pointed.iter().map(|(_, dbname)| *dbname), + newly_pointed + .iter() + .map(|(_, _, dbname)| *dbname) + .chain(newly_named.iter().copied()), ) .await?; - let governed_elsewhere: Vec = if newly_pointed.is_empty() { + let governed_elsewhere: Vec<(String, String)> = if newly_pointed.is_empty() { vec![] } else { - sqlx::query_scalar( - "SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws + sqlx::query_as( + "SELECT DISTINCT dt.value->'database'->>'resource_type', + dt.value->'database'->>'resource_path' + FROM workspace_settings ws CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt WHERE ws.workspace_id <> $1 AND (dt.value ? 'permissions' OR dt.value ? 'governed_by') - AND dt.value->'database'->>'resource_type' = 'instance'", + AND dt.value->'database'->>'resource_type' IN ('instance', 'external_instance')", ) .bind(&w_id) .fetch_all(&mut *tx) .await? }; - for (name, dbname) in newly_pointed { + for (name, kind, dbname) in newly_pointed { let governed_here = old_datatables.values().any(|old| { (old.permissions.is_some() || old.governed_by.is_some()) - && old.database.as_ref().is_some_and(|d| { - d.resource_type == DataTableCatalogResourceType::Instance - && d.resource_path == dbname - }) + && old + .database + .as_ref() + .is_some_and(|d| d.resource_type == kind && d.resource_path == dbname) }); - if governed_here || governed_elsewhere.iter().any(|g| g == dbname) { + if governed_here + || governed_elsewhere + .iter() + .any(|(k, p)| k == kind.as_ref() && p == dbname) + { return Err(Error::BadRequest(format!( "Data table '{name}' would point at database '{dbname}', which a data table under \ roles uses, without carrying those roles: everyone reaching '{name}' would connect \ @@ -6663,7 +6928,9 @@ async fn clone_workspace_data( source_workspace_id: &str, target_workspace_id: &str, authed: &ApiAuthed, + progress: &ForkProgress, ) -> Result<()> { + progress.step("Copying settings, folders and groups"); // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -6685,6 +6952,7 @@ async fn clone_workspace_data( // Clone groups clone_groups(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying resources and variables"); // Clone resource types clone_resource_types(tx, source_workspace_id, target_workspace_id).await?; @@ -6694,6 +6962,7 @@ async fn clone_workspace_data( // Clone variables (including external secret backend replication) clone_variables(tx, db, source_workspace_id, target_workspace_id).await?; + progress.step("Copying scripts"); // Clone scripts with new hashes clone_scripts(tx, source_workspace_id, target_workspace_id).await?; @@ -6711,18 +6980,21 @@ async fn clone_workspace_data( clone_metric_catalog(tx, source_workspace_id, target_workspace_id).await?; clone_asset_usages_and_triggers(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying flows"); // Clone flows with new versions clone_flows(tx, source_workspace_id, target_workspace_id).await?; // Clone flow nodes clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying apps"); // Clone apps with new IDs and app scripts let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?; // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying drafts and dependencies"); // Clone the forker's own per-user drafts (plus the legacy NULL-email // workspace draft, if any) so they keep their pending edits in the // fork. Other users' drafts are intentionally NOT cloned — they don't @@ -8429,13 +8701,14 @@ async fn point_kept_datatables_at_parent( if dt.reference.is_some() { continue; } - // Only instance databases. A resource-backed data table names a resource, and the settings - // clone gave the fork its own copy of that resource in its own workspace — pointing at the - // parent's entry would silently move the fork onto the parent's resource instead. + // Only instance databases, on either cluster. A resource-backed data table names a + // resource, and the settings clone gave the fork its own copy of that resource in its own + // workspace — pointing at the parent's entry would silently move the fork onto the + // parent's resource instead. if dt .database .as_ref() - .is_none_or(|d| d.resource_type != DataTableCatalogResourceType::Instance) + .is_none_or(|d| !d.resource_type.is_windmill_managed()) { continue; } @@ -8609,11 +8882,39 @@ async fn apply_forked_datatable( })?, }; - if database.resource_type == DataTableCatalogResourceType::Instance { + if database.resource_type == DataTableCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::ensure_external_instance_database_registered( + tx, + &fdt.new_dbname, + ) + .await?; + } + if database.resource_type.is_windmill_managed() { + // Held until the fork commits, as every save newly naming a database takes it: none may + // claim the copy between the check below and this fork's entry landing on it. + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut **tx, + [fdt.new_dbname.as_str()], + ) + .await?; + } + if database.resource_type.is_windmill_managed() + && !windmill_api_auth::is_super_admin_authed(db, authed).await? + { + windmill_common::ensure_fork_database_available_to( + &mut **tx, + database.resource_type, + &fdt.new_dbname, + parent_w_id, + ) + .await?; + } + if database.resource_type.is_windmill_managed() { // The whole `database` object, not just its `resource_path`: a pointer entry has none to - // patch. `reference` goes with it — exactly one of the two may be set. + // patch. `reference` goes with it — exactly one of the two may be set. The copy was created + // on the same cluster as its source, so it keeps the source's kind. let new_database = serde_json::json!({ - "resource_type": "instance", + "resource_type": database.resource_type, "resource_path": &fdt.new_dbname, }); sqlx::query( @@ -8919,6 +9220,7 @@ async fn create_workspace_fork( authed: ApiAuthed, Extension(db): Extension, Path(parent_workspace_id): Path, + Query(q): Query, Json(nw): Json, ) -> Result { enforce_fork_depth(&db, &parent_workspace_id, 0).await?; @@ -9024,63 +9326,279 @@ async fn create_workspace_fork( // Refused here, before any database exists; `make_copies` checks again under the locks. validate_forked_datatables(&db, &authed, &parent_workspace_id, &nw).await?; - // Detached from the request: a client that goes away while the copies are made must still end - // with the fork created or no copy left behind, and dropping the handler's future would skip - // that cleanup. - tokio::spawn(async move { - let fork_id = nw.id.clone(); - let copies = crate::datatable_clone::make_copies( - &db, - &authed, - &parent_workspace_id, - ©_requests(&nw.forked_datatables), - ) - .await?; - let replayed: Vec = copies - .iter() - .filter(|c| c.replayed) - .map(|c| c.behavior) - .collect(); - match write_workspace_fork( + if !q.background.unwrap_or(false) { + // Detached from the request: a client that goes away while the copies are made must still + // end with the fork created or no copy left behind, and dropping the handler's future + // would skip that cleanup. + return tokio::spawn(make_workspace_fork( + db, + authed, + parent_workspace_id, + nw, + dev_workspace_label, + ForkProgress::default(), + )) + .await + .map_err(|e| { + Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}")) + })?; + } + + let fork_id = nw.id.clone(); + sqlx::query( + "DELETE FROM workspace_fork_creation + WHERE COALESCE(finished_at, heartbeat_at) < now() - interval '7 days'", + ) + .execute(&db) + .await?; + let claimed = sqlx::query( + "INSERT INTO workspace_fork_creation + (fork_workspace_id, parent_workspace_id, created_by, creation_id) + VALUES ($1, $2, $3, $5) + ON CONFLICT (fork_workspace_id) DO UPDATE SET + parent_workspace_id = EXCLUDED.parent_workspace_id, + created_by = EXCLUDED.created_by, + creation_id = EXCLUDED.creation_id, + started_at = now(), heartbeat_at = now(), step = NULL, finished_at = NULL, + error = NULL + WHERE workspace_fork_creation.finished_at IS NOT NULL + OR workspace_fork_creation.heartbeat_at < now() - $4 * interval '1 second' + RETURNING creation_id", + ) + .bind(&fork_id) + .bind(&parent_workspace_id) + .bind(&authed.email) + .bind(FORK_HEARTBEAT_STALE_SECS as f64) + .bind(Uuid::new_v4()) + .fetch_optional(&db) + .await?; + // Every write of this attempt is scoped to its `creation_id`: one taken over as abandoned must + // not report over the attempt that took its place. + let Some(creation_id) = claimed.map(|r| r.get::("creation_id")) else { + return Err(Error::BadRequest(format!( + "workspace '{fork_id}' is already being created" + ))); + }; + + BACKGROUND_FORKS.spawn(async move { + let (progress_tx, mut progress_rx) = tokio::sync::watch::channel(""); + // The one writer of the run's row while it copies: it records a new step as soon as the + // copy reports it, and refreshes the heartbeat in between. + let heartbeat = { + let (db, fork_id) = (db.clone(), fork_id.clone()); + tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(FORK_HEARTBEAT_SECS)); + let mut reporting = true; + loop { + tokio::select! { + _ = interval.tick() => {} + changed = progress_rx.changed(), if reporting => reporting = changed.is_ok(), + } + let step = *progress_rx.borrow_and_update(); + if let Err(e) = sqlx::query( + "UPDATE workspace_fork_creation SET heartbeat_at = now(), step = $3 + WHERE fork_workspace_id = $1 AND creation_id = $2", + ) + .bind(&fork_id) + .bind(creation_id) + .bind((!step.is_empty()).then_some(step)) + .execute(&db) + .await + { + tracing::warn!("fork '{fork_id}': could not record progress: {e}"); + } + } + }) + }; + let made = tokio::spawn(make_workspace_fork( db.clone(), authed, parent_workspace_id, nw, dev_workspace_label, - &copies, + ForkProgress { creation_id: Some(creation_id), steps: Some(progress_tx) }, + )) + .await + .map_err(|e| Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}"))) + .and_then(|r| r); + heartbeat.abort(); + let error = made.err().map(|e| { + tracing::error!("Creating fork '{fork_id}' failed: {e}"); + e.to_string() + }); + // A fork that committed is already recorded complete, even when its commit's + // acknowledgement was lost and the attempt reads as an error here. + if let Err(e) = sqlx::query( + "UPDATE workspace_fork_creation SET finished_at = now(), error = $3 + WHERE fork_workspace_id = $1 AND creation_id = $2 AND finished_at IS NULL", ) + .bind(&fork_id) + .bind(creation_id) + .bind(error) + .execute(&db) .await { - Ok(message) => { - for behavior in replayed { - windmill_common::feature_usage::log_feature_usage( - "datatable", - "clone_replayed", - match behavior { - DataTableForkBehavior::SchemaOnly => "schema_only", - _ => "schema_and_data", - }, - ); - } - Ok(message) - } - // A commit whose acknowledgement was lost can still have committed, and a concurrent - // request for the same id can have: once the write has settled, the cleanup keeps - // whichever copies a committed fork names, and drops the rest. - Err(e) => match wait_for_fork_write(&db, &fork_id).await { - Ok(()) => Err(crate::datatable_clone::drop_copies_after(&db, copies, e).await), - Err(settle) => { - tracing::error!( - "Could not tell whether fork '{fork_id}' was created, so its copies were \ - kept: {settle}" - ); - Err(e) - } - }, + tracing::error!("fork '{fork_id}': could not record how its creation ended: {e}"); } - }) + }); + Ok(creation_id.to_string()) +} + +/// How often a fork created in the background records that its copy is still running, and how long +/// without that record its creation counts as abandoned. +const FORK_HEARTBEAT_SECS: u64 = 10; +const FORK_HEARTBEAT_STALE_SECS: u64 = 60; + +/// Forks being created in the background. The server waits for them on shutdown: a copy cut short +/// never drops the data table databases it already made, and a retry under the same id is refused +/// while they exist. +pub static BACKGROUND_FORKS: std::sync::LazyLock = + std::sync::LazyLock::new(tokio_util::task::TaskTracker::new); + +#[derive(Deserialize)] +struct CreateWorkspaceForkQuery { + /// Return once the request is validated and create the fork in the background, answering with + /// the id `fork_creation_status` reads this attempt by. Without it the answer is + /// `Created forked workspace `, which clients take as a server without background forks. + background: Option, +} + +/// The attempt a fork created in the background belongs to, and where its copy is, for its status +/// to report. +#[derive(Default)] +struct ForkProgress { + creation_id: Option, + steps: Option>, +} + +impl ForkProgress { + fn step(&self, step: &'static str) { + if let Some(tx) = &self.steps { + tx.send_replace(step); + } + } +} + +#[derive(Serialize)] +struct ForkCreationStatus { + /// `running`, `completed` or `failed`. + status: &'static str, + /// The part of the copy a running fork is in. + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +async fn get_fork_creation_status( + authed: ApiAuthed, + Extension(db): Extension, + Path((parent_workspace_id, creation_id)): Path<(String, Uuid)>, +) -> JsonResult { + let row = sqlx::query( + "SELECT c.created_by, c.finished_at IS NOT NULL AS finished, c.error, c.step, + c.heartbeat_at < now() - $3 * interval '1 second' AS stale + FROM workspace_fork_creation c + WHERE c.creation_id = $1 AND c.parent_workspace_id = $2", + ) + .bind(creation_id) + .bind(&parent_workspace_id) + .bind(FORK_HEARTBEAT_STALE_SECS as f64) + .fetch_optional(&db) + .await?; + // Only the user who started it reads an attempt: its id is not handed to anyone else. + let Some(row) = row.filter(|r| r.get::("created_by") == authed.email) else { + return Err(Error::NotFound(format!( + "no fork creation {creation_id} from '{parent_workspace_id}'" + ))); + }; + let error: Option = row.get("error"); + let status = if row.get::("finished") { + match error { + Some(error) => ForkCreationStatus { status: "failed", step: None, error: Some(error) }, + None => ForkCreationStatus { status: "completed", step: None, error: None }, + } + } else if row.get::("stale") { + ForkCreationStatus { + status: "failed", + step: None, + error: Some( + "the server creating the fork stopped before it finished; check the server logs \ + and whether the fork exists before retrying" + .to_string(), + ), + } + } else { + ForkCreationStatus { status: "running", step: row.get("step"), error: None } + }; + Ok(Json(status)) +} + +/// Copy the data tables the fork gets, then write the fork itself, dropping the copies again if +/// that write fails. +async fn make_workspace_fork( + db: DB, + authed: ApiAuthed, + parent_workspace_id: String, + nw: CreateWorkspaceFork, + dev_workspace_label: Option, + progress: ForkProgress, +) -> Result { + let fork_id = nw.id.clone(); + if !nw.forked_datatables.is_empty() { + progress.step("Copying data tables"); + } + let copies = crate::datatable_clone::make_copies( + &db, + &authed, + &parent_workspace_id, + ©_requests(&nw.forked_datatables), + ) + .await?; + let replayed: Vec = copies + .iter() + .filter(|c| c.replayed) + .map(|c| c.behavior) + .collect(); + match write_workspace_fork( + db.clone(), + authed, + parent_workspace_id, + nw, + dev_workspace_label, + &copies, + &progress, + ) .await - .map_err(|e| Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}")))? + { + Ok(message) => { + for behavior in replayed { + windmill_common::feature_usage::log_feature_usage( + "datatable", + "clone_replayed", + match behavior { + DataTableForkBehavior::SchemaOnly => "schema_only", + _ => "schema_and_data", + }, + ); + } + Ok(message) + } + // A commit whose acknowledgement was lost can still have committed, and a concurrent + // request for the same id can have: once the write has settled, the cleanup keeps + // whichever copies a committed fork names, and drops the rest. + Err(e) => match wait_for_fork_write(&db, &fork_id).await { + Ok(()) => Err(crate::datatable_clone::drop_copies_after(&db, copies, e).await), + Err(settle) => { + tracing::error!( + "Could not tell whether fork '{fork_id}' was created, so its copies were \ + kept: {settle}" + ); + Err(e) + } + }, + } } /// The database a data table of the fork or dev workspace `fork_id` is copied into, as the wizard @@ -9176,6 +9694,7 @@ async fn write_workspace_fork( nw: CreateWorkspaceFork, dev_workspace_label: Option, copies: &[crate::datatable_clone::MadeCopy], + progress: &ForkProgress, ) -> Result { let mut tx: Transaction<'_, Postgres> = db.begin().await?; // Held until this transaction ends: after an error, the copies' cleanup waits on it, so it reads @@ -9184,6 +9703,11 @@ async fn write_workspace_fork( .bind(&nw.id) .execute(&mut *tx) .await?; + // Before the settings clone reads the parent's data tables: a pointer this fork ends up with + // must not be written after cleanup of the parent decided that nothing points at its copies. + // Also before the external cluster's lifecycle lock, which finalizing an external copy takes: + // fork cleanup takes the two in this order. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &parent_workspace_id).await?; if nw.is_dev_workspace { // The checks above ran outside a transaction, so the parent's eligibility and the chain's @@ -9273,8 +9797,15 @@ async fn write_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - if let Err(e) = - clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await + if let Err(e) = clone_workspace_data( + &mut tx, + &db, + &parent_workspace_id, + &forked_id, + &authed, + progress, + ) + .await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: @@ -9305,6 +9836,7 @@ async fn write_workspace_fork( // enabled=false. Disabled rows have no side effects (no listener // attaches, no cron fires) so this is safe by construction. The user // re-enables in the fork, with parent-conflict warnings on enable. + progress.step("Copying triggers and schedules"); clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?; repoint_unresolvable_cloned_identities(&mut tx, &forked_id, &authed).await?; @@ -9328,6 +9860,32 @@ async fn write_workspace_fork( ))); } } + // Saves name an external database under the external cluster's lifecycle lock instead. + let external_copies: Vec<&str> = copies + .iter() + .filter(|c| { + c.source_database.resource_type == DataTableCatalogResourceType::ExternalInstance + }) + .map(|c| c.dbname.as_str()) + .collect(); + if !external_copies.is_empty() { + windmill_common::external_instance_pg::lock_external_instance_pg_state(&mut tx).await?; + } + for dbname in &external_copies { + let uses = windmill_common::workspaces::managed_database_uses( + &mut tx, + DataTableCatalogResourceType::ExternalInstance, + dbname, + None, + ) + .await?; + if !uses.is_empty() { + return Err(Error::BadRequest(format!( + "Database '{dbname}' copied for this fork is already used by {}; fork again", + uses.join(", ") + ))); + } + } // Update forked datatable settings to point to new databases for fdt in &nw.forked_datatables { @@ -9426,6 +9984,16 @@ async fn write_workspace_fork( (!copied.is_empty()).then(|| [("copied_datatables", copied.as_str())].into()), ) .await?; + // Committed with the fork, so its attempt reads complete exactly when the fork exists. + if let Some(creation_id) = progress.creation_id { + sqlx::query( + "UPDATE workspace_fork_creation SET finished_at = now(), error = NULL + WHERE creation_id = $1", + ) + .bind(creation_id) + .execute(&mut *tx) + .await?; + } tx.commit().await?; // A pre-creation lookup could have cached an EMPTY ancestor chain for this id, which @@ -9439,6 +10007,7 @@ async fn write_workspace_fork( windmill_common::workspaces::invalidate_protection_rules_cache(&parent_workspace_id); } + // Clients that ask for a background fork recognise a server without it by this answer. Ok(format!("Created forked workspace {}", &forked_id)) } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 9039bda2ef..8ca98f66d1 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -56,6 +56,11 @@ pub(crate) async fn change_workspace_id( let mut tx = db.begin().await?; + // The settings copy below carries every data table entry to the new id, which fork cleanup of + // the old id cannot see until this commits: without the lock it could drop a copy the renamed + // workspace goes on using. Before the pairing lock, as forking takes the two in that order. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &old_id).await?; + // A rename rewrites the workspace's dev flag and reparents its children, so it decides on the // same state the pairing handlers do: without this lock a concurrent create/attach could commit // an active dev workspace under the shell this rename is about to archive. Both ids, since the @@ -869,6 +874,10 @@ pub(crate) async fn change_workspace_id( } } + // After every workspace_settings write above: fork cleanup locks a settings row before the + // registry, so taking the registry first here would deadlock with it. + migrate_fork_reservations(&mut tx, &old_id, &rw.new_id).await?; + // Audit log in the same transaction as the workspace changes audit_log( &mut *tx, @@ -937,6 +946,14 @@ pub(crate) async fn change_workspace_id( let (_schedules_count, canceled_count, _deleted_tokens_count) = archive_workspace_impl(&db, &old_id, &authed.username, None).await?; + // The old id stays live between the commit above and the archive, and a fork copy created for + // it in that window registers under it. Creation checks the workspace is live under the fork + // lock, so once this has run under it, no copy can be reserved for the old id any more. + let mut tx = db.begin().await?; + windmill_common::workspaces::lock_fork_datatables(&mut tx, &old_id).await?; + migrate_fork_reservations(&mut tx, &old_id, &rw.new_id).await?; + tx.commit().await?; + info!( "Workspace id change completed: moved {} to {}, archived old workspace", old_id, rw.new_id @@ -948,6 +965,50 @@ pub(crate) async fn change_workspace_id( )) } +/// A fork copy reserved for the old id would otherwise be unreachable: its creator cannot import +/// into it or finish its fork under the new id, and nothing else would ever drop it. +async fn migrate_fork_reservations( + tx: &mut Transaction<'_, Postgres>, + old_id: &str, + new_id: &str, +) -> Result<()> { + const MIGRATE: &str = r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', ( + SELECT COALESCE(jsonb_object_agg(k, CASE WHEN v->>'workspace_id' = $1 + THEN jsonb_set(v, '{workspace_id}', to_jsonb($2::text)) ELSE v END), '{}'::jsonb) + FROM jsonb_each(COALESCE(value->'databases', '{}'::jsonb)) AS e(k, v) + )) + WHERE name = $3"#; + sqlx::query(MIGRATE) + .bind(old_id) + .bind(new_id) + .bind("custom_instance_pg_databases") + .execute(&mut **tx) + .await?; + // External copies are registered in the cluster state, which its writers rewrite whole under + // the lifecycle lock. Only taken when there is something to move, as setup can hold it for as + // long as the cluster takes to answer. + let state = windmill_common::global_settings::EXTERNAL_INSTANCE_PG_STATE_SETTING; + let reserved = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM global_settings, jsonb_each(value->'databases') AS e(k, v) + WHERE name = $1 AND jsonb_typeof(value->'databases') = 'object' + AND v->>'workspace_id' = $2)", + ) + .bind(state) + .bind(old_id) + .fetch_one(&mut **tx) + .await?; + if reserved { + windmill_common::external_instance_pg::lock_external_instance_pg_state(tx).await?; + sqlx::query(MIGRATE) + .bind(old_id) + .bind(new_id) + .bind(state) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + #[derive(Deserialize)] pub(crate) struct DeleteWorkspaceQuery { pub(crate) only_delete_forks: Option, @@ -1430,9 +1491,7 @@ pub async fn drop_forked_datatable_databases( _ => continue, }; - if database.resource_type - == windmill_common::workspaces::DataTableCatalogResourceType::Instance - { + if database.resource_type.is_windmill_managed() { let db_to_drop = &database.resource_path; if !db_to_drop.starts_with("wm_fork_") { errors.push(format!( @@ -1441,7 +1500,112 @@ pub async fn drop_forked_datatable_databases( )); continue; } - if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await { + // The fork's own entry is what is going away; anything else still reaching the copy, + // a child fork's pointer at this entry included, keeps it. The lock keeps a child fork + // from gaining such a pointer before the drop. + // A task of its own, so a client going away cannot stop it between dropping the + // database and committing the entry's removal. + let dropped = tokio::spawn({ + let (db, w_id, dt_name, db_to_drop) = ( + db.clone(), + w_id.clone(), + dt_name.clone(), + db_to_drop.clone(), + ); + let resource_type = database.resource_type; + async move { + let mut tx = db.begin().await?; + // The three locks a settings save takes, in its order: this workspace's data + // tables, its settings row, and the database itself. Without them a save could + // rename this entry, or point another one here, either side of the check below. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; + // The snapshot above was read unlocked: a save committing since could have + // repointed this entry, and the entry is removed below whatever it names by then. + let current = sqlx::query_scalar::<_, Option>( + "SELECT datatable->'datatables'->$2 FROM workspace_settings + WHERE workspace_id = $1 FOR UPDATE", + ) + .bind(&w_id) + .bind(&dt_name) + .fetch_optional(&mut *tx) + .await? + .flatten() + .and_then(|v| serde_json::from_value::(v).ok()); + if !current.is_some_and(|dt| { + dt.forked_from.is_some() + && dt.database.is_some_and(|d| { + d.resource_type == resource_type && d.resource_path == db_to_drop + }) + }) { + return Err(Error::BadRequest( + "the data table changed while it was being cleaned up".to_string(), + )); + } + let external = resource_type + == windmill_common::workspaces::DataTableCatalogResourceType::ExternalInstance; + if external { + // Before the governance lock, in the order settings saves and fork + // finalization take the two. + windmill_common::external_instance_pg::lock_external_instance_pg_state( + &mut tx, + ) + .await?; + } + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut tx, + [db_to_drop.as_str()], + ) + .await?; + // Before the entry is removed: child fork pointers are found through it. + let uses = windmill_common::workspaces::managed_database_uses( + &mut tx, + resource_type, + &db_to_drop, + Some((w_id.as_str(), dt_name.as_str())), + ) + .await?; + if !uses.is_empty() { + return Err(Error::BadRequest(format!( + "it is still used by {}", + uses.join(", ") + ))); + } + // The entry goes with the database: a fork this one is cloned into afterwards + // must not inherit a pointer at a data table whose database is gone. + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2] + WHERE workspace_id = $1", + ) + .bind(&w_id) + .bind(&dt_name) + .execute(&mut *tx) + .await?; + if external { + // Checks the uses of the database itself, and unregisters it. + windmill_common::external_instance_pg::drop_external_instance_database_unchecked( + &mut tx, + &db_to_drop, + Some((w_id.as_str(), dt_name.as_str())), + ) + .await?; + } else { + windmill_common::drop_custom_instance_database_keep_entry(&db, &db_to_drop) + .await?; + sqlx::query( + "UPDATE global_settings SET value = value #- ARRAY['databases', $1] + WHERE name = 'custom_instance_pg_databases'", + ) + .bind(&db_to_drop) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok::<_, Error>(()) + } + }) + .await + .unwrap_or_else(|e| Err(Error::internal_err(format!("cleanup task failed: {e}")))); + if let Err(e) = dropped { errors.push(format!( "Could not drop instance database '{}' for datatable://{}: {}", db_to_drop, dt_name, e @@ -1808,7 +1972,17 @@ async fn resolve_fork_catalog_pg( "ducklake://{ducklake_name}: malformed registry catalog identity `{catalog}`" )) })?; - let catalog_resource = if resource_type == "instance" { + let catalog_resource = if resource_type == "external_instance" { + serde_json::to_value( + windmill_common::external_instance_pg::external_instance_connection_unchecked( + db, + resource_path, + false, + ) + .await?, + ) + .map_err(|e| Error::internal_err(format!("serializing pg creds: {e}")))? + } else if resource_type == "instance" { let mut pg_creds = windmill_common::PgDatabase::parse_uri( &windmill_common::get_database_url().await?.as_str().await, )?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1eec8dcf2b..02ff520bdf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.818.0 + version: 1.819.0 title: Windmill API contact: @@ -1398,6 +1398,14 @@ paths: - workspace parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: background + description: | + return once the request is validated and create the fork in the background, answering + with the creation id getForkCreationStatus reads + in: query + required: false + schema: + type: boolean requestBody: description: new forked workspace required: true @@ -1407,12 +1415,45 @@ paths: $ref: "#/components/schemas/CreateWorkspaceFork" responses: "201": - description: forked workspace created + description: forked workspace created, or with background set, the id of its creation content: text/plain: schema: type: string + /w/{workspace}/workspaces/fork_creation_status/{creation_id}: + get: + summary: get the status of a fork created in the background + operationId: getForkCreationStatus + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: creation_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: fork creation status + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [running, completed, failed] + step: + type: string + description: the part of the copy a running fork is in + error: + type: string + required: + - status + /w/{workspace}/workspaces/attach_dev_workspace: post: summary: attach an existing workspace as the dev workspace of this (prod) workspace @@ -1572,6 +1613,104 @@ paths: schema: type: object + /settings/external_instance_pg/status: + get: + summary: Returns whether the external instance cluster is configured and how its last setup went + operationId: getExternalInstancePgStatus + tags: + - setting + responses: + "200": + description: external instance cluster status + content: + application/json: + schema: + $ref: "#/components/schemas/ExternalInstancePgStatus" + + /settings/external_instance_pg/setup: + post: + summary: Sets up the external instance cluster with its saved admin login, optionally rotating the passwords Windmill manages on it (enterprise edition only) + operationId: setupExternalInstancePg + tags: + - setting + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + rotate_passwords: + type: boolean + responses: + "200": + description: the setup report, also stored as the last setup + content: + application/json: + schema: + $ref: "#/components/schemas/ExternalInstancePgSetupReport" + + /settings/external_instance_pg/databases: + get: + summary: Lists the databases Windmill created on the external instance cluster, with the workspaces whose data tables, Ducklake catalogs or pending fork cleanups use each + operationId: listExternalInstancePgDatabases + tags: + - setting + responses: + "200": + description: databases by name + content: + application/json: + schema: + type: object + additionalProperties: + $ref: "#/components/schemas/CustomInstanceDb" + + /settings/external_instance_pg/databases/{name}: + post: + summary: Creates a database on the external instance cluster (enterprise edition only) + operationId: createExternalInstancePgDatabase + tags: + - setting + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tag: + $ref: "#/components/schemas/CustomInstanceDbTag" + responses: + "200": + description: database created + content: + application/json: + schema: {} + delete: + summary: Drops a database Windmill created on the external instance cluster, refused while a data table, Ducklake catalog or pending fork cleanup uses it + operationId: dropExternalInstancePgDatabase + tags: + - setting + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: database dropped + content: + application/json: + schema: {} + /settings/list_custom_instance_pg_databases: post: summary: Returns the set-up statuses of custom instance pg databases @@ -1590,13 +1729,19 @@ paths: /settings/datatable_roles: get: - summary: list the instance's data table roles + summary: list the data table roles of one Windmill-managed Postgres cluster operationId: listInstanceDatatableRoles tags: - setting + parameters: + - in: query + name: cluster + required: false + schema: + $ref: "#/components/schemas/DatatableRoleCluster" responses: "200": - description: the instance role catalog + description: the cluster's role catalog content: application/json: schema: @@ -1604,7 +1749,7 @@ paths: items: $ref: "#/components/schemas/InstanceDatatableRole" post: - summary: create a data table role on the instance's Postgres cluster + summary: create a data table role on a Windmill-managed Postgres cluster operationId: createInstanceDatatableRole tags: - setting @@ -1618,6 +1763,8 @@ paths: properties: name: type: string + cluster: + $ref: "#/components/schemas/DatatableRoleCluster" responses: "200": description: the created role @@ -5351,7 +5498,7 @@ paths: type: string resource_type: type: string - enum: [postgres, instance] + enum: [postgres, instance, external_instance] resource_path: type: string governing_workspace_id: @@ -10898,6 +11045,8 @@ paths: text/plain: schema: type: string + "400": + description: the body's workspace_id does not match the workspace in the path /w/{workspace}/workspace_dependencies/archive/{language}: post: @@ -11815,6 +11964,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_wait_result/fv/{version}: post: @@ -11855,6 +12009,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. get: summary: run flow by version with GET and wait until completion @@ -11887,6 +12046,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_and_stream/f/{path}: post: @@ -11928,6 +12092,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. get: summary: run flow by path with GET and stream updates via SSE @@ -11961,6 +12130,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_and_stream/fv/{version}: post: @@ -12008,6 +12182,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. get: summary: run flow by version with GET and stream updates via SSE @@ -12047,6 +12226,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_and_stream/p/{path}: post: @@ -13552,7 +13736,7 @@ paths: Returns the flat list of all item paths visible to the caller across scripts, flows, apps, raw apps, variables, and resources. Intended to feed an entirely client-side path autocomplete UI: the frontend fetches - once (server caches per workspace for 60s) and performs all prefix/segment + once (server caches per caller for 60s) and performs all prefix/segment computation locally. Capped at 20,000 paths (5,000 per table). operationId: listPathAutocompletePaths tags: @@ -15456,6 +15640,11 @@ paths: schema: type: string format: uuid + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run/fv/{version}: post: @@ -15513,6 +15702,11 @@ paths: schema: type: string format: uuid + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run/batch_rerun_jobs: post: @@ -16006,6 +16200,11 @@ paths: schema: type: string format: uuid + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_wait_result/preview_flow: post: @@ -16035,6 +16234,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run/dynamic_select: post: @@ -28926,6 +29130,23 @@ components: is_test: type: boolean description: Started from the flow editor's test panel rather than a deployed run + running_turn: + type: object + nullable: true + description: >- + The turn the conversation is still answering, set by the list endpoint: its + newest user message, while the flow run it started is queued or running. A + run into this conversation is refused with 409 until the turn ends. + required: [job_id, user_seq] + properties: + job_id: + type: string + format: uuid + description: The flow run of the turn + user_seq: + type: integer + format: int64 + description: created_seq of the user message that started the turn FlowConversationMessage: type: object @@ -29530,6 +29751,7 @@ components: properties: workspace_id: type: string + description: must equal the workspace in the request path language: $ref: "#/components/schemas/ScriptLang" name: @@ -32058,6 +32280,16 @@ components: - id - success - duration_ms + late_run_streak: + type: integer + description: runs in a row, up to the latest, that started or finished after the next occurrence was due, so that occurrence was missed + missed_occurrences: + type: integer + description: occurrences missed by the latest streak of late runs, kept after it ends; a lower bound once a single late run misses 1000 + last_missed_at: + type: string + format: date-time + description: latest missed occurrence, kept after the schedule runs on time again; past 1000 misses in one late run, when the miss was detected, at most one period later ErrorHandler: type: string @@ -33989,9 +34221,55 @@ components: - ducklake - datatable + ExternalInstancePgSetupStep: + type: object + required: [name, status, message] + properties: + name: + type: string + status: + type: string + enum: [ok, warning, error] + message: + type: string + + ExternalInstancePgSetupReport: + type: object + required: [success, finished_at, steps] + properties: + success: + type: boolean + description: no step failed; warnings leave it true + finished_at: + type: string + format: date-time + steps: + type: array + items: + $ref: "#/components/schemas/ExternalInstancePgSetupStep" + + ExternalInstancePgStatus: + type: object + required: [configured, database_count] + properties: + configured: + type: boolean + database_count: + type: integer + last_setup: + $ref: "#/components/schemas/ExternalInstancePgSetupReport" + + DatatableRoleCluster: + type: string + description: >- + The Windmill-managed Postgres cluster a data table role is a login on: Windmill's own + (behind `instance` data tables) or the external instance cluster (behind + `external_instance` ones). Defaults to `instance`. + enum: [instance, external_instance] + InstanceDatatableRole: type: object - required: [id, name, enabled] + required: [id, name, enabled, cluster] properties: id: type: string @@ -33999,6 +34277,8 @@ components: type: string enabled: type: boolean + cluster: + $ref: "#/components/schemas/DatatableRoleCluster" DatatableRoleTenants: type: object @@ -34020,8 +34300,10 @@ components: supported: type: boolean description: >- - Whether this data table can be put under roles at all. Only one backed by the - instance database can: a role is a login on that cluster. + Whether this data table can be put under roles at all. Only one on a database Windmill + manages can: a role is a login on that database's cluster. + cluster: + $ref: "#/components/schemas/DatatableRoleCluster" permissioned: type: boolean default_role: @@ -34333,7 +34615,10 @@ components: type: array items: type: string - description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted. + description: Workspaces that reference this database through a ducklake catalog or a datatable database of the kind being listed — 'instance' for the instance databases endpoint, 'external_instance' for the external cluster one. Computed at request time, not persisted, and only returned to superadmins. + workspace_id: + type: string + description: The workspace a member created this database for as a fork copy. Only that workspace can import into it or point a fork at it. NewSqsTrigger: type: object @@ -36341,6 +36626,7 @@ components: - postgresql - mysql - instance + - external_instance resource_path: type: string required: @@ -36404,6 +36690,7 @@ components: enum: - postgresql - instance + - external_instance resource_path: type: string required: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 54886e2568..ef48927676 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4444,8 +4444,15 @@ async fn execute_component( (JobPayload::Code(raw_code), tag, None) } // inline script: run mode (deployed app) with an entry in `app_script`. - (None, Some(RawCode { language, path, cache_ttl, tag, .. }), Some(id)) => ( - JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path }, + // The path is derived like the legacy arm's: job identity (e.g. OIDC `sub`) + // reads it, so a caller must not choose it. + (None, Some(RawCode { language, cache_ttl, tag, .. }), Some(id)) => ( + JobPayload::AppScript { + id: AppScriptId(id), + cache_ttl, + language, + path: Some(inline_run_path(path, &component)?), + }, resolved_inline_tag(tag), None, ), @@ -4805,8 +4812,9 @@ async fn upload_s3_file_from_app( if let Some(ref s3_resource_path) = query.s3_resource_path { if matched_input.allow_user_resources { if let Some(authed) = opt_authed { + let viewer = policy_granted_viewer(&authed); let db_with_opt_authed = DbWithOptAuthed::from_authed( - &authed, + &viewer, db.clone(), Some(user_db.clone()), ); @@ -5773,6 +5781,14 @@ async fn exists_app( Ok(Json(exists)) } +/// The viewer as whom a resource an app policy lets the viewer pick (`allow_user_resources`) +/// is resolved. The policy, not the token, grants that resource, and app tokens are minted +/// with a fixed scope set that never names variables, so the references inside it resolve on +/// the viewer's RLS alone. +fn policy_granted_viewer(authed: &ApiAuthed) -> ApiAuthed { + ApiAuthed { scopes: None, ..authed.clone() } +} + async fn build_args( policy: &Policy, PolicyTriggerableInputs { @@ -5799,8 +5815,9 @@ async fn build_args( key.and_then(|x| x.clone().strip_prefix("$res:").map(|x| x.to_string())) { if let Some(authed) = authed { + let viewer = policy_granted_viewer(authed); let db_with_opt_authed = - DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); + DbWithOptAuthed::from_authed(&viewer, db.clone(), Some(user_db.clone())); let res = get_resource_value_interpolated_internal( &db_with_opt_authed, w_id, diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 6255b5eb13..8e9fb4a823 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -66,6 +66,7 @@ use crate::{ args::RawWebhookArgs, db::{ApiAuthed, DB}, users::fetch_api_authed, + utils::check_scopes, }; use axum::{ @@ -319,6 +320,7 @@ async fn get_configs( Extension(user_db): Extension, Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>, ) -> JsonResult> { + check_scopes(&authed, || format!("capture:read:{}", path.to_path()))?; let mut tx = user_db.begin(&authed).await?; let configs = sqlx::query_as!( @@ -557,6 +559,7 @@ async fn set_config( Path(w_id): Path, Json(nc): Json, ) -> JsonResult> { + check_scopes(&authed, || format!("capture:write:{}", nc.path))?; let nc = match nc.trigger_kind { TriggerKind::Postgres => { set_postgres_trigger_config(&w_id, authed.clone(), &db, user_db.clone(), nc).await? @@ -616,6 +619,7 @@ async fn ping_config( StripPath, )>, ) -> Result<()> { + check_scopes(&authed, || format!("capture:write:{}", path.to_path()))?; let mut tx = user_db.begin(&authed).await?; if matches!(trigger_kind, TriggerKind::Postgres) { windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?; @@ -667,6 +671,7 @@ async fn list_captures( Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>, Query(query): Query, ) -> JsonResult> { + check_scopes(&authed, || format!("capture:read:{}", path.to_path()))?; let mut tx = user_db.begin(&authed).await?; let (per_page, offset) = paginate(Pagination { page: query.page, per_page: query.per_page }); @@ -713,12 +718,44 @@ async fn list_captures( Ok(Json(captures)) } +/// A capture addressed by id carries no path in the route, so its path scope can only be +/// checked against the row. Looked up under the caller's RLS so the check never reveals +/// whether an id the caller cannot see exists, and the refusal omits the path, which the +/// caller named only by id. +async fn check_capture_id_scope( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + authed: &ApiAuthed, + w_id: &str, + id: i64, + action: &str, +) -> Result<()> { + if authed.scopes.is_none() { + return Ok(()); + } + let path = sqlx::query_scalar!( + "SELECT path FROM capture WHERE id = $1 AND workspace_id = $2", + id, + w_id, + ) + .fetch_optional(&mut **tx) + .await?; + if let Some(path) = path { + check_scopes(authed, || format!("capture:{action}:{path}")).map_err(|_| { + windmill_common::error::Error::PermissionDenied(format!( + "This token's capture:{action} scope does not cover capture {id}" + )) + })?; + } + Ok(()) +} + async fn get_capture( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, id)): Path<(String, i64)>, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; + check_capture_id_scope(&mut tx, &authed, &w_id, id, "read").await?; let capture = sqlx::query_as!( Capture, @@ -751,6 +788,7 @@ async fn delete_capture( Path((w_id, id)): Path<(String, i64)>, ) -> Result<()> { let mut tx = user_db.begin(&authed).await?; + check_capture_id_scope(&mut tx, &authed, &w_id, id, "write").await?; // capture RLS only keys on the path segment, so without workspace_id an id from // another workspace whose path collides with the caller's grants would be deleted. sqlx::query!( @@ -781,8 +819,10 @@ async fn move_captures_and_configs( Path((w_id, runnable_kind, old_path)): Path<(String, RunnableKind, StripPath)>, Json(body): Json, ) -> Result<()> { - let mut tx = user_db.begin(&authed).await?; let old_path = old_path.to_path(); + check_scopes(&authed, || format!("capture:write:{}", old_path))?; + check_scopes(&authed, || format!("capture:write:{}", body.new_path))?; + let mut tx = user_db.begin(&authed).await?; sqlx::query!( r#" diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 60a8037330..10631dbe2b 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -4197,13 +4197,15 @@ async fn get_args( async fn get_started_at_by_ids( Extension(db): Extension, + Path(w_id): Path, Json(mut ids): Json>, ) -> JsonResult>>> { ids.truncate(100); let started_at = sqlx::query!( - "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1)", - ids.as_slice() + "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1) AND workspace_id = $2", + ids.as_slice(), + &w_id ) .fetch_all(&db) .await?; @@ -5411,7 +5413,7 @@ async fn resume_suspended_job_internal( verify_suspended_secret(&w_id, &db, job_id, resume_id, &approver, secret).await?; // Get flow info - works for step-level, flow-level, and WAC approval - let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?; + let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &w_id, &db).await?; // HMAC secret = full capability. Skip approval_conditions checks: possession of the full // resume URL is the authorization (it is only disclosed to intended approvers, e.g. when a @@ -5643,7 +5645,11 @@ struct FlowInfo { /// Returns (FlowInfo, is_flow_level, is_wac) where: /// - is_flow_level: job_id was a flow job (pre-approval) /// - is_wac: job_id is a WAC workflow suspended for approval (target is itself) -async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowInfo, bool, bool)> { +async fn get_flow_info_for_resume( + job_id: Uuid, + w_id: &str, + db: &DB, +) -> error::Result<(FlowInfo, bool, bool)> { // Single query that determines if job_id is a flow, step, or WAC job, // and fetches the appropriate suspended job info. // For WAC jobs (no parent, not a flow), the job itself is the suspended target. @@ -5652,7 +5658,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI WITH job_info AS ( SELECT id, kind::text AS kind, parent_job FROM v2_job - WHERE id = $1 + WHERE id = $1 AND workspace_id = $2 ) SELECT q.id AS "id!", @@ -5672,10 +5678,15 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI FOR UPDATE OF q "#, job_id, + w_id, ) .fetch_optional(db) .await? - .ok_or_else(|| anyhow::anyhow!("job not found or parent flow not in queue: {}", job_id))?; + .ok_or_else(|| { + Error::NotFound(format!( + "job not found or parent flow not in queue: {job_id}" + )) + })?; let flow_info = FlowInfo { id: result.id, @@ -5979,12 +5990,7 @@ pub async fn create_job_signature( Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, Query(approver): Query, ) -> error::Result { - // The HMAC is treated as full authority by the resume endpoints, so minting - // it requires run scope on the suspended job's flow — not merely any - // jobs:run scope. No-op for unscoped tokens (incl. the in-flow substep token - // used by wmill.get_resume_urls()). - let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?; - check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?; + require_resume_mint_authority(&db, &w_id, &authed, job_id).await?; let key = get_workspace_key(&w_id, &db).await?; create_signature(key, job_id, resume_id, approver.approver) } @@ -6077,12 +6083,7 @@ pub async fn get_resume_urls( Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, Query(approver): Query, ) -> error::JsonResult { - // These URLs embed a resume signature (full resume capability), so a scoped - // token must hold run scope on the suspended job's flow. No-op for unscoped - // tokens (incl. the in-flow substep token). Trusted internal callers use - // get_resume_urls_internal directly and are unaffected. - let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?; - check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?; + require_resume_mint_authority(&db, &w_id, &authed, job_id).await?; get_resume_urls_internal( Extension(db), Path((w_id, job_id, resume_id)), @@ -6107,23 +6108,9 @@ pub async fn get_wac_approval_urls( "step_key must be the key of a wait_for_approval step".to_string(), )); } - let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?; - check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?; - - // This handler writes to the job's status row, so the job must actually be in - // the caller's workspace — `v2_job_status` is keyed by job id alone and would - // otherwise take a write aimed at another workspace's job. - let in_workspace = sqlx::query_scalar!( - "SELECT EXISTS (SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2)", - job_id, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - if !in_workspace { - return Err(Error::NotFound(format!("job {job_id} not found"))); - } + // Also the workspace check for the `v2_job_status` write below, which is keyed by job id + // alone and would otherwise take a write aimed at another workspace's job. + require_resume_mint_authority(&db, &w_id, &authed, job_id).await?; // The approval belongs to the WAC parent, but WM_JOB_ID is the child job when // this is called from inside a task() rather than a step(). Resolve up so the @@ -6323,12 +6310,34 @@ pub async fn get_resume_urls_internal( Ok(Json(res)) } -/// Resolve the runnable path of the flow a (possibly step) job belongs to, used +/// A resume signature is full approval authority: `jobs_u/resume|cancel` skip the step's +/// approval_conditions and accept any resume_id. So only a workspace admin, or a job token +/// whose run lineage claims the suspended job ([`unclaimable_run_lineage`]), may mint one, and +/// a scoped token also needs run scope on the flow. The job must be in `w_id`, whose key signs +/// the result. +async fn require_resume_mint_authority( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + job_id: Uuid, +) -> error::Result<()> { + let flow_path = resume_target_flow_path(db, w_id, job_id).await?; + check_scopes(authed, || format!("jobs:run:flows:{}", flow_path))?; + if !unclaimable_run_lineage(db, w_id, vec![job_id], authed) + .await? + .is_empty() + { + return Err(Error::PermissionDenied(format!( + "only job {job_id}'s own run or a workspace admin can sign resume or approval urls for it" + ))); + } + Ok(()) +} + +/// Resolve the runnable path of the flow a (possibly step) job of `w_id` belongs to, used /// to scope-check resume-signature minting against `jobs:run:flows:`. -/// Returns an empty string when the path can't be resolved (e.g. previews or an -/// unknown job); an empty path only matters for path-restricted tokens, which -/// would not be running such a flow. Never hard-fails, so it can't break resume -/// for unscoped tokens (the in-flow `get_resume_urls()` path). +/// Returns an empty string when the path can't be resolved (e.g. previews); an empty +/// path only matters for path-restricted tokens, which would not be running such a flow. async fn resume_target_flow_path(db: &DB, w_id: &str, job_id: Uuid) -> error::Result { let job = sqlx::query!( r#"SELECT kind::text as "kind!", parent_job, runnable_path @@ -6337,10 +6346,8 @@ async fn resume_target_flow_path(db: &DB, w_id: &str, job_id: Uuid) -> error::Re w_id ) .fetch_optional(db) - .await?; - let Some(job) = job else { - return Ok(String::new()); - }; + .await? + .ok_or_else(|| Error::NotFound(format!("job {job_id} not found")))?; // All flow kinds: the job itself is the flow whose path scopes the resume. if matches!( job.kind.as_str(), @@ -6499,6 +6506,8 @@ struct BatchReRunQueryReturnType { scheduled_for: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] schema: Option, + #[serde(skip)] + created_by: String, } async fn batch_rerun_compute_js_expression( @@ -6548,7 +6557,7 @@ fn batch_rerun_jobs_inner( BatchReRunQueryReturnType, r#"WITH norm AS ( SELECT - j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, + j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, j.created_by, -- Project effective kind for dispatch: pass script/flow through; -- for singlestepflow, read the wrapped runnable's type from -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow'). @@ -6587,6 +6596,7 @@ fn batch_rerun_jobs_inner( COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS "script_hash!: _", COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS "scheduled_for!: _", n.args AS input, + n.created_by, -- Pinned schema for script/flow; latest-by-path fallback for -- singlestepflow so input_transforms still resolve at rerun time. COALESCE( @@ -6643,6 +6653,10 @@ async fn batch_rerun_handle_job( w_id: &String, body: &BatchReRunJobsBodyArgs, ) -> error::Result { + // The source jobs are selected on the root pool, and a rerun copies their args into a + // job the caller owns, so each one must pass the same read gate as `jobs_u/get`. + require_job_read_access(db, user_db, authed, w_id, &job.id, &job.created_by, None).await?; + let options = if matches!(job.kind, JobKind::Script) { &body.script_options_by_path } else { @@ -6941,7 +6955,8 @@ async fn resolve_nested_restart( parent_step_id: &str, parent_branch_or_iteration_n: Option, nested_path: Vec, - parent_flow_version: Option, + // (flow path of the restarted job, version to restart on) + parent_flow_version: Option<(&str, i64)>, ) -> error::Result<( Option, Option>, @@ -6969,8 +6984,8 @@ async fn resolve_nested_restart( ) })?; - let flow_data = if let Some(version) = parent_flow_version { - cache::flow::fetch_version(db, version).await? + let flow_data = if let Some((flow_path, version)) = parent_flow_version { + windmill_queue::fetch_restart_flow_version(db, workspace_id, flow_path, version).await? } else { cache::job::fetch_flow(db, &row.job_kind, row.runnable_id) .or_else(|_| cache::job::fetch_preview_flow(db, &parent_job_id, row.raw_flow)) @@ -7147,6 +7162,8 @@ pub async fn restart_flow( .script_path .with_context(|| "No flow path set for completed flow job")?; check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let ehm = HashMap::new(); let push_args = completed_job @@ -7164,7 +7181,7 @@ pub async fn restart_flow( &step_id, branch_or_iteration_n, nested_path, - flow_version, + flow_version.map(|v| (flow_path.as_str(), v)), ) .await?; @@ -7345,6 +7362,13 @@ pub async fn run_workflow_as_code( let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?; check_scopes(&authed, || format!("jobs:run"))?; + // The task becomes a child of `job_id`, runs its code and writes into its flow status, so + // only that job itself (the SDK's `task` wrapper, on its `WM_TOKEN`) or an admin may push it. + if authed.job_id != Some(job_id) && !authed.is_admin { + return Err(error::Error::PermissionDenied(format!( + "only job {job_id}'s own WM_TOKEN can run its workflow tasks" + ))); + } if !is_valid_entrypoint_name(&entrypoint) { return Err(error::Error::BadRequest(format!( @@ -7761,6 +7785,8 @@ pub async fn run_wait_result_job_by_path_get( let tag = run_query.tag.clone().or(tag); let push_args = PushArgs { args: &args.args, extra: args.extra }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_authed.as_ref() { @@ -7907,6 +7933,8 @@ pub async fn run_wait_result_script_by_path_internal( let tag = run_query.tag.clone().or(tag); let push_args = PushArgs { args: &args.args, extra: args.extra }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_of.as_ref() { @@ -8021,6 +8049,8 @@ pub async fn run_wait_result_script_by_hash( let tag = run_query.tag.clone().or(tag); let push_args = PushArgs { args: &args.args, extra: args.extra }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( @@ -9625,6 +9655,13 @@ async fn run_preview_flow_job( // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &raw_flow.path)?; + // Restarting copies the source runs' step results into the new run, and the queue resolves + // them with the service pool, so every run the request names must be readable as the caller. + let mut level = raw_flow.restarted_from.as_ref(); + while let Some(r) = level { + require_job_update_read_access(&db, &user_db, &authed, &w_id, &r.flow_job_id, None).await?; + level = r.nested.as_deref(); + } let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); @@ -10040,6 +10077,8 @@ pub async fn run_job_by_hash_inner( let push_args = PushArgs { args: &args.args, extra: args.extra }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 156c6ca898..556163ce1c 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -1279,6 +1279,15 @@ pub async fn run_server( }); server.await?; + let forks = &windmill_api_workspaces::workspaces::BACKGROUND_FORKS; + forks.close(); + if !forks.is_empty() { + tracing::info!( + "server off. waiting for {} fork(s) being created", + forks.len() + ); + forks.wait().await; + } #[cfg(feature = "agent_worker_server")] for (i, bg_processor) in agent_workers_bg_processor.into_iter().enumerate() { tracing::info!("server off. shutting down agent worker bg processor {i}"); diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 271e9497fe..fe3dd75ecd 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -16,23 +16,55 @@ use axum::{ routing::get, Json, Router, }; +use quick_cache::{sync::Cache, Weighter}; use serde::{Deserialize, Serialize}; -use windmill_common::error::JsonResult; +use windmill_common::{db::UserDB, error::JsonResult}; +use windmill_store::var_resource_cache::auth_identity; -use crate::db::{ApiAuthed, DB}; +use crate::db::ApiAuthed; // Per-table row cap is inlined into the SQL as `LIMIT 5000`. // With 6 tables, the absolute ceiling is ~30k paths pre-dedup. /// Final cap applied after dedup/sort. const MAX_PATHS: usize = 20_000; -/// TTL for the per-workspace path list cache. +/// TTL for the path list cache. const CACHE_TTL: Duration = Duration::from_secs(60); -/// Workspace-wide path list cache keyed by workspace_id only. -/// One entry per workspace shared across all users — autocomplete is a -/// navigation hint, not an access gate. Saves memory and warms faster. -static PATHS_CACHE: LazyLock>, Instant)>> = - LazyLock::new(|| quick_cache::sync::Cache::new(500)); +/// Total paths held across all cache entries. +const CACHE_MAX_PATHS: u64 = 2_000_000; + +type CacheKey = (String, String); +type CacheValue = (Arc>, Instant); + +#[derive(Clone)] +struct PathCountWeighter; + +impl Weighter for PathCountWeighter { + fn weight(&self, _key: &CacheKey, (paths, _): &CacheValue) -> u64 { + (paths.len() as u64).max(1) + } +} + +/// Keyed by (workspace_id, [`auth_identity`]): the list is read under the caller's RLS, +/// so an entry must only ever be served back to the same authorization context. +/// Weighted by path count because the key space grows with callers, not workspaces. +/// Single-sharded: quick_cache splits the weight budget across shards and refuses an +/// entry heavier than one shard's share, which would drop the largest workspaces. +static PATHS_CACHE: LazyLock> = + LazyLock::new(|| { + let options = quick_cache::OptionsBuilder::new() + .shards(1) + .estimated_items_capacity(1000) + .weight_capacity(CACHE_MAX_PATHS) + .build() + .expect("every cache option is set"); + Cache::with_options( + options, + PathCountWeighter, + Default::default(), + Default::default(), + ) + }); pub fn workspaced_service() -> Router { Router::new().route("/list_paths", get(list_paths)) @@ -53,20 +85,22 @@ struct ListPathsQuery { } async fn list_paths( - _authed: ApiAuthed, - Extension(db): Extension, + authed: ApiAuthed, + Extension(user_db): Extension, Path(w_id): Path, Query(ListPathsQuery { force }): Query, ) -> JsonResult { + let cache_key = (w_id, auth_identity(&authed)); if !force { - if let Some((cached, cached_at)) = PATHS_CACHE.get(&w_id) { + if let Some((cached, cached_at)) = PATHS_CACHE.get(&cache_key) { if cached_at.elapsed() < CACHE_TTL { return Ok(Json(ListPathsResponse { paths: cached })); } - PATHS_CACHE.remove(&w_id); + PATHS_CACHE.remove(&cache_key); } } + let mut tx = user_db.begin(&authed).await?; let mut paths: Vec = sqlx::query_scalar!( r#" SELECT path AS "path!" FROM ( @@ -83,16 +117,17 @@ async fn list_paths( (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000) ) t "#, - &w_id, + &cache_key.0, ) - .fetch_all(&db) + .fetch_all(&mut *tx) .await?; + tx.commit().await?; paths.sort_unstable(); paths.truncate(MAX_PATHS); let paths = Arc::new(paths); - PATHS_CACHE.insert(w_id, (paths.clone(), Instant::now())); + PATHS_CACHE.insert(cache_key, (paths.clone(), Instant::now())); Ok(Json(ListPathsResponse { paths })) } diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 22645a2ac4..66dc4da9df 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -51,30 +51,17 @@ const TWO_HUNDRED: &str = "200.html"; /// Check if the original path requires cross-origin isolation headers. /// /// CANONICAL COEP RATIONALE (the dev-server mirror in `frontend/vite.config.js` -/// and the navigation guards in `frontend/src/routes/(root)/(logged)/+layout.svelte` -/// point here): the headers are needed for SharedArrayBuffer and TypeScript -/// workers (raw app editor at `/apps_raw/edit|add`, in-browser bundler at -/// `/ui_builder/`). The raw app *viewer* (`/apps_raw/get/`) must NOT get them: -/// COEP `require-corp` blocks the viewed app's cross-origin subresources -/// (external images, embeds) that lack CORP — and since headers stick to the -/// document, apps would break on a page reload while working when reached via -/// client-side navigation. -/// -/// 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. +/// points here). Only public apps (`/public/` and custom paths `/a/`) ever get +/// them, and only when they 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 COEP `require-corp` blocks subresources without +/// CORP (external image URLs, embeds), and since headers stick to the document, +/// isolating an SPA route leaks into every page reached from it client-side. #[cfg(feature = "static_frontend")] fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { - // no trailing slash on edit/add: matches the +layout.svelte guards - original_path.starts_with("/apps_raw/edit") - || original_path.starts_with("/apps_raw/add") - || original_path.starts_with("/ui_builder/") - || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) - && query_has_flag(query, "wm_coep")) + (original_path.starts_with("/public/") || original_path.starts_with("/a/")) + && query_has_flag(query, "wm_coep") } /// Returns true if `query` contains the given flag key (with or without a @@ -98,8 +85,6 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response< .header(header::CONTENT_TYPE, mime.as_ref()) .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); - // 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, query) { res = res .header("Cross-Origin-Opener-Policy", "same-origin") @@ -167,12 +152,13 @@ mod tests { #[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("/apps_raw/add", None)); - assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); - - // the raw app viewer must NOT be isolated + // the raw app editor, its UI builder frames and the viewer are never isolated + assert!(!needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(!needs_cross_origin_isolation("/apps_raw/add", None)); + assert!(!needs_cross_origin_isolation( + "/ui_builder/index.html", + None + )); assert!(!needs_cross_origin_isolation( "/apps_raw/get/u/foo/bar", None diff --git a/backend/windmill-api/src/tracing_init.rs b/backend/windmill-api/src/tracing_init.rs index d99a7e5907..2fb668a39c 100644 --- a/backend/windmill-api/src/tracing_init.rs +++ b/backend/windmill-api/src/tracing_init.rs @@ -48,7 +48,9 @@ impl OnResponse for MyOnResponse { let status = response.status().as_u16(); if response.status().is_success() || response.status().is_redirection() { tracing::info!(latency = latency, status = status, "response") - } else if response.status().as_u16() == 404 { + } else if status == 404 || status == 409 { + // A refused turn is as expected as a miss: the flow chat takes the turn that + // refused it and sends its message after it. tracing::warn!(latency = latency, status = status, "response") } else { tracing::error!(latency = latency, status = status, "response") diff --git a/backend/windmill-api/src/workspace_dependencies.rs b/backend/windmill-api/src/workspace_dependencies.rs index 194cc1f0d5..170006f44a 100644 --- a/backend/windmill-api/src/workspace_dependencies.rs +++ b/backend/windmill-api/src/workspace_dependencies.rs @@ -34,13 +34,20 @@ async fn create( authed: ApiAuthed, // Extension(user_db): Extension, Extension(db): Extension, + Path(w_id): Path, Json(nwd): Json, ) -> error::Result<(StatusCode, String)> { - tracing::info!(workspace_id = %nwd.workspace_id, name = ?nwd.name, language = ?nwd.language, "create workspace dependencies"); + tracing::info!(workspace_id = %w_id, name = ?nwd.name, language = ?nwd.language, "create workspace dependencies"); require_admin(authed.is_admin, &authed.username)?; + // `require_admin` vouches for the path workspace only; the body must not target another. + if nwd.workspace_id != w_id { + return Err(error::Error::BadRequest(format!( + "workspace_id `{}` in the request body does not match the workspace `{}` in the path", + nwd.workspace_id, w_id + ))); + } let dep_path = WorkspaceDependencies::to_path(&nwd.name, nwd.language)?; - let w_id = nwd.workspace_id.clone(); let email = authed.email.clone(); let username = authed.username.clone(); diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index e95fadfe66..96fa7ceb63 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -76,6 +76,7 @@ bitflags.workspace = true once_cell.workspace = true phf.workspace = true tokio-postgres.workspace = true +postgres-protocol.workspace = true postgres-native-tls.workspace = true native-tls.workspace = true diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index a9195055ac..f355e733cb 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -6,22 +6,67 @@ * LICENSE-AGPL for a copy of the license. */ -//! The instance's data table role catalog. +//! The instance's data table role catalogs. //! -//! A data table role is a real Postgres login role on the Windmill cluster, named exactly as the -//! user named it, shared by every instance database. Windmill decides who may ask for a role (the -//! per-data-table tenant lists in [`crate::workspaces`]); Postgres decides what the role may then -//! touch. The catalog here is only the first half's vocabulary plus the cluster provisioning. +//! A data table role is a real Postgres login role on one cluster — Windmill's own, or the external +//! instance cluster — named exactly as the user named it, shared by every database Windmill manages +//! on that cluster. Each cluster has its own catalog: a role exists where it was created and nowhere +//! else. Windmill decides who may ask for a role (the per-data-table tenant lists in +//! [`crate::workspaces`]); Postgres decides what the role may then touch. The catalog here is only +//! the first half's vocabulary plus the cluster provisioning. //! //! Entries are keyed by a generated id so a rename moves nothing else: tenants name the id. use std::collections::BTreeMap; +use serde::{Deserialize, Serialize}; + use crate::{ error::{Error, Result}, + workspaces::DataTableCatalogResourceType, DB, }; +/// The cluster a role catalog belongs to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DatatableRoleCluster { + /// Windmill's own Postgres, behind `instance` data tables. + #[default] + Instance, + /// The external instance cluster, behind `external_instance` data tables. + ExternalInstance, +} + +impl DatatableRoleCluster { + pub fn as_str(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::ExternalInstance => "external_instance", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "instance" => Ok(Self::Instance), + "external_instance" => Ok(Self::ExternalInstance), + other => Err(Error::BadRequest(format!( + "Unknown data table role cluster '{other}': expected instance or external_instance" + ))), + } + } + + /// The cluster whose roles a data table on `kind` can use. `None` for a resource-backed one, + /// which is never under roles. + pub fn of(kind: DataTableCatalogResourceType) -> Option { + match kind { + DataTableCatalogResourceType::Instance => Some(Self::Instance), + DataTableCatalogResourceType::ExternalInstance => Some(Self::ExternalInstance), + DataTableCatalogResourceType::Postgresql => None, + } + } +} + /// The connection every data table resolved to before roles existed (`custom_instance_user`). It /// owns every pre-existing object, so it is a reserved name rather than a catalog entry: never /// created, renamed or dropped. @@ -164,28 +209,42 @@ pub async fn lock_instance_databases_governance<'a>( /// need the names — but callers MUST NOT let `pwd` reach a response, a log line, an audit record /// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is /// hand-written to redact it for the same reason. -pub async fn read_role_catalog(db: &DB) -> Result { - crate::datatable_roles_oss::read_role_catalog(db).await +pub async fn read_role_catalog( + db: &DB, + cluster: DatatableRoleCluster, +) -> Result { + crate::datatable_roles_oss::read_role_catalog(db, cluster).await } /// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one /// [`lock_role_catalog`] is protecting. Same disclosure contract. pub async fn read_role_catalog_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, ) -> Result { - crate::datatable_roles_oss::read_role_catalog_tx(tx).await + crate::datatable_roles_oss::read_role_catalog_tx(tx, cluster).await } -/// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes. +/// The cluster a role belongs to, or `None` if no role has this id. +pub async fn role_cluster( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + id: &str, +) -> Result> { + crate::datatable_roles_oss::role_cluster(tx, id).await +} + +/// Record a role, in the caller's transaction. On Windmill's own cluster that commits it with the +/// `CREATE ROLE` it describes; on the external cluster the role already exists by then. /// /// Authorization: writes a generated Postgres credential. Callers MUST restrict this to superadmin /// paths and MUST hold [`lock_role_catalog`] on `tx`. pub async fn insert_role_catalog_entry( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: &str, + cluster: DatatableRoleCluster, role: &InstanceDatatableRole, ) -> Result<()> { - crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await + crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, cluster, role).await } /// Update a role's recorded name, login flag and password. Same contract as @@ -215,7 +274,7 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res .find(|(_, role)| role.name == name) .ok_or_else(|| { Error::NotFound(format!( - "'{name}' is not a data table role of this instance. Defined roles: {}.", + "'{name}' is not a data table role of this database's cluster. Defined roles: {}.", catalog .values() .map(|r| r.name.as_str()) @@ -231,70 +290,90 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res Ok(entry.0.as_str()) } -/// Every instance database the registry knows about. Role provisioning has to reach all of them: -/// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters. +/// Every database Windmill manages on `cluster`. Role provisioning has to reach all of them: a role +/// that cannot `CONNECT` to a database is refused by Postgres before any grant matters. /// -/// Authorization: checks nothing, and names every instance database across all workspaces. Callers +/// Authorization: checks nothing, and names every managed database across all workspaces. Callers /// MUST be superadmin-gated or keep the names server-side; never return them to a workspace caller. -pub async fn registered_instance_databases(db: &DB) -> Result> { - crate::datatable_roles_oss::registered_instance_databases(db).await +pub async fn registered_instance_databases( + db: &DB, + cluster: DatatableRoleCluster, +) -> Result> { + crate::datatable_roles_oss::registered_instance_databases(db, cluster).await } -/// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at -/// database creation, and lazily whenever an instance data table is administered, so a database -/// provisioned before a role existed is repaired rather than left silently unreachable. +/// `CONNECT` on `dbname` for every enabled role of `cluster`, and none for `PUBLIC`. Run at role +/// creation, at database creation, and lazily whenever a managed data table is administered, so a +/// database provisioned before a role existed is repaired rather than left silently unreachable. /// /// Authorization: rewrites a database's ACL with the server's own credentials and checks nothing. /// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the /// workspace governing a data table on it. -pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> { - crate::datatable_roles_oss::converge_connect_grants(db, dbname).await +pub async fn converge_connect_grants( + db: &DB, + cluster: DatatableRoleCluster, + dbname: &str, +) -> Result<()> { + crate::datatable_roles_oss::converge_connect_grants(db, cluster, dbname).await } -/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract. +/// As [`converge_connect_grants`], with the catalog of `cluster` the caller already read. Same +/// contract. pub async fn converge_connect_grants_with( db: &DB, + cluster: DatatableRoleCluster, dbname: &str, catalog: &DatatableRoleCatalog, ) -> Result<()> { - crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await + crate::datatable_roles_oss::converge_connect_grants_with(db, cluster, dbname, catalog).await } -/// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user`, and `CONNECT` on -/// every registered database. No privileges beyond that — an admin grants them through SQL or the -/// ACL editor. +/// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user` on `cluster`. No +/// privileges beyond that — an admin grants them through SQL or the ACL editor. +/// +/// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external +/// cluster is another server: the role is created there before `tx` commits, and callers MUST drop +/// it again ([`drop_datatable_role`]) if `tx` then fails to commit. /// /// Authorization: creates a cluster-wide Postgres login. Callers MUST restrict this to superadmin -/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. -pub async fn create_instance_role( +/// paths, and MUST hold [`lock_role_catalog`] on `tx`. +pub async fn create_datatable_role( + db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, name: &str, password: &str, ) -> Result<()> { - crate::datatable_roles_oss::create_instance_role(tx, name, password).await + crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await } /// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin -/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. -pub async fn set_instance_role_login( +/// paths, and MUST hold [`lock_role_catalog`] on `tx`. +pub async fn set_datatable_role_login( + db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, name: &str, enabled: bool, ) -> Result<()> { - crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await + crate::datatable_roles_oss::set_datatable_role_login(db, tx, cluster, name, enabled).await } -/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one. +/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one. On the +/// external cluster the rename lands before `tx` commits, and callers MUST rename it back if `tx` +/// then fails to commit. /// /// Authorization: renames a cluster-wide Postgres login. Callers MUST restrict this to superadmin -/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. -pub async fn rename_instance_role( +/// paths, and MUST hold [`lock_role_catalog`] on `tx`. +pub async fn rename_datatable_role( + db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, from: &str, to: &str, password: &str, ) -> Result<()> { - crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await + crate::datatable_roles_oss::rename_datatable_role(db, tx, cluster, from, to, password).await } /// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the @@ -302,8 +381,9 @@ pub async fn rename_instance_role( /// registry. An unreachable database aborts the whole delete: dropping the role while one database /// still holds objects owned by it leaves those objects owned by a numeric OID nobody can name. /// -/// Each pass runs as the instance's own Postgres user rather than `custom_instance_user`, which -/// owns the databases and can therefore revoke a grant whoever made it. `custom_instance_user` +/// Each pass runs as the cluster's administrator rather than `custom_instance_user`: on Windmill's +/// own cluster the instance's Postgres user, on the external one its configured admin login. Both +/// own the databases and can therefore revoke a grant whoever made it. `custom_instance_user` /// could only undo what it granted itself, so a privilege planted by an operator in psql — the /// ordinary way privileges reach a role — would survive and block the drop. /// @@ -311,16 +391,19 @@ pub async fn rename_instance_role( /// MUST restrict this to superadmin paths, and MUST hold [`lock_role_catalog`] on `tx`. /// /// The per-database passes open their own connections and cannot join `tx`; the lock is what keeps -/// a concurrent mutation out while they run. Only the final `DROP ROLE` is on `tx`, so it commits -/// or rolls back with the catalog write that forgets the role. Those passes commit as they go, so -/// callers MUST have disabled the role in an earlier committed transaction: a failure part-way -/// then leaves a disabled role to retry, not an enabled one already stripped in some databases. -pub async fn drop_instance_role( +/// a concurrent mutation out while they run. On Windmill's own cluster only the final `DROP ROLE` +/// is on `tx`, so it commits or rolls back with the catalog write that forgets the role; on the +/// external cluster it runs there, and tolerates a role already gone so a retry after a failed +/// commit can finish. The passes commit as they go, so callers MUST have disabled the role in an +/// earlier committed transaction: a failure part-way then leaves a disabled role to retry, not an +/// enabled one already stripped in some databases. +pub async fn drop_datatable_role( db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, name: &str, ) -> Result<()> { - crate::datatable_roles_oss::drop_instance_role(db, tx, name).await + crate::datatable_roles_oss::drop_datatable_role(db, tx, cluster, name).await } #[cfg(test)] diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs index 2a3f9dda48..a97a936827 100644 --- a/backend/windmill-common/src/datatable_roles_oss.rs +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -24,12 +24,12 @@ pub fn datatable_roles_unavailable() -> Error { #[cfg(all(feature = "private", feature = "enterprise"))] pub(crate) use crate::datatable_roles_ee::{ can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants, - converge_connect_grants_with, create_instance_role, delete_role_catalog_entry, - drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access, + converge_connect_grants_with, create_datatable_role, delete_role_catalog_entry, + drop_datatable_role, ensure_can_use_datatable_role, ensure_datatable_admin_access, ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere, insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx, - registered_instance_databases, rename_instance_role, resolve_datatable_role_connection, - set_instance_role_login, update_role_catalog_entry, + registered_instance_databases, rename_datatable_role, resolve_datatable_role_connection, + role_cluster, set_datatable_role_login, update_role_catalog_entry, }; #[cfg(not(all(feature = "private", feature = "enterprise")))] @@ -39,7 +39,7 @@ pub(crate) use ce::*; mod ce { use super::datatable_roles_unavailable as unavailable; use crate::{ - datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole}, + datatable_roles::{DatatableRoleCatalog, DatatableRoleCluster, InstanceDatatableRole}, db::AuthedRef, error::Result, workspaces::{ @@ -50,17 +50,31 @@ mod ce { type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>; - pub(crate) async fn read_role_catalog(_db: &DB) -> Result { + pub(crate) async fn read_role_catalog( + _db: &DB, + _cluster: DatatableRoleCluster, + ) -> Result { Err(unavailable()) } - pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result { + pub(crate) async fn read_role_catalog_tx( + _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, + ) -> Result { + Err(unavailable()) + } + + pub(crate) async fn role_cluster( + _tx: &mut Tx<'_>, + _id: &str, + ) -> Result> { Err(unavailable()) } pub(crate) async fn insert_role_catalog_entry( _tx: &mut Tx<'_>, _id: &str, + _cluster: DatatableRoleCluster, _role: &InstanceDatatableRole, ) -> Result<()> { Err(unavailable()) @@ -78,43 +92,57 @@ mod ce { Err(unavailable()) } - pub(crate) async fn registered_instance_databases(_db: &DB) -> Result> { + pub(crate) async fn registered_instance_databases( + _db: &DB, + _cluster: DatatableRoleCluster, + ) -> Result> { Err(unavailable()) } - /// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT` - /// grants it was created with, `PUBLIC`'s included, as it did before roles existed. - pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> { + /// Nothing to converge: with no roles to admit, a managed database keeps the `CONNECT` grants + /// it was created with, as it did before roles existed. + pub(crate) async fn converge_connect_grants( + _db: &DB, + _cluster: DatatableRoleCluster, + _dbname: &str, + ) -> Result<()> { Ok(()) } /// As [`converge_connect_grants`]. pub(crate) async fn converge_connect_grants_with( _db: &DB, + _cluster: DatatableRoleCluster, _dbname: &str, _catalog: &DatatableRoleCatalog, ) -> Result<()> { Ok(()) } - pub(crate) async fn create_instance_role( + pub(crate) async fn create_datatable_role( + _db: &DB, _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, _name: &str, _password: &str, ) -> Result<()> { Err(unavailable()) } - pub(crate) async fn set_instance_role_login( + pub(crate) async fn set_datatable_role_login( + _db: &DB, _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, _name: &str, _enabled: bool, ) -> Result<()> { Err(unavailable()) } - pub(crate) async fn rename_instance_role( + pub(crate) async fn rename_datatable_role( + _db: &DB, _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, _from: &str, _to: &str, _password: &str, @@ -122,12 +150,18 @@ mod ce { Err(unavailable()) } - pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> { + pub(crate) async fn drop_datatable_role( + _db: &DB, + _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, + _name: &str, + ) -> Result<()> { Err(unavailable()) } pub(crate) async fn ensure_instance_db_grant_options_unchecked( _db: &DB, + _cluster: DatatableRoleCluster, _dbname: &str, ) -> Result<()> { Err(unavailable()) diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index da9f5f3df2..c06defaab5 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -303,7 +303,11 @@ impl IntoResponse for Error { let e = &self; - if matches!(status, axum::http::StatusCode::NOT_FOUND) { + // A refused turn is as expected as a miss: the chat follows the turn that refused it. + if matches!( + status, + axum::http::StatusCode::NOT_FOUND | axum::http::StatusCode::CONFLICT + ) { tracing::warn!(message = e.to_string()); } else { tracing::error!(message = e.to_string(), error = ?e); diff --git a/backend/windmill-common/src/external_instance_pg.rs b/backend/windmill-common/src/external_instance_pg.rs new file mode 100644 index 0000000000..c0b6203e40 --- /dev/null +++ b/backend/windmill-common/src/external_instance_pg.rs @@ -0,0 +1,456 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! The external Postgres cluster behind `external_instance` data tables and Ducklake catalogs. +//! +//! Windmill administers that cluster itself, logged in as the user in +//! [`EXTERNAL_INSTANCE_PG_SETTING`]. It creates `custom_instance_user` and +//! `custom_instance_replication_user` there, with passwords it generates and keeps in +//! [`EXTERNAL_INSTANCE_PG_STATE_SETTING`]. They share their names with the roles on Windmill's own +//! cluster, but they are different roles with different passwords. +//! +//! The cluster may hold data Windmill did not create. Two Windmill instances sharing one is not +//! supported: each would keep resetting the passwords the other depends on. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{Error, Result}, + global_settings::{EXTERNAL_INSTANCE_PG_SETTING, EXTERNAL_INSTANCE_PG_STATE_SETTING}, + instance_config::{CustomInstanceDb, ExternalInstancePg}, + DB, +}; + +/// What Windmill keeps about the external cluster. Server-managed and hidden: never part of the +/// instance config, never readable by an agent worker. No `Debug`: it carries live passwords. +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct ExternalInstancePgState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_pwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replication_pwd: Option, + /// The databases Windmill created on the cluster. It only ever drops one of these. + #[serde(default)] + pub databases: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_setup: Option, + /// The cluster ([`external_instance_pg_address`]) the last successful setup converged. Databases + /// are only created on a cluster setup succeeded on: the passwords above exist as soon as setup + /// first runs, whether or not the cluster accepted them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub set_up_for: Option, +} + +/// What identifies the cluster a configuration points at. Other fields (admin login, sslmode) can +/// change without it becoming another cluster. +pub fn external_instance_pg_address(config: &ExternalInstancePg) -> String { + format!( + "{}:{}", + config.host.trim().to_lowercase(), + config.port.unwrap_or(5432) + ) +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ExternalInstancePgSetupReport { + /// No step failed. Warnings leave it true. + pub success: bool, + pub finished_at: chrono::DateTime, + pub steps: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ExternalInstancePgSetupStep { + pub name: String, + pub status: SetupStepStatus, + pub message: String, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SetupStepStatus { + Ok, + Warning, + Error, +} + +/// The status the settings page shows without running anything. +#[derive(Serialize, Debug)] +pub struct ExternalInstancePgStatus { + pub configured: bool, + pub database_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_setup: Option, +} + +/// Authorization: returns the cluster's admin password and checks nothing. Callers MUST be +/// superadmin or an internal server path. +pub(crate) async fn read_external_instance_pg_config<'c>( + executor: impl sqlx::PgExecutor<'c>, +) -> Result> { + let value = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + EXTERNAL_INSTANCE_PG_SETTING + ) + .fetch_optional(executor) + .await?; + value + .map(|v| { + serde_json::from_value(v).map_err(|e| { + Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_SETTING}: {e}")) + }) + }) + .transpose() +} + +/// Authorization: returns the passwords Windmill generated on the cluster and checks nothing. +/// Callers MUST be superadmin or an internal server path. +pub(crate) async fn read_external_instance_pg_state<'c>( + executor: impl sqlx::PgExecutor<'c>, +) -> Result { + let value = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + EXTERNAL_INSTANCE_PG_STATE_SETTING + ) + .fetch_optional(executor) + .await?; + match value { + None => Ok(ExternalInstancePgState::default()), + Some(v) => serde_json::from_value(v).map_err(|e| { + Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_STATE_SETTING}: {e}")) + }), + } +} + +/// Authorization: reads the hidden cluster state and checks nothing. Callers MUST be superadmin. +pub async fn external_instance_pg_status(db: &DB) -> Result { + let configured = read_external_instance_pg_config(db).await?.is_some(); + let state = read_external_instance_pg_state(db).await?; + Ok(ExternalInstancePgStatus { + configured, + database_count: state.databases.len(), + last_setup: state.last_setup, + }) +} + +/// The databases Windmill created on the external cluster, without the passwords kept beside them. +/// +/// Authorization: names every database across all workspaces, and the workspace each fork copy is +/// reserved for, and checks nothing. Callers MUST be superadmin or an internal authorization or +/// lifecycle path that does not return the names to a workspace caller. +pub async fn external_instance_databases(db: &DB) -> Result> { + Ok(read_external_instance_pg_state(db).await?.databases) +} + +/// The workspaces whose data tables or Ducklake catalogs name each database on the external cluster, +/// and the forks whose Ducklake metadata schemas there are still waiting to be dropped: those rows +/// outlive a settings change, and cleanup cannot drop a schema in a database that is gone. A row +/// whose schema is already dropped only waits on object storage, which needs no database. +/// +/// Authorization: reads every workspace's settings and checks nothing. Callers MUST be superadmin +/// or an internal lifecycle path. +pub async fn external_instance_database_usages<'c>( + db: impl sqlx::PgExecutor<'c>, +) -> Result>> { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT ws.workspace_id, entry->'database'->>'resource_path' + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' + ELSE '{}'::jsonb END + ) AS dt(k, entry) + WHERE entry->'database'->>'resource_type' = 'external_instance' + AND entry->'database'->>'resource_path' IS NOT NULL + UNION ALL + SELECT ws.workspace_id, entry->'catalog'->>'resource_path' + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' + THEN ws.ducklake->'ducklakes' + ELSE '{}'::jsonb END + ) AS dl(k, entry) + WHERE entry->'catalog'->>'resource_type' = 'external_instance' + AND entry->'catalog'->>'resource_path' IS NOT NULL + UNION ALL + SELECT workspace_id, substring(catalog FROM length('external_instance:') + 1) + FROM fork_ducklake_namespace + WHERE catalog LIKE 'external\\_instance:%' AND NOT schema_dropped", + ) + .fetch_all(db) + .await?; + let mut usages: BTreeMap> = BTreeMap::new(); + for (workspace_id, dbname) in rows { + usages.entry(dbname).or_default().insert(workspace_id); + } + Ok(usages) +} + +/// Refuse to unset the cluster while Windmill still has databases or data table roles on it, or a +/// workspace still points at one: every data table there would stop resolving, and every role +/// would be a login nothing can drop any more. Allowed on every edition, so a +/// downgraded instance can still clear a setting it no longer uses. +async fn ensure_external_instance_pg_removable(conn: &mut sqlx::PgConnection) -> Result<()> { + ensure_external_instance_pg_unused(conn, &format!("removing {EXTERNAL_INSTANCE_PG_SETTING}")).await +} + +/// Refuse while Windmill has databases or data table roles on the cluster, or a workspace points +/// at one of its databases. `before` finishes the sentence saying what to do first. +async fn ensure_external_instance_pg_unused( + conn: &mut sqlx::PgConnection, + before: &str, +) -> Result<()> { + let state = read_external_instance_pg_state(&mut *conn).await?; + let usages = external_instance_database_usages(&mut *conn).await?; + let roles = sqlx::query_scalar::<_, String>( + "SELECT name FROM datatable_role WHERE cluster = 'external_instance' ORDER BY name", + ) + .fetch_all(&mut *conn) + .await?; + if state.databases.is_empty() && usages.is_empty() && roles.is_empty() { + return Ok(()); + } + let mut held = vec![]; + if !(state.databases.is_empty() && usages.is_empty()) { + let names = state + .databases + .keys() + .chain(usages.keys()) + .collect::>() + .into_iter() + .cloned() + .collect::>() + .join(", "); + held.push(format!("databases in use ({names})")); + } + if !roles.is_empty() { + held.push(format!("data table roles ({})", roles.join(", "))); + } + Err(Error::BadRequest(format!( + "The external instance cluster still holds {}. Drop them and repoint the data tables and \ + Ducklake catalogs using them before {before}.", + held.join(" and ") + ))) +} + +/// Refuse a workspace setting that newly names an `external_instance` database on an edition +/// without them. +pub fn ensure_external_instance_available() -> Result<()> { + crate::external_instance_pg_oss::ensure_external_instance_available() +} + +/// The connection an `external_instance` database resolves to: `custom_instance_user`, or the +/// replication user, on the external cluster. +/// +/// Authorization: returns live credentials and checks nothing. Callers MUST have authorized access +/// to the data table that names `dbname`. +pub async fn external_instance_connection_unchecked( + db: &DB, + dbname: &str, + replication: bool, +) -> Result { + crate::external_instance_pg_oss::external_instance_connection_unchecked(db, dbname, replication) + .await +} + +/// Create `dbname` on the external cluster and register it. Refuses a name already taken there, +/// whoever took it. +/// +/// Runs on `tx`, which it takes [`lock_external_instance_pg_state`] on: the registration lands when +/// the caller commits. Take that lock before any database governance lock, as saves do. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or be cloning a data table they may +/// fork into a `wm_fork_` database. +pub async fn create_external_instance_database_unchecked( + db: &DB, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + dbname: &str, + tag: &str, + for_workspace: Option<&str>, +) -> Result<()> { + crate::external_instance_pg_oss::create_external_instance_database_unchecked( + db, + tx, + dbname, + tag, + for_workspace, + ) + .await +} + +/// Drop `dbname` from the external cluster: only a database Windmill registered creating, and still +/// carries the mark it set there. Refused while anything uses it +/// ([`crate::workspaces::managed_database_uses`]), except the `exempt` data table entry: the fork +/// copy being cleaned up. +/// +/// Runs on `tx`, like [`create_external_instance_database_unchecked`]: the database is gone at +/// once, its registry entry when the caller commits. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or be deleting the fork that owns +/// this `wm_fork_` database. +pub async fn drop_external_instance_database_unchecked( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + dbname: &str, + exempt: Option<(&str, &str)>, +) -> Result<()> { + crate::external_instance_pg_oss::drop_external_instance_database_unchecked(tx, dbname, exempt) + .await +} + +/// Serializes everything that changes which databases exist on the external cluster, or which data +/// tables name them: setup, creates, drops, and data table saves. Held until `tx` ends. +pub async fn lock_external_instance_pg_state( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))") + .bind(EXTERNAL_INSTANCE_PG_STATE_SETTING) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Refuse a data table naming `dbname` unless Windmill created it on the external cluster. Takes +/// the lock drops take, so none can remove the database before `tx`, which saves the data table, +/// commits. +/// +/// Authorization: its refusal says whether Windmill created a database of that name, which is +/// instance-wide knowledge. Callers MUST have authorized the caller as superadmin first. +pub async fn ensure_external_instance_database_registered( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + dbname: &str, +) -> Result<()> { + lock_external_instance_pg_state(tx).await?; + if read_external_instance_pg_state(&mut **tx) + .await? + .databases + .contains_key(dbname) + { + return Ok(()); + } + Err(Error::BadRequest(format!( + "Windmill did not create a database named '{dbname}' on the external instance cluster. \ + Create it from the instance settings first." + ))) +} + +/// Write [`EXTERNAL_INSTANCE_PG_SETTING`]: `None`, null or an empty string unsets it. Every writer +/// of global settings goes through this for that key — the per-key and bulk endpoints as well as +/// the declarative sync — instead of writing the row itself. +/// +/// The checks and the write share one transaction holding [`lock_external_instance_pg_state`]. A +/// check taken outside it could pass while a database create still reads the old cluster, which +/// would then register a database there after the setting names another one. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or the declarative instance config +/// sync, which applies what the operator deployed. +pub async fn write_external_instance_pg_setting( + db: &DB, + value: Option<&serde_json::Value>, +) -> Result<()> { + let value = match value { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) if s.trim().is_empty() => None, + Some(value) => Some(value), + }; + let mut tx = db.begin().await?; + lock_external_instance_pg_state(&mut tx).await?; + // Every check runs on this transaction's own connection: it holds the advisory lock, and + // taking a second connection from the pool while other writers queue on that lock is how a + // small pool deadlocks. + match value { + None => { + ensure_external_instance_pg_removable(&mut tx).await?; + sqlx::query("DELETE FROM global_settings WHERE name = $1") + .bind(EXTERNAL_INSTANCE_PG_SETTING) + .execute(&mut *tx) + .await?; + } + Some(value) => { + crate::external_instance_pg_oss::validate_external_instance_pg_setting(value)?; + ensure_external_instance_pg_not_repointed(&mut tx, value).await?; + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now()", + ) + .bind(EXTERNAL_INSTANCE_PG_SETTING) + .bind(value) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + tracing::info!( + "{} global setting {EXTERNAL_INSTANCE_PG_SETTING}", + if value.is_some() { "Set" } else { "Unset" } + ); + Ok(()) +} + +/// [`write_external_instance_pg_setting`] for a settings diff: writes the key if the diff touches +/// it, and takes it out of the diff so the generic apply does not write it again. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or the declarative instance config +/// sync, which applies what the operator deployed. +pub async fn write_external_instance_pg_from_diff( + db: &DB, + diff: &mut crate::instance_config::SettingsDiff, +) -> Result<()> { + if let Some(value) = diff.upserts.remove(EXTERNAL_INSTANCE_PG_SETTING) { + write_external_instance_pg_setting(db, Some(&value)).await?; + } + if let Some(i) = diff + .deletes + .iter() + .position(|k| k == EXTERNAL_INSTANCE_PG_SETTING) + { + diff.deletes.remove(i); + write_external_instance_pg_setting(db, None).await?; + } + Ok(()) +} + +/// Refuse pointing the setting at another host or port while databases or data table roles live on +/// the current one. Data tables name databases, and the role catalog names logins, not clusters, so +/// both would silently resolve to whatever the new cluster holds under the same names. Other fields +/// (admin login, sslmode) may change freely. +async fn ensure_external_instance_pg_not_repointed( + conn: &mut sqlx::PgConnection, + value: &serde_json::Value, +) -> Result<()> { + let Some(current) = read_external_instance_pg_config(&mut *conn).await? else { + return Ok(()); + }; + let Ok(desired) = serde_json::from_value::(value.clone()) else { + return Ok(()); + }; + if external_instance_pg_address(¤t) == external_instance_pg_address(&desired) { + return Ok(()); + } + ensure_external_instance_pg_unused( + conn, + &format!("pointing {EXTERNAL_INSTANCE_PG_SETTING} at another cluster"), + ) + .await +} + +/// Converge the external cluster on the configured login: check what it can do, create or update +/// Windmill's two roles with the stored passwords, and report anything that would get in the way. +/// With `rotate_passwords`, generate new passwords first. Safe to run again; running it again is +/// how a failed rotation is repaired. +/// +/// Authorization: administers the external cluster with its admin credentials and checks nothing. +/// Callers MUST be superadmin. +pub async fn setup_external_instance_pg_unchecked( + db: &DB, + rotate_passwords: bool, +) -> Result { + crate::external_instance_pg_oss::setup_external_instance_pg_unchecked(db, rotate_passwords) + .await +} diff --git a/backend/windmill-common/src/external_instance_pg_oss.rs b/backend/windmill-common/src/external_instance_pg_oss.rs new file mode 100644 index 0000000000..93b6fc63dd --- /dev/null +++ b/backend/windmill-common/src/external_instance_pg_oss.rs @@ -0,0 +1,82 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the external instance cluster comes from: the enterprise implementation, or a refusal. +//! `private` alone is not that edition: community builds carry it. + +use crate::error::Error; + +pub fn external_instance_pg_unavailable() -> Error { + Error::BadRequest( + "External instance databases are a Windmill Enterprise Edition feature".to_string(), + ) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::external_instance_pg_ee::{ + create_external_instance_database_unchecked, drop_external_instance_database_unchecked, + external_instance_connection_unchecked, setup_external_instance_pg_unchecked, + validate_external_instance_pg_setting, +}; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) fn ensure_external_instance_available() -> crate::error::Result<()> { + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod ce { + use super::external_instance_pg_unavailable as unavailable; + use crate::{ + error::Result, external_instance_pg::ExternalInstancePgSetupReport, PgDatabase, DB, + }; + + pub(crate) fn validate_external_instance_pg_setting(_value: &serde_json::Value) -> Result<()> { + Err(unavailable()) + } + + pub(crate) fn ensure_external_instance_available() -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn setup_external_instance_pg_unchecked( + _db: &DB, + _rotate_passwords: bool, + ) -> Result { + Err(unavailable()) + } + + pub(crate) async fn external_instance_connection_unchecked( + _db: &DB, + _dbname: &str, + _replication: bool, + ) -> Result { + Err(unavailable()) + } + + pub(crate) async fn create_external_instance_database_unchecked( + _db: &DB, + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + _dbname: &str, + _tag: &str, + _for_workspace: Option<&str>, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn drop_external_instance_database_unchecked( + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + _dbname: &str, + _exempt: Option<(&str, &str)>, + ) -> Result<()> { + Err(unavailable()) + } +} diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 91bfa4a39f..5ee99d0681 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -69,7 +69,9 @@ pub async fn get_or_create_conversation_with_id( is_test: bool, ) -> Result { if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? { - return same_kind(existing, is_test); + let existing = same_kind(existing, is_test)?; + refuse_running_turn(tx, conversation_id).await?; + return Ok(existing); } // Truncate title to 25 characters max @@ -104,7 +106,74 @@ pub async fn get_or_create_conversation_with_id( "conversation {conversation_id} belongs to another workspace" )) })?; - same_kind(existing, is_test) + let existing = same_kind(existing, is_test)?; + refuse_running_turn(tx, conversation_id).await?; + Ok(existing) +} + +/// The turn a conversation is still answering: its newest user message, while the flow run +/// that message started is still queued or running. +#[derive(Serialize, Debug, Clone, Copy)] +pub struct RunningTurn { + pub job_id: Uuid, + /// `created_seq` of the user message that started the turn. + pub user_seq: i64, +} + +/// One running turn per conversation holds its agent memory; a second run would write the +/// same memory concurrently. Checked under the conversation's row lock, so two runs sent at +/// once cannot both pass: the second waits, then sees the first's message and queued job. +async fn refuse_running_turn( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + conversation_id: Uuid, +) -> Result<()> { + let Some(turn) = running_turns(&mut **tx, &[conversation_id]) + .await? + .remove(&conversation_id) + else { + return Ok(()); + }; + // A JSON body, so a chat client can follow the running turn instead of failing. + Err(crate::error::Error::Generic( + axum::http::StatusCode::CONFLICT, + serde_json::json!({ + "error": "this conversation is still answering a message; wait for it to finish or stop it before sending another", + "running_turn": turn, + }) + .to_string(), + )) +} + +/// The running turn of each of `conversation_ids` that has one. +/// +/// It answers for whatever ids it is given and checks no permission of its own, so the +/// executor must be one the caller is entitled to read those conversations through: a +/// `user_db` transaction under RLS, or a transaction holding ids the caller has already +/// authorized. Handed a raw pool and ids from a request, it would report other users' jobs. +pub async fn running_turns<'e, E: sqlx::PgExecutor<'e>>( + executor: E, + conversation_ids: &[Uuid], +) -> Result> { + let rows = sqlx::query!( + r#"SELECT c.id AS "id!", u.job_id AS "job_id!", u.created_seq AS "user_seq!" + FROM unnest($1::uuid[]) AS c(id) + CROSS JOIN LATERAL ( + SELECT job_id, created_seq + FROM flow_conversation_message + WHERE conversation_id = c.id AND message_type = 'user' + ORDER BY created_seq DESC + LIMIT 1 + ) u + WHERE u.job_id IS NOT NULL + AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)"#, + conversation_ids + ) + .fetch_all(executor) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.id, RunningTurn { job_id: r.job_id, user_seq: r.user_seq })) + .collect()) } /// `memory_id` is the caller's to choose, so a preview run could name a deployed diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 7306c5c59a..9137dc364e 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -57,6 +57,11 @@ pub const SAML_METADATA_SETTING: &str = "saml_metadata"; pub const SMTP_SETTING: &str = "smtp_settings"; pub const TEAMS_SETTING: &str = "teams"; pub const INDEXER_SETTING: &str = "indexer_settings"; +pub const EXTERNAL_INSTANCE_PG_SETTING: &str = "external_instance_pg"; +/// Turns off Windmill's own Postgres as a data table and Ducklake substrate. Absent means on, +/// which is what every instance that predates the setting expects. +pub const INSTANCE_PG_DISABLED_SETTING: &str = "instance_pg_disabled"; +pub const EXTERNAL_INSTANCE_PG_STATE_SETTING: &str = "external_instance_pg_state"; pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result"; pub const UNIQUE_ID_SETTING: &str = "uid"; @@ -448,6 +453,9 @@ pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[ // resolve datatable connections through the dedicated datatable endpoints, never these. "custom_instance_pg_databases", "custom_instance_replication_pwd", + // The external cluster's admin login, and the passwords Windmill generated on it. + EXTERNAL_INSTANCE_PG_SETTING, + EXTERNAL_INSTANCE_PG_STATE_SETTING, ]; /// Whether an agent worker may read the given global setting over HTTP. diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 8c8720c77a..fd790547b6 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -354,6 +354,8 @@ pub struct GlobalSettings { pub ducklake_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] pub custom_instance_pg_databases: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_instance_pg: Option, // Opaque settings (EE-private structs or no clear schema) #[serde(skip_serializing_if = "Option::is_none")] @@ -477,6 +479,8 @@ pub struct SmtpSettings { pub smtp_tls_implicit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub smtp_disable_tls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub smtp_clicktracking_off: Option, } // --------------------------------------------------------------------------- @@ -813,6 +817,9 @@ pub struct CustomInstanceDb { pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, + /// The workspace a member created this fork copy for. Absent when a superadmin created it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, } /// Setup log entries for a custom instance database. @@ -839,6 +846,36 @@ pub struct CustomInstanceDbLogs { pub user_connect: String, } +// --------------------------------------------------------------------------- +// External instance PG cluster +// --------------------------------------------------------------------------- + +/// The external Postgres cluster Windmill manages for `external_instance` data tables and Ducklake +/// catalogs. `user` logs in as the cluster's administrator: it needs `CREATEDB` and `CREATEROLE`. +/// `dbname` is only where that login connects to run cluster-wide statements. +/// +/// Every field defaults rather than being required: this deserializes as part of the whole +/// instance config, and one malformed row must not make every other setting unreadable. The +/// write path and every use reject an incomplete value instead. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct ExternalInstancePg { + #[serde(default)] + pub host: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(default)] + pub user: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dbname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sslmode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_certificate_pem: Option, +} + // --------------------------------------------------------------------------- // Autoscaling (worker config) // --------------------------------------------------------------------------- @@ -975,6 +1012,7 @@ pub const PROTECTED_SETTINGS: &[&str] = &[ "ducklake_settings", "custom_instance_pg_databases", "custom_instance_replication_pwd", + "external_instance_pg_state", "uid", "rsa_keys", "jwt_secret", @@ -1000,6 +1038,8 @@ pub const HIDDEN_SETTINGS: &[&str] = &[ // Server-only (written by setup/refresh via direct SQL), never operator-authored — // hidden so the config machinery can't read, rewrite, or drop it. "custom_instance_replication_pwd", + // Same for the passwords and database registry Windmill keeps for the external cluster. + "external_instance_pg_state", ]; /// Top-level settings whose entire value is sensitive and must be fully redacted in logs. @@ -1011,6 +1051,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ "license_key", "ducklake_user_pg_pwd", "custom_instance_replication_pwd", + "external_instance_pg_state", "pip_index_url", "pip_extra_index_url", "npm_config_registry", @@ -1036,6 +1077,7 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ &["secret_key", "serviceAccountKey", "accessKey"], ), ("custom_instance_pg_databases", &["user_pwd"]), + ("external_instance_pg", &["password"]), ("github_enterprise_app", &["private_key"]), ]; @@ -1377,7 +1419,8 @@ pub async fn sync_global_settings_declarative( crate::global_settings::parse_max_token_expiration_days(desired.get(max_expiration_key)) .map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?; - let diff = diff_global_settings(current, desired, ApplyMode::Replace); + let mut diff = diff_global_settings(current, desired, ApplyMode::Replace); + crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff).await?; apply_settings_diff(db, &diff).await?; Ok(()) @@ -1510,6 +1553,10 @@ pub fn resolve_env_refs(settings: &mut GlobalSettings) -> Result<(), String> { resolve_env_option(&mut pg.user_pwd)?; } + if let Some(pg) = &mut settings.external_instance_pg { + resolve_env_option(&mut pg.password)?; + } + Ok(()) } @@ -2282,6 +2329,20 @@ mod tests { assert_eq!(original.custom_tags, reconstructed.custom_tags); } + #[test] + fn smtp_settings_keep_clicktracking_off() { + let smtp = serde_json::json!({ + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_tls_implicit": false, + "smtp_disable_tls": false, + "smtp_clicktracking_off": true, + }); + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({ "smtp_settings": smtp })).unwrap(); + assert_eq!(settings.to_settings_map()["smtp_settings"], smtp); + } + // ----------------------------------------------------------------------- // Serialization edge cases // ----------------------------------------------------------------------- @@ -2479,39 +2540,33 @@ mod tests { } #[test] - fn custom_instance_replication_pwd_is_isolated_from_config() { - // The replication-role password is server-only: written by setup/refresh via direct - // SQL, never operator-authored. It must stay out of the declarative config surface - // (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop it. - assert!(HIDDEN_SETTINGS.contains(&"custom_instance_replication_pwd")); - assert!(PROTECTED_SETTINGS.contains(&"custom_instance_replication_pwd")); - assert!(SENSITIVE_SETTINGS.contains(&"custom_instance_replication_pwd")); + fn server_generated_db_passwords_are_isolated_from_config() { + // These hold passwords the server generates: written by setup/refresh via direct SQL, + // never operator-authored. They must stay out of the declarative config surface + // (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop them. + for key in [ + "custom_instance_replication_pwd", + "external_instance_pg_state", + ] { + assert!(HIDDEN_SETTINGS.contains(&key), "{key}"); + assert!(PROTECTED_SETTINGS.contains(&key), "{key}"); + assert!(SENSITIVE_SETTINGS.contains(&key), "{key}"); - // A stray desired value (e.g. flattened into `extra`) is ignored, not upserted. - let mut desired = BTreeMap::new(); - desired.insert( - "custom_instance_replication_pwd".to_string(), - serde_json::json!("attacker-set"), - ); - let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge); - assert!( - diff.upserts.is_empty(), - "hidden setting must not be upserted" - ); + // A stray desired value (e.g. flattened into `extra`) is ignored, not upserted. + let mut desired = BTreeMap::new(); + desired.insert(key.to_string(), serde_json::json!("attacker-set")); + let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge); + assert!(diff.upserts.is_empty(), "{key} must not be upserted"); - // A current value is never deleted by a Replace that omits it. - let mut current = BTreeMap::new(); - current.insert( - "custom_instance_replication_pwd".to_string(), - serde_json::json!("live"), - ); - let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace); - assert!( - !diff - .deletes - .contains(&"custom_instance_replication_pwd".to_string()), - "hidden setting must not be deleted" - ); + // A current value is never deleted by a Replace that omits it. + let mut current = BTreeMap::new(); + current.insert(key.to_string(), serde_json::json!("live")); + let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace); + assert!( + !diff.deletes.contains(&key.to_string()), + "{key} must not be deleted" + ); + } } #[test] diff --git a/backend/windmill-common/src/job_provenance.rs b/backend/windmill-common/src/job_provenance.rs new file mode 100644 index 0000000000..1e4130de8c --- /dev/null +++ b/backend/windmill-common/src/job_provenance.rs @@ -0,0 +1,104 @@ +use uuid::Uuid; + +use crate::{db::DB, error::Result, jobs::JobKind}; + +/// Where a job's code comes from, as far as its chain of parents can prove it. +pub struct JobProvenance { + /// True only when the job and every ancestor run code at a path taken from a + /// deployed version, never from the request that created the job. + pub deployed: bool, + pub parent_path: Option, + pub root_id: Uuid, + pub root_path: Option, + pub root_kind: JobKind, + pub root_trigger_kind: Option, +} + +struct LineageJob { + id: Uuid, + parent_job: Option, + kind: JobKind, + runnable_path: Option, + trigger_kind: Option, + origin_verified: bool, +} + +/// Reads any job of `w_id` regardless of the caller: authorize access to `job_id` first. +pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result> { + let lineage = sqlx::query_as!( + LineageJob, + r#"WITH RECURSIVE lineage AS ( + SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger, + 0 AS depth + FROM v2_job WHERE id = $1 AND workspace_id = $2 + UNION ALL + SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind, + p.trigger, l.depth + 1 + FROM v2_job p JOIN lineage l ON p.id = l.parent_job + WHERE p.workspace_id = $2 AND l.depth < 100 + ) + SELECT id AS "id!", parent_job, kind AS "kind!: JobKind", runnable_path, + trigger_kind::text AS trigger_kind, + -- A restart takes its flow version from the request, so a trusted flow path + -- can carry another flow's code: the version must belong to that path. + CASE kind + WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv + WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2) + WHEN 'script' THEN EXISTS (SELECT 1 FROM script s + WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2) + -- An app editor preview also runs an app script, at an app path it does not + -- have to own; only deployed-app runs are stamped with their app. + WHEN 'appscript' THEN COALESCE(trigger_kind = 'app' + AND starts_with(runnable_path, trigger || '/'), false) + ELSE true + END AS "origin_verified!" + FROM lineage ORDER BY depth"#, + job_id, + w_id + ) + .fetch_all(db) + .await?; + + let Some(root) = lineage.last() else { + return Ok(None); + }; + // An ancestor missing from v2_job (other workspace, depth cap) leaves the chain unproven. + let deployed = root.parent_job.is_none() + && lineage + .iter() + .all(|j| runs_stored_code(j.kind) && j.origin_verified); + Ok(Some(JobProvenance { + deployed, + parent_path: lineage.get(1).and_then(|j| j.runnable_path.clone()), + root_id: root.id, + root_path: root.runnable_path.clone(), + root_kind: root.kind, + root_trigger_kind: root.trigger_kind.clone(), + })) +} + +/// Whether the job's code and its path are both fixed by what was deployed (or, for a +/// hub script, published), rather than supplied by the request that created the job. +fn runs_stored_code(kind: JobKind) -> bool { + match kind { + JobKind::Script + | JobKind::Script_Hub + | JobKind::Flow + | JobKind::SingleStepFlow + | JobKind::FlowScript + | JobKind::FlowNode + | JobKind::AIAgent + | JobKind::AppScript => true, + JobKind::Preview + | JobKind::FlowPreview + | JobKind::Dependencies + | JobKind::FlowDependencies + | JobKind::AppDependencies + | JobKind::Identity + | JobKind::Noop + | JobKind::DeploymentCallback + | JobKind::UnassignedScript + | JobKind::UnassignedFlow + | JobKind::UnassignedSinglestepFlow => false, + } +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 63ec44daec..0a1b0974d5 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -58,6 +58,10 @@ pub mod ee_oss; pub mod email_ee; pub mod email_oss; pub mod error; +pub mod external_instance_pg; +#[cfg(all(feature = "private", feature = "enterprise"))] +mod external_instance_pg_ee; +pub mod external_instance_pg_oss; pub mod external_ip; #[cfg(feature = "private")] pub mod feature_usage_ee; @@ -86,6 +90,7 @@ pub mod workspace_dependencies; #[cfg(feature = "private")] pub mod git_sync_ee; pub mod git_sync_oss; +pub mod job_provenance; pub mod jobs; pub mod jwt; pub mod login_rate_limit; @@ -1114,7 +1119,13 @@ impl PgDatabase { if err_str.contains("password authentication failed for user") && err_str.contains("custom_instance_user") { - if let Some(db) = main_db { + // The external instance cluster has a `custom_instance_user` of its own, whose + // password setup manages. Rotating the local one would break every instance + // data table and fix nothing. + let local = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; + let on_local_cluster = local.host == self.host + && local.port.unwrap_or(5432) == self.port.unwrap_or(5432); + if let Some(db) = main_db.filter(|_| on_local_cluster) { tracing::warn!( "custom_instance_user password auth failed, refreshing and retrying..." ); @@ -1664,13 +1675,41 @@ pub async fn instance_database_users( } /// Drop a custom instance database: validate, terminate connections, DROP DATABASE, remove from global_settings. +/// +/// Authorization: drops any instance database but Windmill's own and checks nothing. Callers MUST +/// be superadmin, or have established the caller may drop this one — a fork's owner cleaning up +/// its own copy that nothing else uses. pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Result<()> { drop_custom_instance_database_on(&mut *db.acquire().await?, dbname).await } +/// [`drop_custom_instance_database`] leaving its registry entry, for a caller holding row locks in +/// a transaction: the registry write has to go through that transaction, as waiting on another +/// connection for a lock the transaction's own peers hold is a deadlock Postgres cannot see. Same +/// authorization contract. +pub async fn drop_custom_instance_database_keep_entry(db: &DB, dbname: &str) -> error::Result<()> { + drop_instance_database_keep_entry_on(&mut *db.acquire().await?, dbname).await +} + async fn drop_custom_instance_database_on( conn: &mut sqlx::PgConnection, dbname: &str, +) -> error::Result<()> { + let dbname = dbname.trim(); + drop_instance_database_keep_entry_on(&mut *conn, dbname).await?; + // Always remove from global_settings + sqlx::query!( + r#"UPDATE global_settings SET value = value #- ARRAY['databases', $1] WHERE name = 'custom_instance_pg_databases'"#, + dbname + ) + .execute(&mut *conn) + .await?; + Ok(()) +} + +async fn drop_instance_database_keep_entry_on( + conn: &mut sqlx::PgConnection, + dbname: &str, ) -> error::Result<()> { let dbname = dbname.trim(); validate_dbname(dbname)?; @@ -1717,14 +1756,6 @@ async fn drop_custom_instance_database_on( tracing::info!("Database '{}' does not exist, skipping drop", dbname); } - // Always remove from global_settings - sqlx::query!( - r#"UPDATE global_settings SET value = value #- ARRAY['databases', $1] WHERE name = 'custom_instance_pg_databases'"#, - dbname - ) - .execute(&mut *conn) - .await?; - Ok(()) } @@ -1749,26 +1780,30 @@ pub(crate) fn instance_db_grants(dbname: &str) -> String { ) } -/// Re-apply [`instance_db_grants`] to an instance database provisioned before data table roles -/// existed, whose grants carry no grant option. Connects as the instance's own Postgres user — -/// the database and `public` schema owner — since only it can hand out an option it holds. +/// Re-apply [`instance_db_grants`] to a managed database provisioned before data table roles +/// existed, whose grants carry no grant option. Connects as the cluster's administrator — the +/// database and `public` schema owner — since only it can hand out an option it holds. /// -/// Authorization: reaches an instance database with the server's own credentials and checks +/// Authorization: reaches a managed database with the server's own credentials and checks /// nothing. Callers MUST have authorized administration of `dbname` — superadmin, or an admin of /// the workspace governing a data table on it. pub async fn ensure_instance_db_grant_options_unchecked( db: &DB, + cluster: crate::datatable_roles::DatatableRoleCluster, dbname: &str, ) -> error::Result<()> { - crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, dbname).await + crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, cluster, dbname) + .await } /// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings. -/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake"). +/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake"). `for_workspace` +/// is the workspace a member creates a fork copy for; see [`ensure_fork_database_available_to`]. pub async fn create_custom_instance_database( db: &DB, dbname: &str, tag: &str, + for_workspace: Option<&str>, ) -> error::Result<()> { let dbname = dbname.trim(); validate_dbname(dbname)?; @@ -1798,7 +1833,7 @@ pub async fn create_custom_instance_database( // Nothing names a database that failed past this point, and its name blocks the retry: drop it // rather than leave it behind. - if let Err(e) = finish_custom_instance_database(db, dbname, tag).await { + if let Err(e) = finish_custom_instance_database(db, dbname, tag, for_workspace).await { match drop_unused_instance_database(db, dbname).await { Ok(Cleanup::InUse(users)) => tracing::warn!( "Kept '{dbname}' after failing to set it up: workspaces {} use it", @@ -1819,7 +1854,13 @@ pub async fn create_custom_instance_database( // A data table role can only reach a database it may CONNECT to, and PUBLIC's default CONNECT // would otherwise let every role in regardless of what this instance defines. Best-effort: a // failure here leaves the database usable as `admin`, and the next role change repairs it. - if let Err(e) = crate::datatable_roles::converge_connect_grants(db, dbname).await { + if let Err(e) = crate::datatable_roles::converge_connect_grants( + db, + crate::datatable_roles::DatatableRoleCluster::Instance, + dbname, + ) + .await + { tracing::warn!("Could not set CONNECT grants on instance database '{dbname}': {e}"); } @@ -1828,7 +1869,12 @@ pub async fn create_custom_instance_database( } /// Grant `custom_instance_user` its privileges on a database just created, and register it. -async fn finish_custom_instance_database(db: &DB, dbname: &str, tag: &str) -> error::Result<()> { +async fn finish_custom_instance_database( + db: &DB, + dbname: &str, + tag: &str, + for_workspace: Option<&str>, +) -> error::Result<()> { // Grant permissions to custom_instance_user let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; let new_pg_creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds }; @@ -1855,7 +1901,8 @@ async fn finish_custom_instance_database(db: &DB, dbname: &str, tag: &str) -> er }, "success": true, "error": null, - "tag": tag + "tag": tag, + "workspace_id": for_workspace, }); sqlx::query!( r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#, @@ -1863,6 +1910,75 @@ async fn finish_custom_instance_database(db: &DB, dbname: &str, tag: &str) -> er ) .execute(db) .await?; + + Ok(()) +} + +/// The system's CA bundle file, for libpq clients that cannot take `sslrootcert=system`: that value +/// needs libpq 16, and verify-full only. +pub fn system_ca_bundle() -> Option { + std::env::var_os("SSL_CERT_FILE") + .map(std::path::PathBuf::from) + .into_iter() + .chain( + [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/cert.pem", + "/etc/ssl/ca-bundle.pem", + ] + .map(std::path::PathBuf::from), + ) + .find(|path| path.is_file()) +} + +/// Refuse a workspace member writing a fork copy into, or pointing a fork at, the managed database +/// `dbname` of `kind`, unless `w_id` created it for that ([`create_custom_instance_database`], or +/// its external instance counterpart) and nothing uses it yet. The `wm_fork_` prefix is no +/// authorization: every database of a cluster answers to the same `custom_instance_user`, so a name +/// is all it takes to reach another workspace's copy. +/// +/// Runs on `conn`: its callers hold a transaction with the fork lock while they check, and a +/// second connection taken from the pool under it is how a small pool deadlocks. +/// +/// Authorization: reads the global registries and every workspace's settings, and names other +/// workspaces in its refusal. Callers MUST have authorized `w_id` for the caller first — a member +/// of it forking or importing there — and MUST NOT call it on a workspace the caller is not in. +pub async fn ensure_fork_database_available_to( + conn: &mut sqlx::PgConnection, + kind: workspaces::DataTableCatalogResourceType, + dbname: &str, + w_id: &str, +) -> error::Result<()> { + let created_for = match kind { + workspaces::DataTableCatalogResourceType::ExternalInstance => { + external_instance_pg::read_external_instance_pg_state(&mut *conn) + .await? + .databases + .remove(dbname) + .and_then(|entry| entry.workspace_id) + } + _ => sqlx::query_scalar::<_, Option>( + "SELECT value->'databases'->$1->>'workspace_id' FROM global_settings + WHERE name = 'custom_instance_pg_databases'", + ) + .bind(dbname) + .fetch_optional(&mut *conn) + .await? + .flatten(), + }; + if created_for.as_deref() != Some(w_id) { + return Err(Error::BadRequest(format!( + "Database '{dbname}' was not created for a fork of workspace '{w_id}'" + ))); + } + let uses = workspaces::managed_database_uses(conn, kind, dbname, None).await?; + if !uses.is_empty() { + return Err(Error::BadRequest(format!( + "Database '{dbname}' is already in use: {}", + uses.join(", ") + ))); + } Ok(()) } diff --git a/backend/windmill-common/src/oidc_oss.rs b/backend/windmill-common/src/oidc_oss.rs index 60a09ce9b7..7a1a6948ce 100644 --- a/backend/windmill-common/src/oidc_oss.rs +++ b/backend/windmill-common/src/oidc_oss.rs @@ -64,6 +64,12 @@ pub struct JobClaim { pub fork_parent_workspace: Option, #[serde(skip_serializing_if = "Option::is_none")] pub end_user_email: Option, + pub job_kind: String, + pub deployed: bool, + pub root_job_id: String, + pub root_path: Option, + pub root_job_kind: String, + pub root_trigger_kind: Option, } #[cfg(not(feature = "private"))] diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 0cdf7ffa4f..583b1e81ab 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -396,7 +396,10 @@ pub fn webhook_ssrf_error_message(e: &SsrfValidationError) -> String { } } -fn is_private_ip(ip: &IpAddr) -> bool { +/// Whether `ip` is loopback, private, link-local, or otherwise not a public +/// internet destination. The one predicate every server-side outbound-URL guard +/// (SSRF checks, git remotes, object storage tests) judges addresses with. +pub fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ipv4) => is_private_ipv4(ipv4), IpAddr::V6(ipv6) => is_private_ipv6(ipv6), @@ -408,6 +411,7 @@ fn is_private_ipv4(ip: &Ipv4Addr) -> bool { || ip.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 || ip.is_link_local() // 169.254.0.0/16 (AWS IMDS lives here) || ip.is_broadcast() // 255.255.255.255 + || ip.is_multicast() // 224.0.0.0/4 // RFC 1122 "this network": the whole /8, not just the unspecified // address `is_unspecified()` matches — stacks that map 0.x.y.z onto the // local host make `0.0.0.1` a bypass. @@ -418,17 +422,60 @@ fn is_private_ipv4(ip: &Ipv4Addr) -> bool { } fn is_private_ipv6(ip: &Ipv6Addr) -> bool { + let seg = ip.segments(); ip.is_loopback() // ::1 || ip.is_unspecified() // :: + || ip.is_multicast() // ff00::/8 // Unique local addresses (fc00::/7) - || (ip.segments()[0] & 0xfe00) == 0xfc00 - // Link-local (fe80::/10) - || (ip.segments()[0] & 0xffc0) == 0xfe80 - // IPv4-mapped addresses — check the embedded IPv4 - || match ip.to_ipv4_mapped() { - Some(ipv4) => is_private_ipv4(&ipv4), - None => false, - } + || (seg[0] & 0xfe00) == 0xfc00 + // Link-local (fe80::/10) and deprecated site-local (fec0::/10) + || (seg[0] & 0xffc0) == 0xfe80 + || (seg[0] & 0xffc0) == 0xfec0 + // NAT64 local-use prefix (64:ff9b:1::/48, RFC 8215): a site's own + // translator, whose IPv4 placement depends on the prefix length it uses. + || (seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2] == 0x0001) + || embedded_ipv4(ip).is_some_and(|v4| is_private_ipv4(&v4)) +} + +/// The IPv4 address an IPv6 address carries through a transition mechanism, +/// which a dual-stack host or a translator on the path will deliver to. Guards +/// must judge that address too, else e.g. `64:ff9b::a9fe:a9fe` reaches +/// 169.254.169.254 through a NAT64 gateway. +fn embedded_ipv4(ip: &Ipv6Addr) -> Option { + let seg = ip.segments(); + let low32 = || { + Ipv4Addr::new( + (seg[6] >> 8) as u8, + seg[6] as u8, + (seg[7] >> 8) as u8, + seg[7] as u8, + ) + }; + match seg { + // IPv4-mapped ::ffff:0:0/96 + [0, 0, 0, 0, 0, 0xffff, _, _] => Some(low32()), + // IPv4-translated ::ffff:0:0:0/96 (SIIT) + [0, 0, 0, 0, 0xffff, 0, _, _] => Some(low32()), + // IPv4-compatible ::/96 (deprecated, still routed by some stacks) + [0, 0, 0, 0, 0, 0, _, _] => Some(low32()), + // NAT64 well-known prefix 64:ff9b::/96 + [0x0064, 0xff9b, 0, 0, 0, 0, _, _] => Some(low32()), + // 6to4 2002::/16: the site router's IPv4 sits right after the prefix + [0x2002, hi, lo, ..] => Some(Ipv4Addr::new( + (hi >> 8) as u8, + hi as u8, + (lo >> 8) as u8, + lo as u8, + )), + // Teredo 2001::/32: the client's IPv4 is stored bit-inverted + [0x2001, 0, ..] => Some(Ipv4Addr::new( + !(seg[6] >> 8) as u8, + !seg[6] as u8, + !(seg[7] >> 8) as u8, + !seg[7] as u8, + )), + _ => None, + } } #[cfg(test)] @@ -486,31 +533,50 @@ mod tests { } #[test] - fn test_private_ipv4() { - assert!(is_private_ipv4(&"127.0.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"10.0.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"172.16.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"192.168.1.1".parse().unwrap())); - assert!(is_private_ipv4(&"169.254.169.254".parse().unwrap())); - assert!(is_private_ipv4(&"0.0.0.0".parse().unwrap())); - // The whole 0.0.0.0/8 block, not just the unspecified address. - assert!(is_private_ipv4(&"0.0.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"0.255.255.255".parse().unwrap())); - assert!(is_private_ipv4(&"100.64.0.1".parse().unwrap())); - assert!(!is_private_ipv4(&"8.8.8.8".parse().unwrap())); - assert!(!is_private_ipv4(&"1.1.1.1".parse().unwrap())); - } - - #[test] - fn test_private_ipv6() { - assert!(is_private_ipv6(&"::1".parse().unwrap())); - assert!(is_private_ipv6(&"::".parse().unwrap())); - assert!(is_private_ipv6(&"fc00::1".parse().unwrap())); - assert!(is_private_ipv6(&"fd00::1".parse().unwrap())); - assert!(is_private_ipv6(&"fe80::1".parse().unwrap())); - // IPv4-mapped loopback - assert!(is_private_ipv6(&"::ffff:127.0.0.1".parse().unwrap())); - assert!(!is_private_ipv6(&"2001:4860:4860::8888".parse().unwrap())); + fn test_private_ip() { + for s in [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + // The whole 0.0.0.0/8 block, not just the unspecified address. + "0.0.0.0", + "0.0.0.1", + "0.255.255.255", + "::1", + "::", + "fc00::1", + "fd00::1", + "fe80::1", + // IPv4 carried inside IPv6 by a transition mechanism. + "::ffff:127.0.0.1", // IPv4-mapped + "::169.254.169.254", // IPv4-compatible + "::ffff:0:a9fe:a9fe", // IPv4-translated (SIIT) + "64:ff9b::a9fe:a9fe", // NAT64 well-known prefix + "64:ff9b:1::a9fe:a9fe", // NAT64 local-use prefix + "64:ff9b:1:ffff::1", // any address in the local-use prefix + "2002:a9fe:a9fe::1", // 6to4 + "2002:7f00:1::", // 6to4 + "2001:0:1:2:0:0:80ff:fffe", // Teredo, client 127.0.0.1 bit-inverted + "2001:0:1:2::5601:5601", // Teredo, client 169.254.169.254 bit-inverted + ] { + let ip: IpAddr = s.parse().unwrap(); + assert!(is_private_ip(&ip), "{s} should be private"); + } + for s in [ + "8.8.8.8", + "1.1.1.1", + "2001:4860:4860::8888", + "2606:4700:4700::1111", + "64:ff9b::808:808", // NAT64 of 8.8.8.8 + "2002:808:808::1", // 6to4 of 8.8.8.8 + "2001:0:1:2::f7f7:f7f7", // Teredo, client 8.8.8.8 + ] { + let ip: IpAddr = s.parse().unwrap(); + assert!(!is_private_ip(&ip), "{s} should be public"); + } } #[tokio::test] diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index ad0a5068e8..d480a89851 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1939,6 +1939,9 @@ pub async fn update_ping_http( insert_ping.occupancy_rate_30m, insert_ping.native_mode.unwrap_or(false), insert_ping.ip.as_deref(), + insert_ping + .last_job_executed + .zip(insert_ping.last_job_workspace_id.as_deref()), db, ) .await? @@ -2220,12 +2223,15 @@ pub async fn update_worker_ping_main_loop_query( occupancy_rate_30m: Option, native_mode: bool, ip: Option<&str>, + last_job: Option<(Uuid, &str)>, db: &DB, ) -> anyhow::Result<()> { + let (last_job_id, last_job_workspace_id) = last_job.unzip(); timeout(Duration::from_secs(10), sqlx::query!( "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2, occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus), - memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6", + memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip), + current_job_id = COALESCE($14, current_job_id), current_job_workspace_id = COALESCE($15, current_job_workspace_id) WHERE worker = $6", jobs_executed, tags, occupancy_rate, @@ -2239,6 +2245,8 @@ pub async fn update_worker_ping_main_loop_query( occupancy_rate_30m, native_mode, ip, + last_job_id, + last_job_workspace_id, ) .execute(db)) .await??; diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index b285332b7d..724743dce9 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1542,6 +1542,47 @@ pub enum DataTableCatalogResourceType { #[strum(serialize = "postgres")] Postgresql, Instance, + /// On the external instance cluster ([`crate::external_instance_pg`]). Enterprise Edition. + #[serde(rename = "external_instance")] + #[strum(serialize = "external_instance")] + ExternalInstance, +} + +impl DataTableCatalogResourceType { + /// A database Windmill created and administers, on its own cluster or the external one, as + /// opposed to one a user brought as a resource. + pub fn is_windmill_managed(self) -> bool { + matches!(self, Self::Instance | Self::ExternalInstance) + } +} + +/// Refuse a new use of Windmill's own Postgres as a data table or Ducklake substrate where an +/// operator turned it off, and on the managed cloud, which never had it. Entries already on it +/// keep resolving: this gates what a save may newly name, not what runs. +pub async fn ensure_instance_pg_available<'c>(executor: impl sqlx::PgExecutor<'c>) -> Result<()> { + if *crate::worker::CLOUD_HOSTED { + return Err(Error::BadRequest( + "Windmill's own database cannot back a data table or Ducklake catalog on Windmill Cloud" + .to_string(), + )); + } + let disabled = sqlx::query_scalar::<_, Option>( + "SELECT value FROM global_settings WHERE name = $1", + ) + .bind(crate::global_settings::INSTANCE_PG_DISABLED_SETTING) + .fetch_optional(executor) + .await? + .flatten() + .is_some_and(|v| v.as_bool().unwrap_or(false)); + if disabled { + return Err(Error::BadRequest( + "Windmill's own database is disabled as a data table and Ducklake substrate on this \ + instance. Use the external instance cluster, or turn it back on in the instance \ + settings." + .to_string(), + )); + } + Ok(()) } /// Build a self-teaching error for an unresolved `datatable://` reference. @@ -1621,14 +1662,82 @@ pub struct GoverningDatatable { pub governor: Option, } +/// Everything still using the Windmill-managed database `dbname`, one description per use: data +/// table entries naming it, fork entries pointing at those, Ducklake catalogs on it, and fork +/// Ducklake metadata schemas there that cleanup has not dropped yet. `exempt` is the one data table +/// entry, `(workspace_id, name)`, the caller is about to stop using it through; pointers at that +/// entry still count, since dropping the database would leave them resolving to nothing. +/// +/// Authorization: reads every workspace's settings and checks nothing. Callers MUST only turn the +/// answer into a refusal for someone allowed to administer `dbname`. +pub async fn managed_database_uses( + conn: &mut sqlx::PgConnection, + kind: DataTableCatalogResourceType, + dbname: &str, + exempt: Option<(&str, &str)>, +) -> Result> { + let (exempt_workspace, exempt_name) = exempt.unzip(); + Ok(sqlx::query_scalar::<_, String>( + "WITH entries AS ( + SELECT ws.workspace_id::text AS workspace_id, dt.key AS name, dt.value + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt + ), naming AS ( + SELECT workspace_id, name FROM entries + WHERE value->'database'->>'resource_type' = $1 + AND value->'database'->>'resource_path' = $2 + ) + SELECT format('data table ''%s'' in workspace ''%s''', name, workspace_id) FROM naming + WHERE $3::text IS NULL OR NOT (workspace_id = $3 AND name = $4) + UNION ALL + SELECT format('data table ''%s'' in workspace ''%s'', which points at the one in ''%s''', + e.name, e.workspace_id, n.workspace_id) + FROM entries e JOIN naming n + ON e.value->'reference'->>'workspace_id' = n.workspace_id + AND e.value->'reference'->>'datatable' = n.name + UNION ALL + SELECT format('Ducklake ''%s'' in workspace ''%s''', dl.key, ws.workspace_id) + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' + THEN ws.ducklake->'ducklakes' ELSE '{}'::jsonb END) dl + WHERE dl.value->'catalog'->>'resource_type' = $1 + AND dl.value->'catalog'->>'resource_path' = $2 + UNION ALL + SELECT format('the Ducklake namespace of fork ''%s'', not cleaned up yet', workspace_id) + FROM fork_ducklake_namespace + WHERE catalog = $1 || ':' || $2 AND NOT schema_dropped + ORDER BY 1", + ) + .bind(kind.as_ref()) + .bind(dbname) + .bind(exempt_workspace) + .bind(exempt_name) + .fetch_all(&mut *conn) + .await?) +} + +/// Held by fork cleanup of `w_id`'s data tables and by forking `w_id`, which can hand the new fork +/// pointers at them, so a pointer cannot appear between cleanup's check and its drop. +pub async fn lock_fork_datatables(conn: &mut sqlx::PgConnection, w_id: &str) -> Result<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork_datatables:' || $1))") + .bind(w_id) + .execute(&mut *conn) + .await?; + Ok(()) +} + impl GoverningDatatable { - /// Backed by the Windmill instance's own Postgres, which is the only substrate data table - /// roles apply to. - pub fn is_instance(&self) -> bool { + /// The Windmill-managed cluster whose data table roles this entry can use. `None` for a + /// resource-backed one: roles are logins Windmill creates, and it creates none on a host a + /// workspace admin chose. + pub fn role_cluster(&self) -> Option { self.datatable .database .as_ref() - .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance) + .and_then(|d| crate::datatable_roles::DatatableRoleCluster::of(d.resource_type)) } /// The workspace whose admins administer the data table and whose members its tenants are. @@ -1776,20 +1885,19 @@ pub async fn resolve_workspace_governing_datatables( clone, )), (None, Some(governed_by)) => { - let (governor_ws, governor_name) = - (governed_by.workspace_id.clone(), governed_by.datatable.clone()); + let (governor_ws, governor_name) = ( + governed_by.workspace_id.clone(), + governed_by.datatable.clone(), + ); let clone = clone.or(Some((ws, name, datatable))); next.push((i, governor_ws, governor_name, clone)); } (None, None) => resolved.push(( i, match clone { - None => GoverningDatatable { - workspace_id: ws, - name, - datatable, - governor: None, - }, + None => { + GoverningDatatable { workspace_id: ws, name, datatable, governor: None } + } Some((clone_ws, clone_name, mut clone_datatable)) => { clone_datatable.permissions = datatable.permissions; GoverningDatatable { @@ -1830,7 +1938,8 @@ pub async fn resolve_workspace_governing_datatables( } /// Build the `admin` connection for a governing entry: `custom_instance_user` for an instance -/// database, the user's own resource for a BYO-postgres one. +/// database, on Windmill's cluster or the external one; the user's own resource for a BYO-postgres +/// one. async fn resolve_datatable_connection_unchecked( db: &DB, governing: &GoverningDatatable, @@ -1841,7 +1950,16 @@ async fn resolve_datatable_connection_unchecked( .database .as_ref() .expect("a governing entry owns a database"); - if database.resource_type == DataTableCatalogResourceType::Instance { + if database.resource_type == DataTableCatalogResourceType::ExternalInstance { + let pg_creds = crate::external_instance_pg::external_instance_connection_unchecked( + db, + &database.resource_path, + replication, + ) + .await?; + serde_json::to_value(&pg_creds) + .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e))) + } else if database.resource_type == DataTableCatalogResourceType::Instance { let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; pg_creds.dbname = database.resource_path.clone(); if replication { @@ -1879,8 +1997,31 @@ pub async fn get_datatable_resource_from_db_unchecked( w_id: &str, name: &str, ) -> Result { + Ok(get_datatable_connection_and_kind_unchecked(db, w_id, name) + .await? + .0) +} + +/// As [`get_datatable_resource_from_db_unchecked`], also reporting the kind of database Windmill +/// manages behind it, `None` for a user resource. One resolution answers both: a caller reading the +/// kind separately can be handed one kind's connection and the other kind's checks by a save +/// landing between the two, and the entry a pointer lands on is another workspace's to change. +/// +/// Same authorization contract: the connection reaches every role. +pub async fn get_datatable_connection_and_kind_unchecked( + db: &DB, + w_id: &str, + name: &str, +) -> Result<(serde_json::Value, Option)> { let governing = resolve_governing_datatable(db, w_id, name).await?; - resolve_datatable_connection_unchecked(db, &governing, false).await + let kind = governing + .datatable + .database + .as_ref() + .map(|d| d.resource_type) + .filter(|kind| kind.is_windmill_managed()); + let connection = resolve_datatable_connection_unchecked(db, &governing, false).await?; + Ok((connection, kind)) } /// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger @@ -2385,6 +2526,10 @@ pub enum DucklakeCatalogResourceType { Postgresql, Mysql, Instance, + /// On the external instance cluster ([`crate::external_instance_pg`]). Enterprise Edition. + #[serde(rename = "external_instance")] + #[strum(serialize = "external_instance")] + ExternalInstance, } #[derive(Deserialize, Serialize)] @@ -2912,7 +3057,16 @@ async fn ducklake_conn_data( let ducklake = serde_json::from_value::(ducklake)?; let catalog_resource = - if ducklake.catalog.resource_type == DucklakeCatalogResourceType::Instance { + if ducklake.catalog.resource_type == DucklakeCatalogResourceType::ExternalInstance { + let pg_creds = crate::external_instance_pg::external_instance_connection_unchecked( + db, + &ducklake.catalog.resource_path, + false, + ) + .await?; + serde_json::to_value(&pg_creds) + .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))? + } else if ducklake.catalog.resource_type == DucklakeCatalogResourceType::Instance { let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; pg_creds.dbname = ducklake.catalog.resource_path.clone(); pg_creds.user = Some("custom_instance_user".to_string()); @@ -3293,6 +3447,14 @@ async fn register_fork_ducklake_namespace( { return Ok(()); } + let mut tx = db.begin().await?; + // A row naming an external database counts as a use of it. Written under the lock a drop takes, + // and only while the database is still registered, so a drop cannot slip in between the + // settings this attach resolved and the row that protects the database. + if let Some(dbname) = catalog.strip_prefix("external_instance:") { + crate::external_instance_pg::ensure_external_instance_database_registered(&mut tx, dbname) + .await?; + } sqlx::query!( "INSERT INTO fork_ducklake_namespace (workspace_id, ducklake_name, metadata_schema, catalog, storage, storage_ref, data_path) @@ -3307,9 +3469,10 @@ async fn register_fork_ducklake_namespace( &storage_ref, data_path, ) - .execute(db) + .execute(&mut *tx) .await .map_err(|e| Error::internal_err(format!("registering fork ducklake namespace: {e:#}")))?; + tx.commit().await?; let mut locations = FORK_DUCKLAKE_REGISTERED .get(w_id) .filter(|(_, exp)| *exp > now) diff --git a/backend/windmill-dep-map/src/workspace_dependencies.rs b/backend/windmill-dep-map/src/workspace_dependencies.rs index 03db1405e5..f4dd404133 100644 --- a/backend/windmill-dep-map/src/workspace_dependencies.rs +++ b/backend/windmill-dep-map/src/workspace_dependencies.rs @@ -11,6 +11,8 @@ use crate::{ #[derive(sqlx::FromRow, Clone, Serialize, Deserialize, Hash, Debug)] pub struct NewWorkspaceDependencies { + /// Trusted as-is by `create`: a caller deserializing this from a request must first check it + /// against the workspace the caller is authorized in. pub workspace_id: String, pub language: ScriptLang, pub name: Option, diff --git a/backend/windmill-object-store/Cargo.toml b/backend/windmill-object-store/Cargo.toml index 90af4f7e7c..f80eb88881 100644 --- a/backend/windmill-object-store/Cargo.toml +++ b/backend/windmill-object-store/Cargo.toml @@ -11,6 +11,7 @@ enterprise = [] parquet = [ "windmill-common/parquet", "dep:object_store", + "dep:reqwest_object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion", @@ -48,6 +49,7 @@ globset.workspace = true axum.workspace = true object_store = { workspace = true, optional = true } +reqwest_object_store = { workspace = true, optional = true } aws-sdk-sts = { workspace = true, optional = true } aws-smithy-types-convert = { workspace = true, optional = true } datafusion = { workspace = true, optional = true } diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index edc3ea9149..a9551ad835 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -583,8 +583,175 @@ pub async fn ambient_aws_credentials( ambient_aws_credentials_provider(region).await.get().await } +/// An object-store HTTP connector whose client cannot reach a non-public address. +/// +/// Validating a caller-supplied endpoint up front does not bind the connection: object_store +/// resolves the host again when it connects, so a name can answer a public address to the check +/// and an internal one to the connect. This client resolves every host through +/// [`PublicOnlyResolver`], which judges the very addresses it hands to the connect, refuses IP-literal +/// hosts that are not public (they never reach a resolver), and follows no redirect. +/// +/// The environment's egress proxy (`HTTP(S)_PROXY`/`ALL_PROXY`) is still used, and its own host is +/// exempt from the resolver check: it is operator configuration and typically internal. A request +/// whose target is the proxy host itself is refused, so the exemption only ever serves the proxy +/// hop. A proxied request's target is resolved by the proxy, out of this client's reach (see +/// `ssrf::ValidatedTarget`). +#[cfg(feature = "parquet")] +#[derive(Debug, Clone, Copy)] +pub struct PublicOnlyConnector; + +#[cfg(feature = "parquet")] +impl object_store::client::HttpConnector for PublicOnlyConnector { + fn connect( + &self, + options: &ClientOptions, + ) -> object_store::Result { + let allow_http = options + .get_config_value(&object_store::ClientConfigKey::AllowHttp) + .is_some_and(|v| v == "true"); + let proxy_hosts: Arc<[String]> = system_proxy_hosts().into(); + let client = reqwest_object_store::Client::builder() + .dns_resolver(Arc::new(PublicOnlyResolver { + proxy_hosts: proxy_hosts.clone(), + })) + .redirect(reqwest_object_store::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(5)) + .default_headers(HeaderMap::from_iter([( + reqwest::header::ACCEPT_ENCODING, + reqwest::header::HeaderValue::from_static(""), + )])) + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .https_only(!allow_http) + .build() + .map_err(|e| object_store::Error::Generic { + store: "HTTP client", + source: Box::new(e), + })?; + Ok(object_store::client::HttpClient::new(PublicOnlyClient { + client, + proxy_hosts, + })) + } +} + +#[cfg(feature = "parquet")] +#[derive(Debug)] +struct PublicOnlyClient { + client: reqwest_object_store::Client, + proxy_hosts: Arc<[String]>, +} + +#[cfg(feature = "parquet")] +#[async_trait] +impl object_store::client::HttpService for PublicOnlyClient { + async fn call( + &self, + req: object_store::client::HttpRequest, + ) -> Result { + let host = req.uri().host().unwrap_or_default(); + let literal = host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .ok(); + let refused = if literal.is_some_and(|ip| windmill_common::ssrf::is_private_ip(&ip)) { + Some("a private, loopback, or link-local address") + } else if self + .proxy_hosts + .iter() + .any(|p| p.eq_ignore_ascii_case(host)) + { + Some("the egress proxy") + } else { + None + }; + if let Some(what) = refused { + return Err(object_store::client::HttpError::new( + object_store::client::HttpErrorKind::Unknown, + std::io::Error::other(format!("'{host}' is {what}")), + )); + } + object_store::client::HttpService::call(&self.client, req).await + } +} + +#[cfg(feature = "parquet")] +struct PublicOnlyResolver { + proxy_hosts: Arc<[String]>, +} + +/// The hosts of the proxies reqwest picks up from the environment. +#[cfg(feature = "parquet")] +fn system_proxy_hosts() -> Vec { + [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ] + .into_iter() + .filter_map(|k| std::env::var(k).ok()) + .filter_map(|v| { + let v = v.trim(); + let url = if v.contains("://") { + v.to_string() + } else { + format!("http://{v}") + }; + Some( + reqwest::Url::parse(&url) + .ok()? + .host_str()? + .to_ascii_lowercase(), + ) + }) + .collect() +} + +#[cfg(feature = "parquet")] +impl reqwest_object_store::dns::Resolve for PublicOnlyResolver { + fn resolve( + &self, + name: reqwest_object_store::dns::Name, + ) -> reqwest_object_store::dns::Resolving { + let is_proxy = self + .proxy_hosts + .iter() + .any(|p| p.eq_ignore_ascii_case(name.as_str())); + Box::pin(async move { + let host = name.as_str(); + let addrs: Vec = + tokio::net::lookup_host((host, 0)).await?.collect(); + if !is_proxy + && addrs + .iter() + .any(|a| windmill_common::ssrf::is_private_ip(&a.ip())) + { + return Err(format!( + "'{host}' resolves to a private, loopback, or link-local address" + ) + .into()); + } + Ok(Box::new(addrs.into_iter()) as reqwest_object_store::dns::Addrs) + }) + } +} + #[cfg(feature = "parquet")] pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result> { + build_s3_client_with(s3_resource_ref, None).await +} + +#[cfg(feature = "parquet")] +async fn build_s3_client_with( + s3_resource_ref: &S3Resource, + connector: Option, +) -> error::Result> { let static_creds = s3_resource_has_static_credentials(s3_resource_ref); let credentials_provider = if !static_creds { @@ -623,6 +790,9 @@ pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result error::Result error::Result> { + build_azure_blob_client_with(azure_blob_resource_ref, None) +} + +#[cfg(feature = "parquet")] +fn build_azure_blob_client_with( + azure_blob_resource_ref: &AzureBlobResource, + connector: Option, ) -> error::Result> { let blob_resource = azure_blob_resource_ref.clone(); @@ -701,6 +879,9 @@ fn build_azure_blob_client( if !blob_resource.use_ssl.unwrap_or(false) { store_builder = store_builder.with_allow_http(true) } + if let Some(connector) = connector { + store_builder = store_builder.with_http_connector(connector); + } if let Some(key) = blob_resource.access_key { if key != "" { @@ -735,6 +916,14 @@ pub fn gcs_service_account_key_is_blank(service_account_key: &str) -> bool { #[cfg(feature = "parquet")] async fn build_gcs_client(gcs_resource_ref: &GcsResource) -> error::Result> { + build_gcs_client_with(gcs_resource_ref, None).await +} + +#[cfg(feature = "parquet")] +async fn build_gcs_client_with( + gcs_resource_ref: &GcsResource, + connector: Option, +) -> error::Result> { let gcs_resource = gcs_resource_ref.clone(); let mut store_builder = GoogleCloudStorageBuilder::new() @@ -754,6 +943,9 @@ async fn build_gcs_client(gcs_resource_ref: &GcsResource) -> error::Result String { pub async fn build_object_store_from_settings( settings: ObjectSettings, init_private_key: Option<&windmill_common::DB>, +) -> error::Result { + build_object_store_from_settings_with(settings, init_private_key, None).await +} + +/// [`build_object_store_from_settings`] for a caller-supplied endpoint: the client refuses to +/// connect to any non-public address (see [`PublicOnlyConnector`]). Filesystem and OIDC settings +/// are refused; whether S3, Azure or GCS settings carry explicit credentials (rather than falling +/// back to the server's ambient ones) is still the caller's to check. +#[cfg(feature = "parquet")] +pub async fn build_public_object_store_from_settings( + settings: ObjectSettings, +) -> error::Result> { + match settings { + ObjectSettings::S3(_) | ObjectSettings::Azure(_) | ObjectSettings::Gcs(_) => { + build_object_store_from_settings_with(settings, None, Some(PublicOnlyConnector)) + .await + .map(|x| x.store) + } + ObjectSettings::AwsOidc(_) | ObjectSettings::Filesystem(_) => { + Err(error::Error::BadRequest( + "This object storage backend cannot be restricted to public endpoints".to_string(), + )) + } + } +} + +#[cfg(feature = "parquet")] +async fn build_object_store_from_settings_with( + settings: ObjectSettings, + init_private_key: Option<&windmill_common::DB>, + connector: Option, ) -> error::Result { let located = |store: Arc, resource: ObjectStoreResource| ExpirableObjectStore { @@ -946,12 +1169,14 @@ pub async fn build_object_store_from_settings( match settings { ObjectSettings::S3(s3_settings) => { let s3_resource = s3_resource_from_settings(s3_settings); - build_s3_client(&s3_resource) + build_s3_client_with(&s3_resource, connector) .await .map(|x| located(x, ObjectStoreResource::S3(s3_resource))) } - ObjectSettings::Azure(azure_settings) => build_azure_blob_client(&azure_settings) - .map(|x| located(x, ObjectStoreResource::Azure(azure_settings))), + ObjectSettings::Azure(azure_settings) => { + build_azure_blob_client_with(&azure_settings, connector) + .map(|x| located(x, ObjectStoreResource::Azure(azure_settings))) + } ObjectSettings::AwsOidc(ref s3_aws_oidc_settings) => { let token_generator = crate::job_s3_helpers_oss::TokenGenerator::AsServerInstance(); let res = crate::job_s3_helpers_oss::generate_s3_aws_oidc_resource( @@ -969,7 +1194,7 @@ pub async fn build_object_store_from_settings( location: Some(object_store_location(&res)), }) } - ObjectSettings::Gcs(gcs_settings) => build_gcs_client(&gcs_settings) + ObjectSettings::Gcs(gcs_settings) => build_gcs_client_with(&gcs_settings, connector) .await .map(|x| located(x, ObjectStoreResource::Gcs(gcs_settings))), ObjectSettings::Filesystem(fs) => build_filesystem_client(&fs.root_path) @@ -2701,6 +2926,44 @@ mod tests { assert_ne!(a.location, b.location); } + /// The restricted store judges the addresses it connects to, not only what an earlier check + /// resolved: neither a hostname that resolves to loopback nor a loopback literal gets a + /// connection. + #[cfg(feature = "parquet")] + #[tokio::test] + async fn test_public_store_refuses_to_connect_to_private_address() { + use futures::StreamExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + for endpoint in ["localhost", "127.0.0.1"] { + let settings = ObjectSettings::S3(S3Settings { + bucket: Some("windmill".to_string()), + region: Some("us-east-1".to_string()), + access_key: Some("key".to_string()), + secret_key: Some("secret".to_string()), + endpoint: Some(endpoint.to_string()), + allow_http: Some(true), + path_style: Some(true), + store_logs: None, + port: Some(listener.local_addr().unwrap().port()), + }); + let store = build_public_object_store_from_settings(settings) + .await + .unwrap(); + // The listener never answers, so a store that connects waits forever: bound it. + let err = + tokio::time::timeout(std::time::Duration::from_secs(30), store.list(None).next()) + .await + .unwrap_or_else(|_| panic!("the store connected to {endpoint}")) + .unwrap() + .unwrap_err(); + assert!( + format!("{err:?}").contains("private, loopback, or link-local"), + "{endpoint}: {err:?}" + ); + } + } + // --- get_logs_from_store test --- #[cfg(feature = "parquet")] diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 3041d89245..7b552fb02c 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -6869,9 +6869,19 @@ async fn push_inner<'c, 'd>( // `schedule_path` (see `FlowJob::schedule_path`), so counting per push would // score one run as a fire per step job — a loop pushes two of those per // iteration — burying every other kind, and would sit on the per-step path. + // + // A job a suspended trigger parks is not a fire: it counts as `fired` only + // when `resume_suspended_trigger_jobs` releases it, or never if discarded. + // Both are counted before the queue caps below and the caller's commit, so a + // rejected push still counts; accepted for a telemetry counter. if flow_step_id.is_none() { if let Some(kind) = trigger_kind.as_ref() { - windmill_common::feature_usage::log_feature_usage("trigger", "fired", kind.as_str()); + let action = if suspended_mode.unwrap_or(false) { + "suspended" + } else { + "fired" + }; + windmill_common::feature_usage::log_feature_usage("trigger", action, kind.as_str()); } } @@ -7689,6 +7699,34 @@ fn reuse_completed_zombie_module(module: FlowStatusModule) -> FlowStatusModule { } } +/// Loads the flow version a restart switches to. The version id is caller-supplied and the +/// restarted job keeps the original job's path, so it must be a version of that same flow in +/// that same workspace: any other id would run foreign code under the original path. +/// This only ties the version to the flow; it does not authorize the caller. `workspace_id` +/// and `flow_path` must come from the original job, which the caller is already allowed to +/// restart. +pub async fn fetch_restart_flow_version( + db: &Pool, + workspace_id: &str, + flow_path: &str, + version: i64, +) -> Result, Error> { + let belongs = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2 AND path = $3) AS \"exists!\"", + version, + workspace_id, + flow_path, + ) + .fetch_one(db) + .await?; + if !belongs { + return Err(Error::BadRequest(format!( + "flow version {version} is not a version of flow {flow_path} in workspace {workspace_id}" + ))); + } + cache::flow::fetch_version(db, version).await +} + async fn restarted_flows_resolution( db: &Pool, workspace_id: &str, @@ -7750,9 +7788,12 @@ async fn restarted_flows_resolution( && row.job_kind == JobKind::Flow; let flow_data = if is_version_change { - // Fetch the new flow version - let new_version = flow_version.unwrap(); - cache::flow::fetch_version(db, new_version).await? + let flow_path = row.script_path.as_deref().ok_or_else(|| { + Error::BadRequest(format!( + "completed flow {completed_flow_id} has no path to restart a version of" + )) + })?; + fetch_restart_flow_version(db, workspace_id, flow_path, flow_version.unwrap()).await? } else { cache::job::fetch_flow(db, &row.job_kind, row.script_hash) .or_else(|_| { diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 40c1292b74..97b175fa9e 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -36,7 +36,7 @@ use windmill_common::DB; use windmill_common::{ error::{self, Result}, schedule::Schedule, - utils::{now_from_db, ScheduleType, StripPath}, + utils::{now_from_db, report_critical_error, ScheduleType, StripPath}, }; /// Helper to fetch metadata for a schedule's script or flow @@ -203,6 +203,62 @@ pub async fn push_scheduled_job<'c>( return Ok(tx); } + // Only a chained push (`now_cutoff` is the previous occurrence) can miss one: create, + // edit, enable and re-arm start fresh, and a pause, even one already over, is deliberate. + let missed = now_cutoff + .filter(|_| schedule.paused_until.is_none()) + .and_then(|prev| { + // A failed count leaves the streak as it was rather than reading as a run on time. + count_missed_occurrences(&sched, &tz, prev, next) + .inspect_err(|e| { + tracing::warn!( + "failed to count the occurrences schedule {} missed: {e}", + &schedule.path + ) + }) + .ok() + }); + if let Some((missed, last_missed)) = missed { + // Past the cap the walk stops short of the last miss; the time it was detected is at + // most one period after it. + let last_missed = last_missed.map(|l| if missed == MAX_COUNTED_MISSES { now } else { l }); + if let Some(last_missed) = last_missed { + let streak = sqlx::query!( + "UPDATE schedule SET late_run_streak = late_run_streak + 1, + missed_occurrences = CASE WHEN late_run_streak = 0 THEN $3 + ELSE missed_occurrences + $3 END, + last_missed_at = $4 + WHERE workspace_id = $1 AND path = $2 + RETURNING late_run_streak, missed_occurrences", + &schedule.workspace_id, + &schedule.path, + missed as i32, + last_missed, + ) + .fetch_optional(&mut *tx) + .warn_after_seconds_with_sql(1, "update_schedule_late_run_streak".to_string()) + .await?; + if let Some(streak) = streak.filter(|s| s.late_run_streak == LATE_RUNS_BEFORE_ALERT) { + tokio::spawn(alert_late_run_streak( + db.clone(), + schedule.workspace_id.clone(), + schedule.path.clone(), + streak.missed_occurrences, + )); + } + } else if schedule.late_run_streak > 0 { + sqlx::query!( + "UPDATE schedule SET late_run_streak = 0 + WHERE workspace_id = $1 AND path = $2", + &schedule.workspace_id, + &schedule.path, + ) + .execute(&mut *tx) + .warn_after_seconds_with_sql(1, "reset_schedule_late_run_streak".to_string()) + .await?; + } + } + let mut args: HashMap> = HashMap::new(); if let Some(args_v) = &schedule.args { @@ -622,6 +678,68 @@ pub async fn push_scheduled_job<'c>( Ok(tx) // TODO: Bubble up pushed UUID from here } +const MAX_COUNTED_MISSES: u32 = 1000; + +/// Due slots of the cron strictly between the previous occurrence and the next one, and +/// the last of them. A chain on time costs one `find_next`: its first slot is `next` itself. +fn count_missed_occurrences( + sched: &ScheduleType, + tz: &chrono_tz::Tz, + prev: DateTime, + next: DateTime, +) -> Result<(u32, Option>)> { + let mut count = 0; + let mut last = None; + let mut slot = prev.with_timezone(tz); + while count < MAX_COUNTED_MISSES { + match sched.find_next(&slot) { + Ok(s) if s.with_timezone(&Utc) < next => { + count += 1; + last = Some(s.with_timezone(&Utc)); + slot = s; + } + Ok(_) => break, + Err(e) => return Err(e), + } + } + Ok((count, last)) +} + +/// A single late run is a blip (a slow run, a worker restart, an edit mid-run); only a +/// streak alerts, once, when it reaches this length. The schedules list shows every one. +const LATE_RUNS_BEFORE_ALERT: i32 = 3; + +/// Spawned: it reaches the instance alert channels, which must not hold up the push. +async fn alert_late_run_streak(db: DB, w_id: String, path: String, missed: i32) { + // The push transaction holds this row until it ends, so FOR SHARE waits for it: a push + // that rolled back leaves the streak short of the threshold, and only its committed + // retry alerts. + let committed = sqlx::query_scalar!( + "SELECT late_run_streak FROM schedule WHERE workspace_id = $1 AND path = $2 FOR SHARE", + &w_id, + &path, + ) + .fetch_optional(&db) + .await; + match committed { + Ok(Some(streak)) if streak >= LATE_RUNS_BEFORE_ALERT => {} + Ok(_) => return, + Err(e) => { + tracing::warn!("failed to confirm the late run streak of schedule {w_id}/{path}: {e}"); + return; + } + } + report_critical_error( + format!( + "Schedule {path} missed {missed} occurrences: its last {LATE_RUNS_BEFORE_ALERT} runs in a row started or finished too late" + ), + db, + Some(&w_id), + None, + ) + .await; +} + /// Enabled schedules with no occurrence in the queue, as `(workspace_id, path)`. /// /// Every path that completes a scheduled job pushes the next occurrence in the @@ -743,7 +861,7 @@ pub async fn get_schedule_opt<'c>( path: &str, ) -> Result> { let schedule_opt = sqlx::query_as::<_, Schedule>( - "SELECT workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, args, extra_perms, email, permissioned_as, error, on_failure, on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery, on_recovery_times, on_recovery_extra_args, on_success, on_success_extra_args, ws_error_handler_muted, retry, no_flow_overlap, summary, description, tag, paused_until, cron_version, dynamic_skip, labels FROM schedule WHERE path = $1 AND workspace_id = $2", + "SELECT workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, args, extra_perms, email, permissioned_as, error, on_failure, on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery, on_recovery_times, on_recovery_extra_args, on_success, on_success_extra_args, ws_error_handler_muted, retry, no_flow_overlap, summary, description, tag, paused_until, cron_version, dynamic_skip, labels, late_run_streak FROM schedule WHERE path = $1 AND workspace_id = $2", ) .bind(path) .bind(w_id) @@ -802,3 +920,22 @@ pub async fn clear_schedule<'c>( windmill_common::jobs::delete_jobs(&mut **tx, &deleted_ids).await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_the_slots_missed_between_two_occurrences() { + let every_30s = ScheduleType::from_str("*/30 * * * * *", None, false).unwrap(); + let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc); + let tz = chrono_tz::UTC; + let prev = at("2026-09-02T08:20:00Z"); + let count = |next| count_missed_occurrences(&every_30s, &tz, prev, at(next)).unwrap(); + assert_eq!(count("2026-09-02T08:20:30Z"), (0, None)); + assert_eq!( + count("2026-09-02T08:21:30Z"), + (2, Some(at("2026-09-02T08:21:00Z"))) + ); + } +} diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index f3ae34251e..c68c59bb16 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -49,6 +49,7 @@ mod schedule_push { cron_version: None, dynamic_skip: None, labels: None, + late_run_streak: 0, }; overrides(&mut s); s @@ -511,6 +512,63 @@ mod schedule_push { Ok(()) } + // ----------------------------------------------------------------------- + // push_scheduled_job: late run streak + // ----------------------------------------------------------------------- + + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn test_push_tracks_late_run_streak(db: Pool) -> anyhow::Result<()> { + let mut schedule = make_schedule(|s| s.schedule = "0 0 0 * * *".to_string()); + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as) + VALUES ($1, $2, 'test-user', $3, $4, 'u/test-user')", + ) + .bind(&schedule.workspace_id) + .bind(&schedule.path) + .bind(&schedule.schedule) + .bind(&schedule.script_path) + .execute(&db) + .await?; + let authed = make_authed(); + let streak = || { + sqlx::query_as::<_, (i32, i32, Option>)>( + "SELECT late_run_streak, missed_occurrences, last_missed_at FROM schedule WHERE path = $1", + ) + .bind(&schedule.path) + .fetch_one(&db) + }; + let push = |schedule: Schedule, prev: chrono::DateTime| { + let db = db.clone(); + let authed = authed.clone(); + async move { + sqlx::query("DELETE FROM v2_job_queue").execute(&db).await?; + let tx = db.begin().await?; + push_scheduled_job(&db, tx, &schedule, Some(&authed), Some(prev)) + .await? + .commit() + .await?; + anyhow::Ok(()) + } + }; + + let midnight = Utc::now() + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(); + + // The daily run due 3 days ago finished just now: the 3 midnights since were missed. + push(schedule.clone(), midnight - chrono::Duration::days(3)).await?; + let (runs, missed, at) = streak().await?; + assert_eq!((runs, missed, at), (1, 3, Some(midnight))); + + // Today's run on time ends the streak but keeps what it missed, for the badge. + schedule.late_run_streak = runs; + push(schedule.clone(), midnight).await?; + assert_eq!(streak().await?, (0, 3, at)); + Ok(()) + } + // ----------------------------------------------------------------------- // try_schedule_next_job: disabled schedule does not push // ----------------------------------------------------------------------- diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index fb39619ca5..e22860af80 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -17,7 +17,9 @@ use windmill_api_auth::{ }; use windmill_common::db::DB; use windmill_common::per_minute_counter::PerMinuteCounter; -use windmill_common::ssrf::{private_git_host_allowed, private_git_host_hint, GitRemoteCaller}; +use windmill_common::ssrf::{ + is_private_ip, private_git_host_allowed, private_git_host_hint, GitRemoteCaller, +}; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use crate::secret_backend_ext::rename_vault_secret; @@ -814,6 +816,7 @@ pub async fn get_resource_value_interpolated_internal<'a>( token_for_context, 0, &used_job_context, + Some(path), ) .await?; if let Some(identity) = cache_identity.as_deref() { @@ -853,10 +856,29 @@ pub async fn transform_json_value( token, depth, &used_job_context, + None, ) .await } +/// RLS resolves a reference as the token's user, not as the token: a token scoped to one +/// resource would otherwise read, through that resource, every variable or resource its +/// user can. Job tokens carry no scopes, so what a runnable resolves is unaffected. +/// `resource_path` is the resource being expanded: its own linked secrets +/// ([`is_owned_linked_var`]) are covered by the read of it. +fn check_interpolation_scope( + db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>, + domain: &str, + path: &str, + resource_path: Option<&str>, +) -> Result<()> { + match db_with_opt_authed.authed() { + Some(_) if resource_path.is_some_and(|r| is_owned_linked_var(r, path)) => Ok(()), + Some(authed) => check_scopes(authed, || format!("{domain}:read:{path}")), + None => Ok(()), + } +} + /// Like [`transform_json_value`], but records into `used_job_context` whether the value /// contains a `$WM_*` contextual variable (resolved from `job_id`/`token`). A value that did /// not is job-independent and safe to cache; one that did must not be cached or shared across @@ -870,6 +892,7 @@ pub async fn transform_json_value_tracked( token: Option<&str>, depth: u8, used_job_context: &std::sync::atomic::AtomicBool, + resource_path: Option<&str>, ) -> Result { if depth >= MAX_RESOURCE_INTERPOLATION_DEPTH { return Err(Error::internal_err(format!( @@ -879,6 +902,7 @@ pub async fn transform_json_value_tracked( match v { Value::String(y) if y.starts_with("$var:") => { let path = y.strip_prefix("$var:").unwrap(); + check_interpolation_scope(db_with_opt_authed, "variables", path, resource_path)?; let v = crate::variables::get_value_internal(&db_with_opt_authed, workspace, path, false) @@ -887,6 +911,7 @@ pub async fn transform_json_value_tracked( } Value::String(y) if y.starts_with("$jsonvar:") => { let path = y.strip_prefix("$jsonvar:").unwrap(); + check_interpolation_scope(db_with_opt_authed, "variables", path, resource_path)?; let v = crate::variables::get_value_internal(&db_with_opt_authed, workspace, path, false) @@ -902,6 +927,7 @@ pub async fn transform_json_value_tracked( "Invalid resource path: {path}" ))); } + check_interpolation_scope(db_with_opt_authed, "resources", path, None)?; let mut tx: Transaction<'_, Postgres> = db_with_opt_authed.begin().await?; let v = sqlx::query_scalar!( "SELECT value from resource WHERE path = $1 AND workspace_id = $2", @@ -921,6 +947,7 @@ pub async fn transform_json_value_tracked( token, depth + 1, used_job_context, + Some(path), ) .await } else { @@ -1020,6 +1047,7 @@ pub async fn transform_json_value_tracked( token, depth + 1, used_job_context, + resource_path, ) .await?; } @@ -1044,6 +1072,7 @@ pub async fn transform_json_value_tracked( token, depth + 1, used_job_context, + resource_path, ) .await?; m.insert(a.clone(), v); @@ -3667,38 +3696,6 @@ struct GitRepositoryResource { branch: Option, } -/// Checks whether an IP address belongs to a private, loopback, link-local, or -/// otherwise reserved range that should not be reachable from git operations. -fn is_private_or_reserved_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - // RFC 1122 "this network": the whole /8, not just the - // unspecified address `is_unspecified()` matches — stacks that - // map 0.x.y.z onto the local host make `0.0.0.1` a bypass. - || v4.octets()[0] == 0 // 0.0.0.0/8 - || v4.is_broadcast() - // 100.64.0.0/10 (Carrier-grade NAT / CGNAT) - || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) - } - IpAddr::V6(v6) => { - let seg = v6.segments(); - v6.is_loopback() - || v6.is_unspecified() - // fc00::/7 (unique local address) — std has no stable is_unique_local() - || (seg[0] & 0xfe00) == 0xfc00 - // fe80::/10 (link-local) — std has no stable is_unicast_link_local() - || (seg[0] & 0xffc0) == 0xfe80 - // IPv4-mapped IPv6 (::ffff:x.x.x.x) — check the inner v4 - || v6.to_ipv4_mapped().map_or(false, |v4| { - is_private_or_reserved_ip(&IpAddr::V4(v4)) - }) - } - } -} - /// Extracts the hostname from a git URL. /// /// Handles standard URLs (`https://host/path`, `ssh://user@host/path`) and @@ -3868,7 +3865,7 @@ async fn validate_git_url(url: &str, caller: GitRemoteCaller) -> Result<()> { // Check literal IP addresses if let Ok(ip) = host.parse::() { - if is_private_or_reserved_ip(&ip) { + if is_private_ip(&ip) { return Err(Error::BadRequest(format!( "Git URLs targeting private or reserved IP addresses are not allowed.{hint}" ))); @@ -3891,7 +3888,7 @@ async fn validate_git_url(url: &str, caller: GitRemoteCaller) -> Result<()> { ))); } for addr in addrs { - if is_private_or_reserved_ip(&addr.ip()) { + if is_private_ip(&addr.ip()) { return Err(Error::BadRequest(format!( "Git URL hostname resolves to a private or reserved IP address.{hint}" ))); @@ -5147,73 +5144,6 @@ mod tests { ); } - #[test] - fn test_is_private_or_reserved_ip() { - use std::net::IpAddr; - // Loopback - assert!(is_private_or_reserved_ip( - &"127.0.0.1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"127.0.0.2".parse::().unwrap() - )); - // Private ranges - assert!(is_private_or_reserved_ip( - &"10.0.0.1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"172.16.0.1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"192.168.1.1".parse::().unwrap() - )); - // Link-local / cloud metadata - assert!(is_private_or_reserved_ip( - &"169.254.169.254".parse::().unwrap() - )); - // CGNAT - assert!(is_private_or_reserved_ip( - &"100.64.0.1".parse::().unwrap() - )); - // "This network" 0.0.0.0/8, not just the unspecified address - assert!(is_private_or_reserved_ip( - &"0.0.0.0".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"0.0.0.1".parse::().unwrap() - )); - // IPv6 loopback - assert!(is_private_or_reserved_ip(&"::1".parse::().unwrap())); - // IPv6 unique local address (fc00::/7) - assert!(is_private_or_reserved_ip( - &"fd00::1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"fc00::1".parse::().unwrap() - )); - // IPv6 link-local (fe80::/10) - assert!(is_private_or_reserved_ip( - &"fe80::1".parse::().unwrap() - )); - // IPv4-mapped IPv6 - assert!(is_private_or_reserved_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); - // Public IPs should pass - assert!(!is_private_or_reserved_ip( - &"8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_or_reserved_ip( - &"140.82.121.4".parse::().unwrap() - )); - // Public IPv6 should pass - assert!(!is_private_or_reserved_ip( - &"2606:2800:220:1:248:1893:25c8:1946" - .parse::() - .unwrap() - )); - } - // A caller let through to private hosts must still hit the scheme check. #[tokio::test] async fn test_validate_git_url_blocks_file_scheme() { diff --git a/backend/windmill-trigger/src/global_handler.rs b/backend/windmill-trigger/src/global_handler.rs index 6e1c92d588..ec74b4f139 100644 --- a/backend/windmill-trigger/src/global_handler.rs +++ b/backend/windmill-trigger/src/global_handler.rs @@ -14,6 +14,7 @@ use windmill_api_jobs::execution::cancel_jobs; use windmill_common::{ db::{UserDB, DB}, error::{self, Error, Result}, + feature_usage::log_feature_usage, jobs::{delete_jobs, JobTriggerKind}, triggers::TriggerMetadata, }; @@ -168,14 +169,16 @@ pub async fn resume_suspended_trigger_jobs( .await? }; - let trigger_metadata = TriggerMetadata::new(Some(trigger_path.clone()), trigger_kind); + let trigger_metadata = TriggerMetadata::new(Some(trigger_path.clone()), trigger_kind.clone()); let l = jobs.len(); + let mut unsuspended_in_place = 0; for job in jobs { - // If job was created before trigger was edited, simply update it to unsuspend + // If job was created after trigger was edited, simply update it to unsuspend // instead of deleting and repushing if job.created_at > trigger.edited_at { + unsuspended_in_place += 1; // Map the placeholder unassigned kind back to its assigned counterpart so // singlestepflow wrappers (retry/error_handler/skip_handler) keep their // flow-orchestrator identity. @@ -209,7 +212,7 @@ pub async fn resume_suspended_trigger_jobs( .execute(&mut *tx) .await?; } else { - // Job was created after trigger edit - delete and repush with new configuration + // Job was created before trigger edit - delete and repush with new configuration // Pass the transaction to trigger_runnable_inner so everything is in the same transaction let (_uuid, _delete_after_use, _early_return, _has_failure_module, tx_o) = trigger_runnable_inner( @@ -274,6 +277,16 @@ pub async fn resume_suspended_trigger_jobs( tx.commit().await?; + // A repushed job is counted as `fired` inside `push`, before this commit, so + // a resume that rolls back still counts it; accepted for a telemetry counter. + // One unsuspended in place never goes through `push`, so it is counted here. + for _ in 0..unsuspended_in_place { + log_feature_usage("trigger", "fired", trigger_kind.as_str()); + } + for _ in 0..l { + log_feature_usage("trigger", "resumed", trigger_kind.as_str()); + } + Ok(Json(format!("Reassigned {} jobs", l))) } @@ -334,6 +347,9 @@ pub async fn cancel_suspended_trigger_jobs( true, ) .await?; + for _ in 0..cancelled_jobs.0.len() { + log_feature_usage("trigger", "discarded", trigger_kind.as_str()); + } Ok(Json(format!("Canceled {} jobs", cancelled_jobs.0.len()))) } else { Ok(Json(format!("No jobs to cancel"))) diff --git a/backend/windmill-types/src/schedule.rs b/backend/windmill-types/src/schedule.rs index 75a48f8798..ce3add9605 100644 --- a/backend/windmill-types/src/schedule.rs +++ b/backend/windmill-types/src/schedule.rs @@ -57,6 +57,10 @@ pub struct Schedule { pub dynamic_skip: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + // Runtime state, not configuration: kept out of exports and git sync. + #[sqlx(default)] + #[serde(skip)] + pub late_run_streak: i32, } impl Schedule { diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index 943703472f..d3bd976f21 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -1475,6 +1475,7 @@ pub async fn do_duckdb( &job.id, client, &mut hidden_passwords, + job_dir, ) .await?, ); @@ -1490,13 +1491,19 @@ pub async fn do_duckdb( &mut hidden_passwords, &job.workspace_id, materialize.as_ref().map(|(_, m)| m.asset_path.as_str()), + job_dir, ) .await? { probe_blocks.extend(q); - } else if let Some(q) = - transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) - .await? + } else if let Some(q) = transform_attach_datatable( + &query_block, + conn, + &mut hidden_passwords, + job, + job_dir, + ) + .await? { probe_blocks.extend(q); } else { @@ -1552,6 +1559,7 @@ pub async fn do_duckdb( &job.id, client, &mut hidden_passwords, + job_dir, ) .await?, ); @@ -1567,13 +1575,19 @@ pub async fn do_duckdb( &mut hidden_passwords, &job.workspace_id, materialize.as_ref().map(|(_, m)| m.asset_path.as_str()), + job_dir, ) .await? { v.extend(ducklake_query); - } else if let Some(datatable_query) = - transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) - .await? + } else if let Some(datatable_query) = transform_attach_datatable( + &query_block, + conn, + &mut hidden_passwords, + job, + job_dir, + ) + .await? { v.extend(datatable_query); } else { @@ -2240,11 +2254,80 @@ fn parse_attach_db_resource<'a>(query: &'a str) -> Option Result { +/// The verification a DuckDB postgres attach keeps, as its libpq `sslmode` and `sslrootcert`. +/// +/// Attaches have always turned verify-ca and verify-full into `require`, which resources rely on. +/// A connection that explicitly refuses invalid certificates — the external instance cluster's — +/// keeps its mode instead: under `require` its shared password would go to whichever server +/// answers. DuckDB's libpq takes one root file, so it gets the system bundle plus the configured +/// certificate, written in the job directory: a resource's certificate is workspace-controlled, so +/// a file per distinct one has to go with the job rather than pile up on the worker. +fn pg_attach_verification<'a>( + res: &'a PgDatabase, + job_dir: &str, +) -> Result> { + let mode = match res.sslmode.as_deref() { + // The rule every other Postgres connection uses, so a resource that verifies elsewhere is + // not quietly downgraded here. + Some(mode @ ("verify-ca" | "verify-full")) if !res.verify_mode_skips_verification() => mode, + _ => return Ok(None), + }; + let bundle = windmill_common::system_ca_bundle() + .map(std::fs::read_to_string) + .transpose() + .map_err(|e| Error::ExecutionErr(format!("Failed to read the system CA bundle: {e}")))? + .unwrap_or_default(); + let pem = res.root_certificate_pem.as_deref().unwrap_or_default(); + if bundle.is_empty() && pem.is_empty() { + return Err(Error::ExecutionErr(format!( + "sslmode {mode} needs a root certificate, and this worker has no system CA bundle" + ))); + } + let roots = format!("{bundle}\n{pem}\n"); + use sha2::Digest; + let path = std::path::Path::new(job_dir).join(format!( + "pg_roots_{}.pem", + hex::encode(&sha2::Sha256::digest(roots.as_bytes())[..8]) + )); + // The job's modules are written in this directory first, so whatever already sits at this path + // may be caller-supplied: trusting it would let the caller pick the CA. Replace it, and + // `create_new` refuses to write through anything recreated there. + let write_roots = || -> std::io::Result<()> { + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + use std::io::Write; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)? + .write_all(roots.as_bytes()) + }; + write_roots() + .map_err(|e| Error::ExecutionErr(format!("Failed to write root certificates: {e}")))?; + Ok(Some((mode, path))) +} + +fn pg_attach_uri(res: &PgDatabase, job_dir: &str) -> Result { + let uri = res.to_uri(); + let Some((mode, roots)) = pg_attach_verification(res, job_dir)? else { + return Ok(uri); + }; + let base = uri.strip_suffix("?sslmode=require").ok_or_else(|| { + Error::internal_err("unexpected sslmode in a postgres connection URI".to_string()) + })?; + Ok(format!( + "{base}?sslmode={mode}&sslrootcert={}", + urlencoding::encode(&roots.to_string_lossy()) + )) +} + +fn format_attach_db_conn_str(db_resource: Value, db_type: &str, job_dir: &str) -> Result { let s = match db_type.to_lowercase().as_str() { "postgres" | "postgresql" => { let res: PgDatabase = serde_json::from_value(db_resource)?; - res.to_uri() + pg_attach_uri(&res, job_dir)? } #[cfg(feature = "mysql")] "mysql" => { @@ -2316,6 +2399,7 @@ async fn transform_attach_db_resource_query( job_id: &Uuid, client: &AuthedClient, hidden_passwords: &mut Arc>>, + job_dir: &str, ) -> Result> { let db_resource: Value = client .get_resource_value_interpolated(parsed.resource_path, Some(job_id.to_string())) @@ -2323,8 +2407,14 @@ async fn transform_attach_db_resource_query( if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) { hidden_passwords.lock().unwrap().push(pwd.to_string()); } - db_resource_to_attach_statements(db_resource, parsed.name, parsed.db_type, parsed.extra_args) - .await + db_resource_to_attach_statements( + db_resource, + parsed.name, + parsed.db_type, + parsed.extra_args, + job_dir, + ) + .await } async fn db_resource_to_attach_statements( @@ -2332,11 +2422,12 @@ async fn db_resource_to_attach_statements( ident_name: &str, db_type: &str, extra_args: Option<&str>, + job_dir: &str, ) -> Result> { // Escape single quotes: the connection string is built from resource fields // (host/db/user/password) and embedded in a single-quoted DuckDB literal, so an // unescaped quote in any field would otherwise break out of the ATTACH statement. - let conn_str = format_attach_db_conn_str(db_resource, db_type)?.replace('\'', "''"); + let conn_str = format_attach_db_conn_str(db_resource, db_type, job_dir)?.replace('\'', "''"); let attach_str = format!( "ATTACH '{}' as {} (TYPE {}{});", conn_str, @@ -2359,6 +2450,7 @@ async fn transform_attach_ducklake( hidden_passwords: &mut Arc>>, w_id: &str, materialize_target: Option<&str>, + job_dir: &str, ) -> Result>> { lazy_static::lazy_static! { static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'ducklake(://[^':]+)?'\s*AS\s+([^ ;]+)\s*(\([^)]*\))?").unwrap(); @@ -2391,7 +2483,9 @@ async fn transform_attach_ducklake( format!(", {}", user_extra_args) }; let db_type = match ducklake.catalog.resource_type { - DucklakeCatalogResourceType::Instance => "postgres", + DucklakeCatalogResourceType::Instance | DucklakeCatalogResourceType::ExternalInstance => { + "postgres" + } _ => ducklake.catalog.resource_type.as_ref(), }; @@ -2407,7 +2501,7 @@ async fn transform_attach_ducklake( // single-quoted DuckDB literals below, so an unescaped quote in a resource // field would break out of the ATTACH statement. let db_conn_str = - format_attach_db_conn_str(ducklake.catalog_resource, db_type)?.replace('\'', "''"); + format_attach_db_conn_str(ducklake.catalog_resource, db_type, job_dir)?.replace('\'', "''"); let storage = ducklake .storage .storage @@ -2462,6 +2556,7 @@ async fn transform_attach_ducklake( defer, materialize_target, hidden_passwords, + job_dir, )?); } Ok(Some(statements)) @@ -2495,6 +2590,7 @@ fn fork_defer_statements( defer: &windmill_common::workspaces::DucklakeForkDefer, materialize_target: Option<&str>, hidden_passwords: &mut Arc>>, + job_dir: &str, ) -> Result> { let mut stmts = vec![]; if defer.ancestors.is_empty() { @@ -2507,12 +2603,13 @@ fn fork_defer_statements( hidden_passwords.lock().unwrap().push(pwd.to_string()); } let db_type = match a.catalog.resource_type { - DucklakeCatalogResourceType::Instance => "postgres", + DucklakeCatalogResourceType::Instance + | DucklakeCatalogResourceType::ExternalInstance => "postgres", _ => a.catalog.resource_type.as_ref(), }; stmts.push(get_attach_db_install_str(db_type)?.to_string()); - let conn_str = - format_attach_db_conn_str(a.catalog_resource.clone(), db_type)?.replace('\'', "''"); + let conn_str = format_attach_db_conn_str(a.catalog_resource.clone(), db_type, job_dir)? + .replace('\'', "''"); let storage = a .storage .storage @@ -2631,6 +2728,7 @@ async fn transform_attach_datatable( conn: &Connection, hidden_passwords: &mut Arc>>, job: &MiniPulledJob, + job_dir: &str, ) -> Result>> { let Some(attached) = parse_attach_datatable(query) else { return Ok(None); @@ -2673,6 +2771,7 @@ async fn transform_attach_datatable( Ok(Some(pg_secret_attach_statements( db_resource, attached.alias, + job_dir, )?)) } @@ -2693,17 +2792,32 @@ fn datatable_secret_name(alias: &str) -> String { /// ATTACH a datatable's postgres database through a DuckDB TEMPORARY SECRET holding /// the connection parameters; only sslmode and options ride in the ATTACH string. -fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result> { +fn pg_secret_attach_statements( + db_resource: Value, + alias_name: &str, + job_dir: &str, +) -> Result> { let res: PgDatabase = serde_json::from_value(db_resource)?; // Escape single quotes: each field is embedded in a single-quoted DuckDB literal, // so an unescaped quote would break out of the CREATE SECRET statement. let esc = |s: &str| s.replace('\'', "''"); // The postgres secret type has no sslmode parameter, so it goes in the ATTACH // string; only the libpq values PgDatabase::to_uri collapses to are forwarded. - let sslmode = match res.sslmode.as_deref() { - Some("disable") => "disable", - Some("require") | Some("verify-ca") | Some("verify-full") => "require", - _ => "prefer", + let sslmode = match pg_attach_verification(&res, job_dir)? { + // A libpq keyword/value string: the path is quoted for libpq, then for the DuckDB literal. + Some((mode, roots)) => format!( + "{mode} sslrootcert=''{}''", + roots + .to_string_lossy() + .replace('\\', "\\\\") + .replace('\'', "\\''") + ), + None => match res.sslmode.as_deref() { + Some("disable") => "disable", + Some("require") | Some("verify-ca") | Some("verify-full") => "require", + _ => "prefer", + } + .to_string(), }; // Nor an options parameter. The value is quoted for libpq's keyword/value syntax first, // then escaped for the DuckDB literal around it. @@ -2805,6 +2919,76 @@ pub struct Arg { mod tests { use super::*; + #[test] + fn pg_attach_keeps_verification_only_when_required() { + let job_dir = std::env::temp_dir().join(format!("wm-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&job_dir).unwrap(); + let job_dir = job_dir.to_string_lossy().to_string(); + let pg = |sslmode: &str, accept_invalid_certs: Option| PgDatabase { + host: "db.internal".to_string(), + user: Some("custom_instance_user".to_string()), + password: Some("pw".to_string()), + port: None, + sslmode: Some(sslmode.to_string()), + dbname: "dt".to_string(), + root_certificate_pem: Some("-----BEGIN CERTIFICATE-----test".to_string()), + accept_invalid_certs, + use_iam_auth: None, + region: None, + options: None, + }; + let uri = pg_attach_uri(&pg("verify-full", Some(false)), &job_dir).unwrap(); + assert!(uri.contains("?sslmode=verify-full&sslrootcert="), "{uri}"); + let root = urlencoding::decode(uri.split("sslrootcert=").nth(1).unwrap()).unwrap(); + assert!(std::fs::read_to_string(root.as_ref()) + .unwrap() + .contains("-----BEGIN CERTIFICATE-----test")); + // A file a job module planted under the same name is not trusted. + std::fs::write(root.as_ref(), "-----BEGIN CERTIFICATE-----planted").unwrap(); + assert_eq!( + pg_attach_uri(&pg("verify-full", Some(false)), &job_dir).unwrap(), + uri + ); + assert!(!std::fs::read_to_string(root.as_ref()) + .unwrap() + .contains("planted")); + // Every certificate a job attaches keeps its own file: one attach must not evict another's. + for i in 0..40 { + let mut other = pg("verify-full", Some(false)); + other.root_certificate_pem = Some(format!("-----BEGIN CERTIFICATE-----{i}")); + let other = pg_attach_uri(&other, &job_dir).unwrap(); + let path = urlencoding::decode(other.split("sslrootcert=").nth(1).unwrap()).unwrap(); + assert!(std::path::Path::new(path.as_ref()).is_file(), "{path}"); + } + assert!( + std::path::Path::new(root.as_ref()).is_file(), + "the first file is still there" + ); + let external = serde_json::to_value(pg("verify-full", Some(false))).unwrap(); + let attach = &pg_secret_attach_statements(external, "dt", &job_dir).unwrap()[3]; + assert!( + attach.starts_with(&format!( + "ATTACH 'sslmode=verify-full sslrootcert=''{}''", + root + )), + "{attach}" + ); + // A resource carrying a root certificate verifies without opting in, as it does on every + // other Postgres path; one carrying neither keeps the historical downgrade. + assert!(pg_attach_uri(&pg("verify-full", None), &job_dir) + .unwrap() + .contains("?sslmode=verify-full&sslrootcert=")); + let mut bare = pg("verify-full", None); + bare.root_certificate_pem = None; + assert!(pg_attach_uri(&bare, &job_dir) + .unwrap() + .ends_with("?sslmode=require")); + assert!(pg_attach_uri(&pg("require", Some(false)), &job_dir) + .unwrap() + .ends_with("?sslmode=require")); + std::fs::remove_dir_all(&job_dir).unwrap(); + } + #[test] fn attach_datatable_parses_name_and_role() { let reference_of = |q: &str| parse_attach_datatable(q).unwrap().reference; @@ -2956,7 +3140,7 @@ mod tests { let mut defer = test_fork_defer(vec![("orders", false)], vec![]); defer.ancestors[0].extra_args = Some("ENCRYPTED true".to_string()); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let attach = stmts .iter() .find(|s| s.starts_with("ATTACH IF NOT EXISTS")) @@ -2980,7 +3164,7 @@ mod tests { vec![], ); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let joined = stmts.join("\n"); assert!( joined.contains( @@ -3012,7 +3196,7 @@ mod tests { fn test_fork_defer_statements_shape() { let defer = test_fork_defer(vec![("orders", false), ("dim", true)], vec![]); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let joined = stmts.join("\n"); // Ancestor attach: read-only, idempotent, never auto-migrating or auto-creating. assert!(joined.contains("ATTACH IF NOT EXISTS"), "{joined}"); @@ -3038,9 +3222,15 @@ mod tests { // Target currently a defer view → skip its CREATE, drop the view (+ companion). let defer = test_fork_defer(vec![("orders", false)], vec!["orders", "orders_current"]); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = - fork_defer_statements("lake", "_wm_target", &defer, Some("lake/orders"), &mut hp) - .unwrap(); + let stmts = fork_defer_statements( + "lake", + "_wm_target", + &defer, + Some("lake/orders"), + &mut hp, + "/tmp", + ) + .unwrap(); let joined = stmts.join("\n"); assert!(!joined.contains("CREATE VIEW"), "{joined}"); assert!( @@ -3055,15 +3245,22 @@ mod tests { // Target already a real table (NOT in fork_views, e.g. after a failed re-run whose // status can't be trusted) → no DROP VIEW, or the job would wedge on a type mismatch. let defer = test_fork_defer(vec![("orders", false)], vec![]); - let stmts = - fork_defer_statements("lake", "_wm_target", &defer, Some("lake/orders"), &mut hp) - .unwrap(); + let stmts = fork_defer_statements( + "lake", + "_wm_target", + &defer, + Some("lake/orders"), + &mut hp, + "/tmp", + ) + .unwrap(); assert!(!stmts.join("\n").contains("DROP VIEW"), "{stmts:?}"); // Target in a different lake → this lake's defer views are untouched. let defer = test_fork_defer(vec![("orders", false)], vec!["orders"]); let stmts = - fork_defer_statements("lake", "dl", &defer, Some("other/orders"), &mut hp).unwrap(); + fork_defer_statements("lake", "dl", &defer, Some("other/orders"), &mut hp, "/tmp") + .unwrap(); let joined = stmts.join("\n"); assert!( joined.contains("CREATE VIEW IF NOT EXISTS dl.\"orders\""), @@ -3076,7 +3273,7 @@ mod tests { fn test_fork_defer_statements_schema_qualified() { let defer = test_fork_defer(vec![("staging.raw", false)], vec![]); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let joined = stmts.join("\n"); assert!( joined.contains("CREATE SCHEMA IF NOT EXISTS dl.\"staging\";"), @@ -3902,7 +4099,7 @@ mod tests { "dbname": "mydb", "sslmode": "require" }); - let result = format_attach_db_conn_str(db_resource, "postgres").unwrap(); + let result = format_attach_db_conn_str(db_resource, "postgres", "/tmp").unwrap(); // Should be in URI format: postgres://user:password@host:port/dbname?sslmode=require assert!(result.starts_with("postgres://")); assert!(result.contains("admin:secret123@localhost:5432/mydb")); @@ -3915,7 +4112,7 @@ mod tests { "host": "db.example.com", "dbname": "production" }); - let result = format_attach_db_conn_str(db_resource, "postgres").unwrap(); + let result = format_attach_db_conn_str(db_resource, "postgres", "/tmp").unwrap(); // Should be in URI format with defaults: postgres://postgres:@host:5432/dbname?sslmode=prefer assert!(result.starts_with("postgres://")); assert!(result.contains("@db.example.com:5432/production")); @@ -3928,7 +4125,7 @@ mod tests { "host": "localhost", "dbname": "test" }); - let result = format_attach_db_conn_str(db_resource, "postgresql").unwrap(); + let result = format_attach_db_conn_str(db_resource, "postgresql", "/tmp").unwrap(); // Should be in URI format (postgresql is treated the same as postgres) assert!(result.starts_with("postgres://")); assert!(result.contains("@localhost:5432/test")); @@ -3945,7 +4142,7 @@ mod tests { "dbname": "wm_datatables", "sslmode": "require" }); - let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let stmts = pg_secret_attach_statements(db_resource, "dt", "/tmp").unwrap(); assert_eq!(stmts[0], "INSTALL postgres;"); assert_eq!(stmts[1], "LOAD postgres;"); let secret_name = datatable_secret_name("dt"); @@ -3976,7 +4173,7 @@ mod tests { if let Some(s) = input { db_resource["sslmode"] = json!(s); } - let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let stmts = pg_secret_attach_statements(db_resource, "dt", "/tmp").unwrap(); assert!( stmts[3].starts_with(&format!("ATTACH 'sslmode={expected}'")), "sslmode {input:?} → {}", @@ -3988,7 +4185,7 @@ mod tests { #[test] fn test_pg_secret_attach_statements_options() { let db_resource = json!({ "host": "h", "dbname": "d", "options": r"-c search_path='a\b'" }); - let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let stmts = pg_secret_attach_statements(db_resource, "dt", "/tmp").unwrap(); let secret_name = datatable_secret_name("dt"); assert_eq!( stmts[3], @@ -4012,7 +4209,7 @@ mod tests { let db_resource = json!({ "project_id": "my-gcp-project" }); - let result = format_attach_db_conn_str(db_resource, "bigquery").unwrap(); + let result = format_attach_db_conn_str(db_resource, "bigquery", "/tmp").unwrap(); assert_eq!(result, "project=my-gcp-project"); } @@ -4021,7 +4218,7 @@ mod tests { let db_resource = json!({ "other_field": "value" }); - let result = format_attach_db_conn_str(db_resource, "bigquery"); + let result = format_attach_db_conn_str(db_resource, "bigquery", "/tmp"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("project_id")); } @@ -4029,7 +4226,7 @@ mod tests { #[test] fn test_format_attach_db_conn_str_unsupported_type() { let db_resource = json!({}); - let result = format_attach_db_conn_str(db_resource, "oracle"); + let result = format_attach_db_conn_str(db_resource, "oracle", "/tmp"); assert!(result.is_err()); assert!(result .unwrap_err() @@ -4043,7 +4240,7 @@ mod tests { "host": "localhost", "dbname": "test" }); - let result = format_attach_db_conn_str(db_resource, "POSTGRES").unwrap(); + let result = format_attach_db_conn_str(db_resource, "POSTGRES", "/tmp").unwrap(); // Should be in URI format assert!(result.starts_with("postgres://")); assert!(result.contains("@localhost:5432/test")); @@ -4060,7 +4257,7 @@ mod tests { "database": "app_db", "ssl": true }); - let result = format_attach_db_conn_str(db_resource, "mysql").unwrap(); + let result = format_attach_db_conn_str(db_resource, "mysql", "/tmp").unwrap(); assert!(result.contains("database=app_db")); assert!(result.contains("host=mysql.example.com")); assert!(result.contains("ssl_mode=required")); @@ -4077,7 +4274,7 @@ mod tests { "database": "test", "ssl": false }); - let result = format_attach_db_conn_str(db_resource, "mysql").unwrap(); + let result = format_attach_db_conn_str(db_resource, "mysql", "/tmp").unwrap(); assert!(result.contains("ssl_mode=disabled")); } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index a2ddac713e..1b4cfdd771 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -2321,6 +2321,7 @@ fn start_interactive_worker_shell( job_completed_tx: JobCompletedSender, base_internal_url: String, worker_dir: String, + unreported_job: Arc>>, ) -> JoinHandle<()> { tokio::spawn(async move { let started_at = Instant::now(); @@ -2407,6 +2408,8 @@ fn start_interactive_worker_shell( flow_runners, } = extract_job_and_perms(job, &conn).await; + *unreported_job.lock().unwrap() = Some((job.id, job.workspace_id.clone())); + let authed_client = AuthedClient::new( base_internal_url.to_owned(), job.workspace_id.clone(), @@ -2899,6 +2902,11 @@ pub async fn run_worker( // Only jobs run by this process count towards EXIT_AFTER_N_JOBS: the point is the age of // the environment, not the lifetime total. let mut jobs_executed_in_env: u64 = 0; + // The job poller only records a job in `worker_ping` once it has run for a poll interval, so + // the main-loop ping reports the last pulled job for shorter ones. The interactive shell runs + // jobs under this worker's name too and must set the same slot, or the ping would overwrite a + // shell job its poller recorded with an older main-loop job. Sent once, then left to the poller. + let unreported_job: Arc>> = Default::default(); let is_dedicated_worker: bool = { let config = WORKER_CONFIG.load(); @@ -3016,6 +3024,7 @@ pub async fn run_worker( job_completed_tx.clone(), base_internal_url.to_owned(), worker_dir.clone(), + unreported_job.clone(), ); Some(it_shell) @@ -3219,6 +3228,7 @@ pub async fn run_worker( let read_cgroups = *REFRESH_CGROUP_READINGS && last_reading.elapsed().as_secs() > NUM_SECS_READINGS; + let last_job = unreported_job.lock().unwrap().take(); update_worker_ping_full( &conn, read_cgroups, @@ -3228,6 +3238,7 @@ pub async fn run_worker( &mut occupancy_metrics, &killpill_tx, ip, + last_job.as_ref().map(|(id, w_id)| (*id, w_id.as_str())), ) .await; @@ -3562,6 +3573,7 @@ pub async fn run_worker( last_executed_job = None; jobs_executed += 1; + *unreported_job.lock().unwrap() = Some((job.id, job.workspace_id.clone())); let mut dirties_env = dirties_worker_env( job.kind, &job.tag, diff --git a/backend/windmill-worker/src/worker_utils.rs b/backend/windmill-worker/src/worker_utils.rs index 67e9831481..3a313e9360 100644 --- a/backend/windmill-worker/src/worker_utils.rs +++ b/backend/windmill-worker/src/worker_utils.rs @@ -28,6 +28,7 @@ pub(crate) async fn update_worker_ping_full( occupancy_metrics: &mut OccupancyMetrics, killpill_tx: &KillpillSender, ip: Option<&str>, + last_job: Option<(Uuid, &str)>, ) { let wc = WORKER_CONFIG.load(); let tags = wc.worker_tags.clone(); @@ -67,6 +68,7 @@ pub(crate) async fn update_worker_ping_full( occupancy_rate_30m, native_mode, ip, + last_job, ) }) .retry( @@ -114,6 +116,7 @@ async fn update_worker_ping_full_inner( occupancy_rate_30m: Option, native_mode: bool, ip: Option<&str>, + last_job: Option<(Uuid, &str)>, ) -> anyhow::Result<()> { match conn { Connection::Sql(db) => { @@ -131,6 +134,7 @@ async fn update_worker_ping_full_inner( occupancy_rate_30m, native_mode, ip, + last_job, db, ) .await?; @@ -141,8 +145,8 @@ async fn update_worker_ping_full_inner( UPDATE_PING_URL, None, &Ping { - last_job_executed: None, - last_job_workspace_id: None, + last_job_executed: last_job.map(|(id, _)| id), + last_job_workspace_id: last_job.map(|(_, w_id)| w_id.to_string()), worker_instance: None, ip: ip.map(str::to_string), tags: Some(tags.to_vec()), diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index e8d5f78831..3edd3c5318 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.818.0"; +export const VERSION = "v1.819.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/README.md b/chat-sdk/README.md index 1d09b68265..62a5467a47 100644 --- a/chat-sdk/README.md +++ b/chat-sdk/README.md @@ -229,7 +229,8 @@ set) means the turn could not run or be followed at all, such as a refused reque Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`, `newConversation()`, `selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`, `deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`, -`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's +`resumeTurn(turn)`, `refreshMessages()` (reads what another tab added to the open +conversation), `destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries `isTest`. A rename keeps the conversation's place in the list. Switching conversations stops following the current answer; the flow keeps running and, with server history, @@ -261,6 +262,27 @@ user needs read and write on `windmill_uploads/*`, which the default rules grant `job_helpers`, so a restricted token needs `job_helpers:write`; a sandboxed raw app cannot request that scope today, so attachments are not available there yet. +## One turn at a time + +A conversation answers one message at a time, and Windmill enforces it: a message sent +while its previous turn still runs (from another tab, or before a reload) is refused. +`sendMessage` then rejects with a `TurnRunningError` and shows nothing of the message. +With server history, a listed `Conversation` also carries `runningTurn` while it is +answering. Either way, `resumeTurn(turn)` follows that turn in the selected +conversation: its answer streams in from the start and the turn finishes as if it had +been sent here, after which the message can be sent again. + +```ts +// `runningTurn` comes from the listing, so read it before selecting. +await chat.loadConversations() +const running = chat.getState().conversations.find((c) => c.id === id)?.runningTurn +await chat.selectConversation(id) +if (running) await chat.resumeTurn(running) +``` + +A dropped connection to the answer is retried; when it keeps failing, the chat stops +streaming and waits for the flow's result instead, so the turn still ends. + ## History Windmill stores every conversation of a chat-mode flow, and each Windmill user sees diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index bd492f9d21..d74b6217ac 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.818.0", + "version": "1.819.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.818.0", + "version": "1.819.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 9094398139..7de263a3b9 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.818.0", + "version": "1.819.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/chat-sdk/src/ai-sdk.ts b/chat-sdk/src/ai-sdk.ts index d93bb99e2f..0ff422562b 100644 --- a/chat-sdk/src/ai-sdk.ts +++ b/chat-sdk/src/ai-sdk.ts @@ -169,9 +169,19 @@ function chunkStream( for (const e of event.events) parts.apply(e) continue } + // A result polled after the stream failed: the round that was streaming stopped + // wherever the connection did. Sent chunks cannot be taken back, so the answer's + // rest follows them, or the whole answer when it does not continue them. + const cut = parts.openText ?? '' parts.closeOpen() failure = await failureText(api, entry.jobId, event.result, signal) - if (failure === undefined && !parts.streamedText) { + if (failure === undefined && event.streamLost) { + const answer = extractChatAnswer(event.result) + if (answer !== undefined && answer !== cut) { + parts.text(answer.startsWith(cut) ? answer.slice(cut.length) : answer) + parts.closeOpen() + } + } else if (failure === undefined && !parts.streamedText) { // No agent streamed: the flow's result is the answer. const answer = extractChatAnswer(event.result) if (answer !== undefined) { @@ -216,6 +226,8 @@ async function failureText( */ class PartWriter { streamedText = false + /** The text of the round still streaming, until a tool call or the end closes it. */ + openText: string | undefined #textId: string | undefined #reasoningId: string | undefined #started = new Set() @@ -227,8 +239,10 @@ class PartWriter { this.streamedText = true if (!this.#textId) { this.#textId = randomId() + this.openText = '' this.emit({ type: 'text-start', id: this.#textId }) } + this.openText += delta this.emit({ type: 'text-delta', id: this.#textId, delta }) } @@ -248,6 +262,7 @@ class PartWriter { if (this.#textId) { this.emit({ type: 'text-end', id: this.#textId }) this.#textId = undefined + this.openText = undefined } } diff --git a/chat-sdk/src/api.ts b/chat-sdk/src/api.ts index a14e9800a1..ed97832aae 100644 --- a/chat-sdk/src/api.ts +++ b/chat-sdk/src/api.ts @@ -13,13 +13,53 @@ export interface WindmillChatApiOptions { export class WindmillApiError extends Error { constructor( message: string, - readonly status: number + readonly status: number, + /** The response body, as the server sent it. */ + readonly body?: string ) { super(message) this.name = 'WindmillApiError' } } +/** The turn a conversation is still answering, as the server reports it. */ +export interface RunningTurn { + jobId: string + /** `created_seq` of the user message that started the turn. */ + userSeq: number +} + +/** + * A message was sent to a conversation whose turn is still running. The server refuses + * it (409) so two runs never write one agent memory; `turn` is the run to follow instead. + */ +export class TurnRunningError extends Error { + constructor( + message: string, + readonly turn: RunningTurn + ) { + super(message) + this.name = 'TurnRunningError' + } +} + +/** + * The running turn named by a run's 409 body, or undefined for any other body. Exported + * for a custom `run` that calls Windmill through its own client: it rethrows the refusal + * as a `TurnRunningError` so the chat can follow the running turn. + */ +export function turnRunningError(body: string): TurnRunningError | undefined { + try { + const parsed = JSON.parse(body) as { error?: unknown; running_turn?: FlowConversation['running_turn'] } + const turn = parsed?.running_turn + if (!turn || typeof turn.job_id !== 'string' || typeof turn.user_seq !== 'number') return undefined + const message = typeof parsed.error === 'string' ? parsed.error : 'this conversation is still answering a message' + return new TurnRunningError(message, { jobId: turn.job_id, userSeq: turn.user_seq }) + } catch { + return undefined + } +} + export interface FlowConversation { id: string workspace_id: string @@ -30,6 +70,8 @@ export interface FlowConversation { created_by: string /** Started from the flow editor's test panel rather than a deployed run. */ is_test: boolean + /** Set by the list: the turn this conversation is still answering. */ + running_turn?: { job_id: string; user_seq: number } | null } /** @@ -111,18 +153,27 @@ export class WindmillChatApi { this.#pollDelayMs = options.pollDelayMs } - /** Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the job id. */ + /** + * Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the + * job id. Throws `TurnRunningError` when the conversation is still answering. + */ async runFlow( flowPath: string, args: Record, options: { memoryId: string; signal?: AbortSignal } ): Promise { - const res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, { - method: 'POST', - query: { memory_id: options.memoryId, skip_preprocessor: 'true' }, - body: args, - signal: options.signal - }) + let res: Response + try { + res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, { + method: 'POST', + query: { memory_id: options.memoryId, skip_preprocessor: 'true' }, + body: args, + signal: options.signal + }) + } catch (e) { + if (e instanceof WindmillApiError && e.status === 409) throw turnRunningError(e.body ?? '') ?? e + throw e + } return (await res.text()).trim() } @@ -146,7 +197,9 @@ export class WindmillChatApi { signal: options.signal }) if (!res.body) { - throw new WindmillApiError('The job update stream has no body', res.status) + // Status 0, whatever the status line said: a response with no stream in it comes from + // something in front of Windmill, so it is followed by a reconnect like any other. + throw new WindmillApiError('The job update stream has no body', 0) } for await (const data of readServerSentEvents(res.body)) { try { @@ -294,7 +347,8 @@ export class WindmillChatApi { const text = await res.text().catch(() => '') throw new WindmillApiError( `${init.method ?? 'GET'} ${path} failed (${res.status})${text ? `: ${text}` : ''}`, - res.status + res.status, + text ) } return res diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index 2e2c52f3df..dfef438f33 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -1,9 +1,11 @@ import { + TurnRunningError, WindmillApiError, WindmillChatApi, type ConversationKind, type FlowConversation, - type FlowConversationMessage + type FlowConversationMessage, + type RunningTurn } from './api' import { resolveConfig, type ResolvedConfig } from './config' import { followJob } from './follow' @@ -38,6 +40,8 @@ const PERSIST_DEBOUNCE_MS = 250 /** Messages persist from spawned tasks that can land just after the flow completes. */ const RECONCILE_ATTEMPTS = 3 const RECONCILE_DELAY_MS = 400 +/** Rows per read of what a turn wrote; a fuller page is read on from its last row. */ +const ROWS_PAGE = 100 interface Turn { controller: AbortController @@ -73,6 +77,11 @@ class ChatImpl implements Chat { /** The kind the caller last listed, so the refresh after a new turn lists the same rows. */ #conversationKind: ConversationKind | undefined #persistTimer: ReturnType | undefined + /** Settles once the selected conversation's first page has been read. */ + #selecting: Promise = Promise.resolve() + /** Bumped whenever a turn is taken or the conversation is left: a read that started before + * it describes a conversation this chat has moved past. */ + #epoch = 0 constructor(options: ChatOptions) { this.#config = resolveConfig(options) @@ -141,6 +150,7 @@ class ChatImpl implements Chat { streamedText: false } this.#turn = turn + this.#epoch++ const timestamp = now() const conversation: Conversation = this.#state.conversations.find( @@ -186,6 +196,109 @@ class ChatImpl implements Chat { turn.jobId = this.#config.run ? await this.#config.run(args, context) : await this.#api.runFlow(this.#config.flowPath, args, context) + } catch (e) { + try { + // Nothing of this message reached the server, whether the conversation was already + // answering one sent elsewhere or an attachment never uploaded: the message is taken + // back rather than shown as a failed turn, and the caller gets the reason. + if (e instanceof TurnRunningError || !turn.started) { + this.#withdrawTurn(turn) + throw e + } + // stop() and a conversation switch abort the turn and settle the state themselves. + if (turn.controller.signal.aborted || isAbortError(e)) return + this.#failTurn(turn, e) + return + } finally { + if (this.#turn === turn) this.#turn = undefined + } + } + await this.#followTurn(turn, isNew) + } + + resumeTurn = async ({ jobId, userSeq }: RunningTurn): Promise => { + const conversationId = this.#state.conversationId + if (!conversationId || this.#turn) return + if (this.#state.history !== 'server') { + if (this.#state.status === 'submitted') this.#set({ status: 'idle' }) + return + } + const turn: Turn = { + controller: new AbortController(), + conversationId, + userMessageId: '', + jobId, + // Its run was asked for elsewhere and is already going: there is nothing to withdraw, + // and the conversation it belongs to is listed. + isNew: false, + started: true, + withdrawn: false, + streamedText: false + } + this.#turn = turn + this.#epoch++ + this.#set({ status: 'submitted', error: undefined }) + try { + // The conversation's first page may still be on its way; it would land over the turn. + await this.#selecting + if (!this.#turnActive(turn)) return + // A listing is a snapshot: the turn it named can have ended and another one started + // since. The newest user message this chat holds names the turn to follow instead — + // following the one the listing named would drop the newer turn's rows and leave the + // chat idle while it runs. + const newest = this.#latestUserAfter(userSeq) + if (newest) { + if (!newest.jobId) { + // Its row is written and its run is not named yet: there is nothing to follow, so + // it is left to the next listing to name the turn that runs now. + if (this.#turn === turn) this.#turn = undefined + this.#set({ status: 'idle' }) + return + } + turn.jobId = newest.jobId + userSeq = newest.seq! + } + // The stream replays the turn from its start, so what this chat shows of it goes and + // comes back as it replays: its rows, and what an earlier follow of it streamed or + // failed with, its own unread question included. The message that started it stays: + // it is the turn's anchor, and a long turn can have pushed it off the page this chat + // opened on. An earlier turn's answer that only its flow result gave has no row and so + // no seq; it names that turn's job, and it stays wherever the fallback put it. + const held = this.#state.messages + const at = held.findIndex((m) => m.seq === userSeq) + let user = at === -1 ? undefined : held[at] + let messages = held.filter((m, i) => { + if (m.seq !== undefined) return m.seq < userSeq + if (m.pending || m.jobId === turn.jobId) return false + return m.jobId !== undefined || (at !== -1 && i < at) + }) + if (user) messages = [...messages, user] + if (!user) { + const [row] = await this.#api.listMessages(conversationId, { + afterSeq: userSeq - 1, + perPage: 1, + signal: turn.controller.signal + }) + if (!this.#turnActive(turn)) return + if (row?.created_seq !== userSeq || row.message_type !== 'user') { + throw new Error('windmill-chat: the message that started the running turn is gone') + } + user = fromRow(row) + messages = [...messages, user] + } + turn.userMessageId = user.id + this.#set({ messages }) + } catch (e) { + if (!(turn.controller.signal.aborted || isAbortError(e))) this.#failTurn(turn, e) + if (this.#turn === turn) this.#turn = undefined + return + } + await this.#followTurn(turn, false) + } + + async #followTurn(turn: Turn, isNew: boolean): Promise { + let nextTurn: RunningTurn | undefined + try { const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {} let result: unknown try { @@ -193,7 +306,7 @@ class ChatImpl implements Chat { } finally { stopPolling() } - await this.#finishTurn(turn, result, isNew) + nextTurn = await this.#finishTurn(turn, result, isNew) } catch (e) { if (!turn.started) { // Nothing ran: the message is withdrawn rather than shown as a failed turn, and the @@ -208,6 +321,7 @@ class ChatImpl implements Chat { } finally { if (this.#turn === turn) this.#turn = undefined } + if (nextTurn) await this.resumeTurn(nextTurn) } stop = async (): Promise => { @@ -249,8 +363,14 @@ class ChatImpl implements Chat { }) } - selectConversation = async (conversationId: string): Promise => { - if (conversationId === this.#state.conversationId) return + selectConversation = (conversationId: string): Promise => { + if (conversationId === this.#state.conversationId) return this.#selecting + const selecting = this.#select(conversationId) + this.#selecting = selecting.catch(() => {}) + return selecting + } + + async #select(conversationId: string): Promise { this.#leaveConversation() this.#page = 1 this.#set({ @@ -379,9 +499,19 @@ class ChatImpl implements Chat { }) if (this.#state.conversationId !== conversationId) return const known = new Set(this.#state.messages.map((m) => m.serverId ?? m.id)) + // Pages count back from the newest row, so rows written since the first page shift + // newer rows into this one; and a resumed turn drops the rows it replays. Only what + // is older than everything held belongs above it. + const oldest = this.#state.messages.reduce( + (min, m) => (m.seq !== undefined && (min === undefined || m.seq < min) ? m.seq : min), + undefined + ) + const older = rows + .map(fromRow) + .filter((m) => !known.has(m.id) && (oldest === undefined || m.seq! < oldest)) this.#page = page this.#set({ - messages: [...rows.map(fromRow).filter((m) => !known.has(m.id)), ...this.#state.messages], + messages: [...older, ...this.#state.messages], hasMoreMessages: rows.length === this.#config.pageSize }) } finally { @@ -389,6 +519,56 @@ class ChatImpl implements Chat { } } + refreshMessages = async (): Promise => { + const conversationId = this.#state.conversationId + if ( + !conversationId || + this.#turn || + this.#state.history !== 'server' || + this.#state.loadingMessages + ) { + return + } + const newestBefore = latestSeq(this.#state.messages) + const failure = lastFailureShown(this.#state.messages) + // A turn that starts and ends while these rows are read, or the conversation being left + // and opened again, leaves the chat holding messages this read knows nothing about, and + // the rows would land under them rather than in their own place. They are left to the + // next read, which sees the conversation as it is now. + const epoch = this.#epoch + await this.#syncFromServer(conversationId, () => this.#epoch === epoch) + if (this.#epoch !== epoch) return + if (!failure || this.#state.conversationId !== conversationId) return + if (!this.#state.messages.some((m) => m.id === failure.id)) return + // The turn this chat lost may have been carried to its end elsewhere, which only a row + // this read brought can say, and only one of that turn's own: an agent writes its answer + // from a task the run does not wait for, so an earlier turn's answer can commit after + // this turn's question and would otherwise settle it with someone else's answer. The + // failed turn's job is on the message it left; unknown jobs accept the row, as everywhere + // else the turn's jobs are read. + const jobs = failure.jobId ? await this.#turnJobIds(failure.jobId) : undefined + if (this.#state.conversationId !== conversationId) return + const held = this.#state.messages + if (!held.some((m) => m.id === failure.id)) return + const answered = held.some( + (m) => + m.seq !== undefined && + m.seq > newestBefore && + m.role === 'assistant' && + m.success && + (jobs === undefined || m.jobId === undefined || jobs.has(m.jobId)) + ) + if (!answered) return + // A turn that ran, or failed, while those jobs were read owns the state now: this one's + // failure still goes, since its answer is here, but that turn's outcome stands. + const settles = + this.#state.status === 'error' && lastFailureShown(held)?.id === failure.id + this.#set({ + messages: held.filter((m) => m.id !== failure.id), + ...(settles ? { status: 'idle' as const, error: undefined } : {}) + }) + } + destroy = (): void => { this.#leaveConversation() } @@ -398,7 +578,10 @@ class ChatImpl implements Chat { async #follow(turn: Turn, onStreamStart: () => void): Promise { let started = false for await (const event of followJob(this.#api, turn.jobId!, { signal: turn.controller.signal })) { - if (event.type === 'completed') return event.result + if (event.type === 'completed') { + if (event.streamLost) this.#dropCutRound(turn) + return event.result + } if (!started) { started = true // Persisted rows for the streaming step would duplicate what is streaming. @@ -409,6 +592,19 @@ class ChatImpl implements Chat { throw new Error('windmill-chat: the job stream ended before the flow completed') } + /** + * The stream failed while a round's text was arriving, so that text stops wherever the + * connection did. It goes: the persisted rows or, without them, the flow result give + * the whole answer instead. Rounds a tool call closed were complete and stay. + */ + #dropCutRound(turn: Turn): void { + if (!this.#turnActive(turn)) return + const cut = turn.assistantId + turn.assistantId = undefined + turn.streamedText = false + if (cut) this.#set({ messages: this.#state.messages.filter((m) => m.id !== cut) }) + } + #applyEvents(turn: Turn, events: AgentStreamEvent[]): void { if (events.length === 0 || !this.#turnActive(turn)) return let messages = [...this.#state.messages] @@ -506,18 +702,20 @@ class ChatImpl implements Chat { this.#set({ messages, status: 'streaming' }) } - async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise { + async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise { if (!this.#turnActive(turn)) return if (this.#state.history === 'server') { - turn.jobIds = await this.#turnJobIds(turn) + turn.jobIds = await this.#turnJobIds(turn.jobId!, turn.controller.signal) if (!this.#turnActive(turn)) return const reconciled = await this.#reconcileTurn(turn) if (!this.#turnActive(turn)) return if (reconciled) { - this.#set({ status: 'idle' }) + const nextTurn = this.#nextTurnAfter(turn) + this.#set({ status: nextTurn ? 'submitted' : 'idle' }) this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages }) if (isNew) await this.loadConversations().catch(() => {}) - return + if (!this.#turnActive(turn)) return + return nextTurn } // Server history just proved unreadable: the turn completes as local history. } @@ -540,9 +738,13 @@ class ChatImpl implements Chat { messages = [...messages, assistantMessage(answer, true, turn.jobId)] } } - this.#set({ messages: finalized(messages), status: 'idle' }) + const nextTurn = this.#state.history === 'server' ? this.#nextTurnAfter(turn) : undefined + this.#set({ messages: finalized(messages), status: nextTurn ? 'submitted' : 'idle' }) this.#persistLocal() this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages }) + // `onFinish` can switch conversation, and the next turn belongs to this one. + if (!this.#turnActive(turn)) return + return nextTurn } /** @@ -561,11 +763,7 @@ class ChatImpl implements Chat { for (let attempt = 1; attempt <= RECONCILE_ATTEMPTS; attempt++) { let rows: FlowConversationMessage[] try { - rows = await this.#api.listMessages(turn.conversationId, { - afterSeq: this.#lastSeq(), - perPage: 100, - signal: turn.controller.signal - }) + rows = await this.#rowsAfterLastSeq(turn.conversationId, turn.controller.signal) } catch (e) { if (isAbortError(e)) throw e if (this.#fallBackToLocal(e)) return false @@ -592,22 +790,26 @@ class ChatImpl implements Chat { * a badly delayed one can invert that order at the cost of the reconcile * retries). The content is not compared with the flow result: an image answer, a * structured one and a forwarded agent result are all persisted in a shape the - * result does not reproduce. Rows carrying a job id belong to the turn when the - * job is one of the turn's, which leaves out an earlier turn whose job outlived - * `stop()` (a token without `jobs:write` cannot cancel it); a tool row without one - * (an MCP call runs inside the agent step) belongs to whatever turn is under way. + * result does not reproduce. A row belongs to the turn when it was created after + * the user message and, when it carries a job id, the job is one of the turn's. The + * server refuses a turn while the previous run is queued, but an agent writes its + * answer row from a task the run does not wait for, so that row can still land after + * the next user message; its job says whose it is. A tool row without a job (an MCP + * call runs inside the agent step) belongs to the turn under way. Until the user + * message's own row has been read, its position in the list stands in for its seq. */ #answered(turn: Turn): boolean { const messages = this.#state.messages const from = messages.findIndex((m) => m.id === turn.userMessageId) + const userSeq = messages[from]?.seq const ownJob = (m: ChatMessage) => turn.jobIds === undefined || (m.jobId === undefined ? m.role === 'tool' : turn.jobIds.has(m.jobId)) let latest: ChatMessage | undefined - for (let i = from + 1; i < messages.length; i++) { - const m = messages[i] - if (m.seq === undefined || m.role === 'user' || !ownJob(m)) continue + messages.forEach((m, i) => { + if (m.seq === undefined || m.role === 'user' || !ownJob(m)) return + if (userSeq !== undefined ? m.seq <= userSeq : i <= from) return if (latest === undefined || m.seq > latest.seq!) latest = m - } + }) return latest?.role === 'assistant' } @@ -615,22 +817,29 @@ class ChatImpl implements Chat { * The flow job plus every step job it ran, the failure and preprocessor steps * included (a failure handler's answer is persisted under its own job), and the * jobs an agent step's tool calls ran as (a tool row is persisted under its own - * job too). Unknown when the read fails. + * job too). A failed read is retried like the rows are; unknown when it keeps + * failing or the credential may not read jobs. Unknown accepts every row after the + * question: refusing them would leave a token without job access with no turn ever + * answered, each one finished a second time from its result. */ - async #turnJobIds(turn: Turn): Promise | undefined> { - try { - const job = await this.#api.getFlowJob(turn.jobId!, turn.controller.signal) - const ids = new Set([turn.jobId!]) - const status = job.flow_status - for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) { - if (m?.job) ids.add(m.job) - for (const j of m?.flow_jobs ?? []) ids.add(j) - for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id) + async #turnJobIds(jobId: string, signal?: AbortSignal): Promise | undefined> { + for (let attempt = 1; ; attempt++) { + try { + const job = await this.#api.getFlowJob(jobId, signal) + const ids = new Set([jobId]) + const status = job.flow_status + for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) { + if (m?.job) ids.add(m.job) + for (const j of m?.flow_jobs ?? []) ids.add(j) + for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id) + } + return ids + } catch (e) { + if (isAbortError(e)) throw e + const refused = e instanceof WindmillApiError && e.status >= 400 && e.status < 500 + if (refused || attempt === RECONCILE_ATTEMPTS) return undefined + await sleep(RECONCILE_DELAY_MS, signal) } - return ids - } catch (e) { - if (isAbortError(e)) throw e - return undefined } } @@ -689,11 +898,7 @@ class ChatImpl implements Chat { } if (stopped) return try { - const rows = await this.#api.listMessages(turn.conversationId, { - afterSeq: this.#lastSeq(), - perPage: 100, - signal - }) + const rows = await this.#rowsAfterLastSeq(turn.conversationId, signal) if (!stopped && this.#turnActive(turn)) this.#mergeRows(rows) } catch { // transient; the completion reconciliation catches up @@ -706,12 +911,22 @@ class ChatImpl implements Chat { } } - async #syncFromServer(conversationId: string): Promise { - const rows = await this.#api.listMessages(conversationId, { - afterSeq: this.#lastSeq(), - perPage: 100 - }) + /** Every row created after the newest one held, however many pages that takes. */ + async #rowsAfterLastSeq(conversationId: string, signal?: AbortSignal): Promise { + const rows: FlowConversationMessage[] = [] + let afterSeq = this.#lastSeq() + while (true) { + const page = await this.#api.listMessages(conversationId, { afterSeq, perPage: ROWS_PAGE, signal }) + rows.push(...page) + if (page.length < ROWS_PAGE) return rows + afterSeq = page[page.length - 1].created_seq + } + } + + async #syncFromServer(conversationId: string, stillCurrent?: () => boolean): Promise { + const rows = await this.#rowsAfterLastSeq(conversationId) if (this.#turn || this.#state.conversationId !== conversationId) return + if (stillCurrent && !stillCurrent()) return this.#mergeRows(rows) this.#set({ messages: finalized(this.#state.messages) }) } @@ -828,6 +1043,21 @@ class ChatImpl implements Chat { return this.#turn === turn && this.#state.conversationId === turn.conversationId } + #latestUserAfter(userSeq: number): ChatMessage | undefined { + return this.#state.messages.reduce( + (found, message) => + message.role === 'user' && message.seq !== undefined && message.seq > (found?.seq ?? userSeq) ? message : found, + undefined + ) + } + + #nextTurnAfter(turn: Turn): RunningTurn | undefined { + const userSeq = this.#state.messages.find((message) => message.id === turn.userMessageId)?.seq + if (userSeq === undefined) return undefined + const next = this.#latestUserAfter(userSeq) + return next?.jobId && next.seq !== undefined ? { jobId: next.jobId, userSeq: next.seq } : undefined + } + /** Stops following the current answer; the flow itself keeps running. */ #detachTurn(): void { const turn = this.#turn @@ -842,6 +1072,7 @@ class ChatImpl implements Chat { * written out now rather than on the debounce that may never fire. */ #leaveConversation(): void { + this.#epoch++ if (this.#turn) { this.#detachTurn() this.#set({ messages: finalized(this.#state.messages), status: 'idle' }) @@ -859,6 +1090,20 @@ class ChatImpl implements Chat { } } +/** The failure this chat is showing: the message `#failTurn` left, which is never a row. */ +function lastFailureShown(messages: readonly ChatMessage[]): ChatMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m.seq === undefined && m.success === false) return m + } + return undefined +} + +/** The newest row seq a list holds; 0 when it holds none. */ +function latestSeq(messages: readonly ChatMessage[]): number { + return messages.reduce((newest, m) => (m.seq !== undefined && m.seq > newest ? m.seq : newest), 0) +} + function fromRow(row: FlowConversationMessage): ChatMessage { const toolName = row.message_type === 'tool' @@ -898,7 +1143,10 @@ function fromConversation(row: FlowConversation): Conversation { title: row.title ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at, - isTest: row.is_test + isTest: row.is_test, + runningTurn: row.running_turn + ? { jobId: row.running_turn.job_id, userSeq: row.running_turn.user_seq } + : undefined } } diff --git a/chat-sdk/src/follow.ts b/chat-sdk/src/follow.ts index 50f101393e..6402b0d6f7 100644 --- a/chat-sdk/src/follow.ts +++ b/chat-sdk/src/follow.ts @@ -1,13 +1,18 @@ -import type { WindmillChatApi } from './api' +import { WindmillApiError, type WindmillChatApi } from './api' import { createStreamEventParser, type AgentStreamEvent } from './stream' -import { abortError, sleep } from './utils' +import { abortError, isAbortError, sleep } from './utils' const RECONNECT_DELAY_MS = 300 +const MAX_RECONNECT_DELAY_MS = 5000 +/** Consecutive failed connections before the job is polled instead. */ +const MAX_CONNECTION_FAILURES = 3 +const RESULT_POLL_MS = 2000 export type FollowEvent = /** Agent events decoded from the job's result stream; empty when a chunk ended mid-line. */ | { type: 'stream'; events: AgentStreamEvent[] } - | { type: 'completed'; result: unknown } + /** `streamLost`: the result was polled after the stream failed, so what streamed may stop short. */ + | { type: 'completed'; result: unknown; streamLost?: boolean } /** * Follows a job to completion across the server's stream timeouts: every @@ -18,6 +23,10 @@ export type FollowEvent = * The offset indexes the stream of one sub-job (`flow_stream_job_id`, the flow's * streaming step). A retried step gets a new one, so when the id changes the * offset is dropped and the connection reopened from that sub-job's start. + * + * A connection that fails (a proxy restarting, the network dropping) is retried with + * backoff. The run is still going, so after a few failures in a row the job's result is + * polled instead: the rest of the answer is not streamed, but the turn still ends. */ export async function* followJob( api: WindmillChatApi, @@ -27,45 +36,86 @@ export async function* followJob( let parser = createStreamEventParser() let offset = options.streamOffset let streamJobId: string | undefined - while (true) { + let failures = 0 + while (failures < MAX_CONNECTION_FAILURES) { let reopen = false - for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) { - if (update.type === 'ping') continue - if (update.type === 'timeout') { - reopen = true - break - } - if (update.type === 'error') throw new Error(update.error) - if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`) - if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) { - const switched = streamJobId !== undefined && offset !== undefined - streamJobId = update.flow_stream_job_id - if (switched) { - // This connection skipped the new sub-job's first chunks: start it over. - offset = undefined - options.onOffset?.(undefined) - parser = createStreamEventParser() + try { + for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) { + // Opening a connection does not prove it is carrying the job. Only stream progress + // clears the count, or status snapshots followed by EOF would never reach polling. + if (update.type === 'ping') continue + if (update.type === 'timeout') { reopen = true break } + if (update.type === 'error') throw new Error(update.error) + if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`) + if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) { + const switched = streamJobId !== undefined && offset !== undefined + streamJobId = update.flow_stream_job_id + if (switched) { + // This connection skipped the new sub-job's first chunks: start it over. + failures = 0 + offset = undefined + options.onOffset?.(undefined) + parser = createStreamEventParser() + reopen = true + break + } + } + if (update.stream_offset !== undefined) { + if (update.stream_offset !== offset) failures = 0 + offset = update.stream_offset + options.onOffset?.(offset) + } + if (update.new_result_stream) { + failures = 0 + yield { type: 'stream', events: parser.push(update.new_result_stream) } + } + if (update.completed) { + const rest = parser.flush() + if (rest.length > 0) yield { type: 'stream', events: rest } + yield { type: 'completed', result: update.only_result } + return + } } - if (update.stream_offset !== undefined) { - offset = update.stream_offset - options.onOffset?.(offset) - } - if (update.new_result_stream) { - yield { type: 'stream', events: parser.push(update.new_result_stream) } - } - if (update.completed) { - const rest = parser.flush() - if (rest.length > 0) yield { type: 'stream', events: rest } - yield { type: 'completed', result: update.only_result } - return - } + } catch (e) { + if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e + failures++ + if (failures >= MAX_CONNECTION_FAILURES) break + await sleep(Math.min(RECONNECT_DELAY_MS * 2 ** failures, MAX_RECONNECT_DELAY_MS), options.signal) + continue } if (options.signal?.aborted) throw abortError() - // The server closes the connection after its timeout; a dropped connection looks - // the same minus the event. Either way the offset lets the next one resume. - if (!reopen) await sleep(RECONNECT_DELAY_MS, options.signal) + // The server closes the connection after its timeout; a connection that ends without + // that event, and without the job completing, carried nothing to its end. The offset + // lets the next one resume, and it counts like a failed one so a gateway closing every + // stream this way still reaches the polling below rather than reconnecting for ever. + if (!reopen) { + failures++ + if (failures >= MAX_CONNECTION_FAILURES) break + await sleep(RECONNECT_DELAY_MS, options.signal) + } + } + while (true) { + await sleep(RESULT_POLL_MS, options.signal) + try { + const { completed, result } = await api.getCompletedResult(jobId, options.signal) + if (completed) { + yield { type: 'completed', result, streamLost: true } + return + } + } catch (e) { + if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e + } } } + +/** + * A failure that says nothing about the job: the request never reached Windmill, or a + * gateway in front of it answered. A 4xx from Windmill itself (not found, refused) does. + */ +function isConnectionFailure(e: unknown): boolean { + if (e instanceof WindmillApiError) return e.status >= 500 || e.status === 0 + return e instanceof TypeError +} diff --git a/chat-sdk/src/index.ts b/chat-sdk/src/index.ts index 9ed2ed0b7e..a050edce37 100644 --- a/chat-sdk/src/index.ts +++ b/chat-sdk/src/index.ts @@ -3,7 +3,10 @@ export { detectRawApp, type RawAppContext } from './config' export { WindmillChatApi, WindmillApiError, + TurnRunningError, + turnRunningError, readServerSentEvents, + type RunningTurn, type WindmillChatApiOptions, type ConversationKind, type FlowConversation, diff --git a/chat-sdk/src/react.ts b/chat-sdk/src/react.ts index 33193842f2..c32324ee18 100644 --- a/chat-sdk/src/react.ts +++ b/chat-sdk/src/react.ts @@ -6,6 +6,7 @@ export type UseWindmillChat = ChatState & Pick< Chat, | 'sendMessage' + | 'resumeTurn' | 'stop' | 'newConversation' | 'selectConversation' @@ -13,6 +14,7 @@ export type UseWindmillChat = ChatState & | 'deleteConversation' | 'renameConversation' | 'loadOlderMessages' + | 'refreshMessages' > & { chat: Chat } /** @@ -61,13 +63,15 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat { chat, sendMessage: (text, options) => chat.sendMessage(text, { ...options, inputs: { ...latest.current.inputs, ...options?.inputs } }), + resumeTurn: chat.resumeTurn, stop: chat.stop, newConversation: chat.newConversation, selectConversation: chat.selectConversation, loadConversations: chat.loadConversations, deleteConversation: chat.deleteConversation, renameConversation: chat.renameConversation, - loadOlderMessages: chat.loadOlderMessages + loadOlderMessages: chat.loadOlderMessages, + refreshMessages: chat.refreshMessages }), [state, chat] ) diff --git a/chat-sdk/src/types.ts b/chat-sdk/src/types.ts index 99e16fe304..180238e50c 100644 --- a/chat-sdk/src/types.ts +++ b/chat-sdk/src/types.ts @@ -1,3 +1,5 @@ +import type { RunningTurn } from './api' + export type ChatRole = 'user' | 'assistant' | 'tool' | 'system' /** @@ -61,6 +63,11 @@ export interface Conversation { * server has listed the conversation; unset for one only this client has seen. */ isTest?: boolean + /** + * The turn the conversation was still answering when the server listed it: started in + * another tab, or before this chat was created. Pass it to `resumeTurn` to follow it. + */ + runningTurn?: RunningTurn } export interface ChatState { @@ -164,8 +171,17 @@ export interface Chat { * Sends a message in the current conversation, starting one when there is none. Resolves * when the answer is complete. Rejects when the message could not be sent at all — a turn * already running, an attachment that failed to upload — without touching the transcript. + * A conversation still answering a message sent elsewhere rejects with `TurnRunningError`: + * follow that turn with `resumeTurn`, then send again. */ sendMessage(text: string, options?: SendMessageOptions): Promise + /** + * Follows a turn of the current conversation that this chat did not start, as named by + * `Conversation.runningTurn` or a `TurnRunningError`: its answer streams into `messages` + * from the start and the turn finishes like one sent here. Server history only; resolves + * when the answer is complete, at once when a turn is already being followed. + */ + resumeTurn(turn: RunningTurn): Promise /** Stops following the answer and asks Windmill to cancel the run. */ stop(): Promise newConversation(): void @@ -183,6 +199,11 @@ export interface Chat { /** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */ renameConversation(conversationId: string, title: string): Promise loadOlderMessages(): Promise + /** + * Reads what the current conversation gained since its newest message held here, such + * as a turn another tab ran. Server history only; does nothing while a turn is followed. + */ + refreshMessages(): Promise /** Stops background work (stream, polling) and writes local history out. The chat stays usable. */ destroy(): void } diff --git a/chat-sdk/test/ai-sdk-chat.test.ts b/chat-sdk/test/ai-sdk-chat.test.ts index a0bbb7b8a1..4cef282a82 100644 --- a/chat-sdk/test/ai-sdk-chat.test.ts +++ b/chat-sdk/test/ai-sdk-chat.test.ts @@ -59,4 +59,26 @@ describe('AI SDK Chat over the Windmill transport', () => { expect(chat.error?.message).toBe('boom') expect(memoryIds.length === 1 || calls.filter((c) => c.method === 'POST')[1].url.searchParams.get('memory_id') === memoryIds[0]).toBe(true) }) + + test('a text part cut by a lost stream is completed from the polled result', async () => { + let streams = 0 + const { fetch } = fetchMock( + (c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? text('job-1') : undefined), + (c) => + c.url.pathname.endsWith('/getupdate_sse/job-1') + ? ++streams === 1 + ? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'The ans' }), stream_offset: 1 }]) + : text('bad gateway', 502) + : undefined, + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { output: 'The answer is 42', messages: [] } }) + : undefined + ) + const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, token: 'tok', fetch }) + const chat = new Chat({ id: 'cut-chat', transport }) + await chat.sendMessage({ text: 'what is it?' }) + const texts = chat.messages[1].parts.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text) + expect(texts.join('')).toBe('The answer is 42') + }, 15000) }) diff --git a/chat-sdk/test/chat.test.ts b/chat-sdk/test/chat.test.ts index a739abf3ad..3845c91ac7 100644 --- a/chat-sdk/test/chat.test.ts +++ b/chat-sdk/test/chat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import { TurnRunningError } from '../src/api' import { createChat } from '../src/chat' import type { ChatOptions } from '../src/types' import { fetchMock, json, memoryStorage, messageRow, ndjson, sse, sseTimed, text, type Route } from './support' @@ -11,6 +12,15 @@ const run: Route = (c) => const streamPath = '/api/w/ws/jobs_u/getupdate_sse/job-1' +/** Waits for a condition the code under test must reach, and fails saying which one. */ +async function until(done: () => boolean, what: string, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs + while (!done()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`) + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + function options(extra: Partial, fetch: ChatOptions['fetch']): ChatOptions { return { flowPath: FLOW, baseUrl: BASE, workspace: 'ws', fetch, storage: memoryStorage(), ...extra } } @@ -945,41 +955,752 @@ describe('createChat with server history', () => { ]) }) - test('a late answer from a stopped job is not taken as the next turn answer', async () => { - let jobs = 0 - let reads = 0 + test('a message refused because a turn is running leaves nothing behind and names that turn', async () => { const { fetch } = fetchMock( - (c) => (c.method === 'POST' && c.url.pathname.includes('/jobs/run/f/') ? text(`job-${++jobs}`) : undefined), - // job-1 never completes: the connection just ends, so the turn keeps waiting. - (c) => (c.url.pathname.endsWith('/getupdate_sse/job-1') ? sse([{ type: 'update' }]) : undefined), (c) => - c.url.pathname.endsWith('/getupdate_sse/job-2') - ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` + ? text(JSON.stringify({ error: 'still answering', running_turn: { job_id: 'job-9', user_seq: 41 } }), 409) + : undefined, + (c) => (c.url.pathname.endsWith('/messages') ? json([messageRow(40, 'user', 'earlier')]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + const refused = await chat.sendMessage('again').catch((e) => e) + expect(refused).toBeInstanceOf(TurnRunningError) + expect(refused.turn).toEqual({ jobId: 'job-9', userSeq: 41 }) + const state = chat.getState() + expect(state.messages.map((m) => m.content)).toEqual(['earlier']) + expect(state.status).toBe('idle') + // The conversation is a real one, answering elsewhere: it stays listed, its message gone. + expect(state.conversations.map((c) => c.id)).toEqual(['conv']) + }) + + test('a listing that names a turn already over follows the one running now', async () => { + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2' + ? sse([ + { + type: 'update', + new_result_stream: ndjson({ type: 'token_delta', content: 'second answer' }), + stream_offset: 1, + completed: true, + only_result: { windmill_chat_answer: 'second answer' } + } + ]) : undefined, - // The run-only token cannot cancel: job-1 keeps running after stop(). - (c) => (c.url.pathname.includes('/queue/cancel/') ? text('forbidden', 400) : undefined), (c) => c.url.pathname.endsWith('/jobs_u/get/job-2') ? json({ flow_status: { modules: [{ job: 'step-2' }] } }) : undefined, - // Read 1 is stop()'s sync; the stopped job's answer lands after the second user row. + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })]) + return json([ + messageRow(50, 'user', 'first'), + messageRow(51, 'assistant', 'first answer'), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + // The listing named the first turn; it ended and the second one started before this select. + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-1'))).toBe(false) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true) + expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([ + ['row-50', 'first'], + ['row-51', 'first answer'], + ['row-52', 'second'], + ['row-53', 'second answer'] + ]) + expect(chat.getState().status).toBe('idle') + expect(chat.getState().error).toBeUndefined() + }) + + test('a newer message whose run is not named yet leaves the chat free', async () => { + const { fetch, calls } = fetchMock( (c) => c.url.pathname.endsWith('/messages') - ? json( - ++reads === 1 - ? [messageRow(71, 'user', 'first')] - : reads === 2 - ? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })] - : [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })] - ) + ? json([messageRow(50, 'user', 'first'), messageRow(52, 'user', 'second')]) + : undefined + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse'))).toBe(false) + // A message sent now starts its own turn rather than being refused. + expect(chat.getState().error).toBeUndefined() + }) + + test('a turn started while a resumed turn reconciles is followed next', async () => { + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2' + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-2') + ? json({ flow_status: { modules: [{ job: 'step-2' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === null) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })]) + if (after === '50') { + return json([ + messageRow(51, 'assistant', 'first answer', { job_id: 'step-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + } + if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })]) + return json([]) + } + ) + const chat = createChat(options({}, fetch)) + const statuses: string[] = [] + chat.subscribe((state) => statuses.push(state.status)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(calls.some((c) => c.url.pathname === streamPath)).toBe(true) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true) + expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([ + ['row-50', 'first'], + ['row-51', 'first answer'], + ['row-52', 'second'], + ['row-53', 'second answer'] + ]) + expect(chat.getState().status).toBe('idle') + // Vacuous without this: with no 'submitted' at all, both lookups are -1 and the slice empty. + expect(statuses).toContain('submitted') + expect(statuses.slice(statuses.indexOf('submitted'), statuses.lastIndexOf('submitted') + 1)).not.toContain('idle') + }) + + test('a fallback to local history during handoff does not leave the chat busy', async () => { + let messageFetches = 0 + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + messageFetches++ + if (messageFetches === 1) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })]) + if (messageFetches === 2) return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })]) + return text('forbidden', 403) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().history).toBe('local') + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('a conversation list fallback during handoff does not leave the chat busy', async () => { + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? text('forbidden', 403) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('first') + expect(chat.getState().history).toBe('local') + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('a conversation switch during handoff does not resume the next turn elsewhere', async () => { + let releaseList!: (response: Response) => void + const listGate = new Promise((resolve) => { + releaseList = resolve + }) + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([]) + return json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined) + ) + const chat = createChat(options({}, fetch)) + const sent = chat.sendMessage('first') + await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for') + await chat.selectConversation('other') + releaseList(json([])) + await sent + expect(chat.getState().conversationId).toBe('other') + expect(chat.getState().status).toBe('idle') + expect(chat.getState().messages).toEqual([]) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('resuming a later turn keeps an earlier answer that only the flow result gave', async () => { + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2' + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-2') + ? json({ flow_status: { modules: [{ job: 'step-2' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === '51') return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })]) + if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })]) + // The first answer never got a row: the turn finishes from the flow result. + return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('first') + await chat.resumeTurn({ jobId: 'job-2', userSeq: 52 }) + expect(chat.getState().messages.map((m) => m.content)).toEqual([ + 'first', + 'first answer', + 'second', + 'second answer' + ]) + }) + + test('resuming a turn this chat lost drops what it had shown of that turn', async () => { + let streams = 0 + const { fetch } = fetchMock( + (c) => { + if (c.url.pathname !== streamPath) return undefined + return ++streams === 1 + ? sse([ + { type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 }, + { type: 'error', error: 'stream broke' } + ]) + : sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }]) + }, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })]) + } + return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + // The run goes on; following it again replays the whole answer under its question. + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer']) + }) + + test('resuming a turn this chat sent and lost shows its question once', async () => { + let streams = 0 + const { fetch } = fetchMock( + run, + (c) => { + if (c.url.pathname !== streamPath) return undefined + return ++streams === 1 + ? sse([ + { type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 }, + { type: 'error', error: 'stream broke' } + ]) + : sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }]) + }, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === '49') return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + if (after === '50') return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })]) + // The stream fails before the question's row is read. + return json([]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('question') + expect(chat.getState().status).toBe('error') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer']) + }) + + test('a re-read that finds the lost turn answered clears the failure it was left with', async () => { + let answered = false + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const rows = [messageRow(50, 'user', 'question', { job_id: 'job-1' })] + if (answered) rows.push(messageRow(51, 'assistant', 'the answer', { job_id: 'job-1' })) + return json(rows) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + // Another tab carried the turn to its end and its answer is in the rows now. + answered = true + await chat.refreshMessages() + // The answer stands alone: the failure this chat showed was never a row. + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer']) + expect(chat.getState().status).toBe('idle') + expect(chat.getState().error).toBeUndefined() + }) + + test("a re-read does not settle a failed turn with the previous turn's late answer", async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-b' + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-b') + ? json({ flow_status: { modules: [{ job: 'step-b' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const rows = [ + messageRow(50, 'user', 'qA', { job_id: 'job-a' }), + messageRow(51, 'user', 'qB', { job_id: 'job-b' }) + ] + // Turn A's answer commits from its detached task, after B's question. + if (c.url.searchParams.get('after_seq') === '51') { + return json([messageRow(52, 'assistant', "A's answer", { job_id: 'step-a' })]) + } + return json(rows) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-b', userSeq: 51 }) + expect(chat.getState().status).toBe('error') + await chat.refreshMessages() + // A's answer is not B's: B's failure stands, and it is still shown. + expect(chat.getState().status).toBe('error') + expect(chat.getState().messages.some((m) => m.success === false && m.seq === undefined)).toBe(true) + }) + + test("a re-read settles the failed turn from its own answer, not only from the newest row", async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + // The turn's answer, and behind it the question of a turn started elsewhere. + return json([ + messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' }), + messageRow(52, 'user', 'next question', { job_id: 'job-2' }) + ]) + } + return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + await chat.refreshMessages() + expect(chat.getState().status).toBe('idle') + expect(chat.getState().messages.map((m) => m.content)).toEqual([ + 'question', + 'the answer', + 'next question' + ]) + }) + + test('a conversation left and opened again while its rows are read drops that read', async () => { + let releaseRows!: (r: Response) => void + const rowsGate = new Promise((resolve) => (releaseRows = resolve)) + let reads = 0 + const { fetch } = fetchMock((c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.pathname.includes('/flow_conversations/other/')) return json([]) + reads++ + // The read the refresh made, answered only after the reader has come back. + if (reads === 2) return rowsGate + return json([messageRow(150, 'user', 'newest page', { job_id: 'job-x' })]) + }) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + const refreshed = chat.refreshMessages() + await chat.selectConversation('other') + await chat.selectConversation('conv') + // Rows from before the reader left: older than the page the chat holds now. + releaseRows(json([messageRow(51, 'user', 'older page', { job_id: 'job-y' })])) + await refreshed + expect(chat.getState().messages.map((m) => m.content)).toEqual(['newest page']) + }) + + test('a turn that runs while the rows are read leaves them to the next read', async () => { + let releaseRows!: (r: Response) => void + const rowsGate = new Promise((resolve) => (releaseRows = resolve)) + let reads = 0 + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname.includes('/jobs_u/getupdate_sse/') + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (++reads === 1) return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })]) + // The read the refresh made, answered only after another turn has come and gone. + if (reads === 2) return rowsGate + return json([]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + const refreshed = chat.refreshMessages() + // A turn starts and fails entirely while that read is in flight. + await chat.sendMessage('qB') + expect(chat.getState().status).toBe('error') + releaseRows(json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })])) + await refreshed + // A's answer is not appended under B: it waits for a read that sees the conversation as + // it is now. + expect(chat.getState().messages.map((m) => m.content)).toEqual([ + 'qA', + 'qB', + 'stream broke' + ]) + }) + + test('a turn that starts while the jobs are read still frees the answered failure', async () => { + let releaseJobs!: (r: Response) => void + const jobsGate = new Promise((resolve) => (releaseJobs = resolve)) + let releaseRun!: (r: Response) => void + const runGate = new Promise((resolve) => (releaseRun = resolve)) + const { fetch, calls } = fetchMock( + (c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? runGate : undefined), + (c) => + c.url.pathname.includes('/jobs_u/getupdate_sse/') + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined), + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-b') + ? json({ flow_status: { modules: [{ job: 'job-b' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })]) + } + return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + const refreshed = chat.refreshMessages() + await until( + () => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')), + 'the job read to start' + ) + // A turn starts while that read is in flight, and is still running when it answers. + const sent = chat.sendMessage('qB') + await until(() => chat.getState().status === 'submitted', 'the new turn to take the chat') + releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } })) + await refreshed + // A's failure goes with its answer; the running turn keeps the chat busy. + expect(chat.getState().messages.some((m) => m.success === false)).toBe(false) + expect(chat.getState().status).toBe('submitted') + releaseRun(text('job-b')) + await sent + }) + + test('a turn that fails while the jobs are read keeps its own failure', async () => { + let releaseJobs!: (r: Response) => void + const jobsGate = new Promise((resolve) => (releaseJobs = resolve)) + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath || c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-1' + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined), + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })]) + } + return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + // The refresh stops on the job read; a second turn is sent and fails while it waits. + const refreshed = chat.refreshMessages() + await until( + () => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')), + 'the job read to start' + ) + await chat.sendMessage('qB') + expect(chat.getState().status).toBe('error') + releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } })) + await refreshed + // A's failure goes, since its answer is here; the error the chat shows is B's. + expect(chat.getState().status).toBe('error') + const failures = chat.getState().messages.filter((m) => m.success === false) + expect(failures).toHaveLength(1) + expect(chat.getState().messages.map((m) => m.content)).toContain("A's answer") + }) + + test('a re-read that finds no answer leaves the failure standing', async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + : undefined + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + await chat.refreshMessages() + expect(chat.getState().status).toBe('error') + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'stream broke']) + }) + + test('a conversation switch from onFinish does not resume the next turn elsewhere', async () => { + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([]) + // No answer row lands, so the turn finishes from the flow result. + return json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({ onFinish: () => void chat.selectConversation('other') }, fetch)) + await chat.sendMessage('first') + expect(chat.getState().conversationId).toBe('other') + expect(chat.getState().messages).toEqual([]) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('stopping during handoff does not resume the next turn', async () => { + let releaseList!: (response: Response) => void + const listGate = new Promise((resolve) => { + releaseList = resolve + }) + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined) + ) + const chat = createChat(options({}, fetch)) + const sent = chat.sendMessage('first') + await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for') + const stopped = chat.stop() + releaseList(json([])) + await Promise.all([sent, stopped]) + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('a stream that keeps ending before the job completes hands the turn to polling', async () => { + let streams = 0 + const { fetch } = fetchMock( + run, + // Every real connection starts with a status snapshot. It is not stream progress. + (c) => (c.url.pathname === streamPath ? (streams++, sse([{ type: 'update', running: true }])) : undefined), + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(81, 'user', 'hi'), messageRow(82, 'assistant', 'polled')]) : undefined, (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) ) const chat = createChat(options({}, fetch)) - const first = chat.sendMessage('first') - await new Promise((r) => setTimeout(r, 50)) - await chat.stop() - await first + await chat.sendMessage('hi') + expect(streams).toBe(3) + expect(chat.getState().status).toBe('idle') + expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'polled']) + }, 15000) + + test('resuming a turn whose message is off the first page replays it without duplicating rows', async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath && !c.url.searchParams.has('stream_offset') + ? sse([ + { + type: 'update', + new_result_stream: ndjson( + { type: 'tool_call', call_id: 'c1', function_name: 'lookup' }, + { type: 'tool_result', call_id: 'c1', function_name: 'lookup', result: '1', success: true }, + { type: 'token_delta', content: 'Done' } + ), + stream_offset: 3, + completed: true, + only_result: { output: 'Done', messages: [] } + } + ]) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + // The first page holds only what the running turn wrote so far. + if (after === null) return json([messageRow(51, 'tool', 'Used lookup tool')]) + if (after === '49') return json([messageRow(50, 'user', 'hi')]) + return json([messageRow(51, 'tool', 'Used lookup tool'), messageRow(52, 'assistant', 'Done')]) + } + ) + const chat = createChat(options({}, fetch)) + void chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().messages.map((m) => [m.serverId, m.role, m.content, m.pending])).toEqual([ + ['row-50', 'user', 'hi', false], + ['row-51', 'tool', 'Used lookup tool', false], + ['row-52', 'assistant', 'Done', false] + ]) + expect(chat.getState().status).toBe('idle') + }) + + test("the previous run's answer landing after the next message is not that turn's answer", async () => { + let reads = 0 + let jobReads = 0 + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + : undefined, + // A transient failure of the job read is retried, not taken as "any row counts". + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? ++jobReads === 1 + ? text('bad gateway', 502) + : json({ flow_status: { modules: [{ job: 'step-2' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (!c.url.searchParams.has('after_seq')) return json([messageRow(71, 'user', 'first')]) + // The earlier agent wrote its answer from a task its run did not wait for. + return json( + ++reads === 1 + ? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })] + : [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })] + ) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') await chat.sendMessage('second') expect(chat.getState().messages.map((m) => [m.role, m.content, m.serverId])).toEqual([ ['user', 'first', 'row-71'], @@ -989,6 +1710,64 @@ describe('createChat with server history', () => { ]) }) + test('an answer cut by a lost stream gives way to the polled result', async () => { + let streams = 0 + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? ++streams === 1 + ? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'Hel' }), stream_offset: 1 }]) + : text('bad gateway', 502) + : undefined, + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { windmill_chat_answer: 'Hello, full answer' } }) + : undefined + ) + const chat = createChat(options({ history: 'none' }, fetch)) + await chat.sendMessage('hi') + expect(chat.getState().messages.map((m) => [m.role, m.content, m.pending])).toEqual([ + ['user', 'hi', false], + ['assistant', 'Hello, full answer', false] + ]) + }, 15000) + + test('a stream that keeps failing hands the turn to polling the job', async () => { + // Each connection opens, sends the server's ping, then drops. + const pingThenDrop = () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'ping' })}\n\n`)) + setTimeout(() => controller.error(new TypeError('network connection was lost')), 20) + } + }), + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ) + const { fetch } = fetchMock( + run, + (c) => (c.url.pathname === streamPath ? pingThenDrop() : undefined), + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(61, 'user', 'hi'), messageRow(62, 'assistant', 'polled')]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('hi') + const state = chat.getState() + expect(state.status).toBe('idle') + expect(state.messages.map((m) => [m.role, m.content, m.success])).toEqual([ + ['user', 'hi', true], + ['assistant', 'polled', true] + ]) + }, 15000) + test('a failure handler answer is attributed to the turn', async () => { let reads = 0 const { fetch } = fetchMock( diff --git a/chat-sdk/test/follow.test.ts b/chat-sdk/test/follow.test.ts index 19af6baf38..991b1a7886 100644 --- a/chat-sdk/test/follow.test.ts +++ b/chat-sdk/test/follow.test.ts @@ -23,3 +23,20 @@ test('a retried streaming step reports its offset as lost before the new sub-job } expect(offsets).toEqual([3, undefined, 1]) }) + +test('a 200 carrying no stream is reconnected like any other failed connection', async () => { + let streams = 0 + const { fetch } = fetchMock((c) => { + if (!c.url.pathname.endsWith('/jobs_u/getupdate_sse/job-1')) return undefined + // Something in front of Windmill answers 200 with nothing in it. + if (++streams === 1) return new Response(null, { status: 200 }) + return sse([{ type: 'update', completed: true, only_result: 'ok' }]) + }) + const api = new WindmillChatApi({ baseUrl: 'http://wm.test', workspace: 'ws', token: 'tok', fetch }) + let result: unknown + for await (const event of followJob(api, 'job-1')) { + if (event.type === 'completed') result = event.result + } + expect(streams).toBe(2) + expect(result).toBe('ok') +}) diff --git a/cli/src/commands/app/generate_agents.ts b/cli/src/commands/app/generate_agents.ts index eec86a8b19..6e3bac6888 100644 --- a/cli/src/commands/app/generate_agents.ts +++ b/cli/src/commands/app/generate_agents.ts @@ -186,7 +186,14 @@ export async function regenerateAgentDocs( } // Read local data configuration from raw_app.yaml - let localData: { tables?: string[]; datatable?: string; schema?: string } | undefined; + let localData: + | { + tables?: string[]; + datatable?: string; + schema?: string; + roles?: Record; + } + | undefined; try { const rawApp = (await yamlParseFile(rawAppPath)) as Record; if (rawApp.data && typeof rawApp.data === "object") { diff --git a/cli/src/commands/datatable/datatable.ts b/cli/src/commands/datatable/datatable.ts index 6609d08c50..46ee7b4c39 100644 --- a/cli/src/commands/datatable/datatable.ts +++ b/cli/src/commands/datatable/datatable.ts @@ -111,6 +111,8 @@ const migrateCommand = new Command() ) .action(migrateDown as any); +type DataTableResourceType = "postgresql" | "instance" | "external_instance"; + async function create( opts: GlobalOptions & { resource?: string; force?: boolean }, name?: string, @@ -139,12 +141,12 @@ async function create( const datatables: Record< string, - { database: { resource_type: "postgresql" | "instance"; resource_path?: string } } + { database: { resource_type: DataTableResourceType; resource_path?: string } } > = {}; for (const d of existing) { datatables[d.name] = { database: { - resource_type: d.resource_type as "postgresql" | "instance", + resource_type: d.resource_type as DataTableResourceType, resource_path: d.resource_path ?? undefined, }, }; diff --git a/cli/src/commands/lint/flow_semantics.ts b/cli/src/commands/lint/flow_semantics.ts new file mode 100644 index 0000000000..26b72a8867 --- /dev/null +++ b/cli/src/commands/lint/flow_semantics.ts @@ -0,0 +1,312 @@ +/** + * Flow checks the OpenFlow JSON schema cannot express. Errors are what fails + * a run of the flow or stops the flow editor from drawing it; warnings are + * what the editor tolerates but renders wrong. + */ + +// `TOOL_NAME_REGEX` and `flow_module_tool_name` in +// backend/windmill-worker/src/ai_executor.rs; `getToolNameError` in +// frontend/src/lib/components/flows/agentToolUtils.ts applies the same rules. +const TOOL_NAME_REGEX = /^[a-zA-Z0-9_]+$/; +const WEBSEARCH_ENABLED_NAME = "__wm_web_search"; + +// `forbiddenIds` in frontend/src/lib/components/flows/idUtils.ts. +const RESERVED_IDS = new Set([ + "do", + "bg", + "ctx", + "state", + "if", + "else", + "for", + "delete", + "while", + "new", + "in", + "failure", + "preprocessor", + "__wm_agent_root", + "as", + "Input", + "Result", + "Trigger", +]); + +// `NoteColor` in frontend/src/lib/components/graph/noteColors.ts. +const PALETTE = [ + "yellow", + "blue", + "green", + "purple", + "pink", + "orange", + "red", + "cyan", + "lime", + "gray", +]; + +// Nodes the flow graph synthesizes (`VIRTUAL_NODE_IDS` in +// frontend/src/lib/components/graph/groupDetectionUtils.ts). +const VIRTUAL_NODE_IDS = new Set(["Input", "Result", "Trigger"]); + +export interface FlowSemanticReport { + errors: string[]; + warnings: string[]; +} + +interface ModuleList { + pointer: string; + modules: any[]; +} + +// These checks also run on a flow that failed schema validation, so any +// collection may have the wrong type; treating it as empty leaves reporting it +// to the schema errors instead of aborting the whole lint run. +function asArray(value: unknown): any[] { + return Array.isArray(value) ? value : []; +} + +/** + * Every module list a group can span, and every aiagent module, anywhere in the + * tree. The lists match `getContainerInnerArrays` in + * frontend/src/lib/components/graph/groupEditor.svelte.ts: agent tools are not + * steps of the graph, so no group can reach them. + */ +function walkModules( + modules: unknown, + pointer: string, + lists: ModuleList[], + agents: { pointer: string; module: any }[], +) { + if (!Array.isArray(modules)) return; + lists.push({ pointer, modules }); + modules.forEach((m, i) => { + const value = m?.value; + const at = `${pointer}/${i}/value`; + switch (value?.type) { + case "forloopflow": + case "whileloopflow": + walkModules(value.modules, `${at}/modules`, lists, agents); + break; + case "branchone": + walkModules(value.default, `${at}/default`, lists, agents); + asArray(value.branches).forEach((b: any, bi: number) => + walkModules(b?.modules, `${at}/branches/${bi}/modules`, lists, agents) + ); + break; + case "branchall": + asArray(value.branches).forEach((b: any, bi: number) => + walkModules(b?.modules, `${at}/branches/${bi}/modules`, lists, agents) + ); + break; + case "aiagent": + collectAgents(m, `${pointer}/${i}`, agents); + break; + } + }); +} + +/** An aiagent module plus any agent that is itself one of its tools. */ +function collectAgents( + module: any, + pointer: string, + agents: { pointer: string; module: any }[], +) { + // A step linked to an `ai_agent` resource runs the resource's tools and ignores + // its own (`ai_executor.rs`), so those are left unchecked. + if (typeof module?.value?.agent === "string" && module.value.agent !== "") { + return; + } + agents.push({ pointer, module }); + asArray(module?.value?.tools).forEach((tool: any, i: number) => { + if (tool?.value?.type === "aiagent") { + collectAgents(tool, `${pointer}/value/tools/${i}`, agents); + } + }); +} + +function checkAgentTools( + agent: { pointer: string; module: any }, + report: FlowSemanticReport, +) { + const tools = agent.module?.value?.tools; + if (!Array.isArray(tools)) return; + const seen = new Map(); + tools.forEach((tool: any, i: number) => { + // An mcp entry exposes its server's own tool names and a websearch entry is + // named by `WEBSEARCH_ENABLED_NAME`; neither summary reaches the model. + const kind = tool?.value?.tool_type; + if (kind === "mcp" || kind === "websearch") return; + const at = `${agent.pointer}/value/tools/${i}`; + const name = tool?.summary; + if (typeof name !== "string" || name.length === 0) { + report.errors.push( + `${at} has no summary: a tool's summary is the name the agent calls it by`, + ); + return; + } + if (!TOOL_NAME_REGEX.test(name)) { + report.errors.push( + `${at} tool name '${name}' may only contain letters, numbers and underscores (a run that offers this tool fails with 'Invalid tool name')`, + ); + return; + } + if (name === WEBSEARCH_ENABLED_NAME) { + report.errors.push( + `${at} tool name '${name}' is reserved for enabling web search`, + ); + return; + } + if (seen.has(name)) { + report.errors.push( + `${at} tool name '${name}' is already used by tool ${seen.get(name)} of this agent`, + ); + return; + } + seen.set(name, i); + if (RESERVED_IDS.has(name)) { + report.warnings.push( + `${at} tool name '${name}' is a reserved id; the flow editor refuses it`, + ); + } + }); +} + +function checkColor( + at: string, + color: unknown, + report: FlowSemanticReport, +) { + if (color === undefined || color === null) return; + if (typeof color !== "string" || !PALETTE.includes(color)) { + report.warnings.push( + `${at} color '${color}' renders unstyled; use one of: ${PALETTE.join(", ")}`, + ); + } +} + +/** + * Mirrors `buildStructureTree` in frontend/src/lib/components/graph/flowStructure.ts, + * whose throws make the editor draw no step at all. + */ +function checkGroups( + groups: unknown, + lists: ModuleList[], + report: FlowSemanticReport, +) { + if (!Array.isArray(groups)) return; + const location = new Map(); + lists.forEach((l, list) => + l.modules.forEach((m, index) => { + if (typeof m?.id === "string") location.set(m.id, { list, index }); + }) + ); + + const placed: { at: string; list: number; start: number; end: number }[] = + []; + const keys = new Set(); + groups.forEach((g: any, i: number) => { + const at = `/value/groups/${i}`; + checkColor(at, g?.color, report); + const startId = g?.start_id; + const endId = g?.end_id; + if (typeof startId !== "string" || typeof endId !== "string") return; + + const key = `${startId}:${endId}`; + if (keys.has(key)) { + report.errors.push( + `${at} spans the same steps as another group (${startId} to ${endId})`, + ); + return; + } + keys.add(key); + + for (const id of [startId, endId]) { + if (VIRTUAL_NODE_IDS.has(id)) { + report.errors.push(`${at} cannot include the '${id}' node`); + return; + } + if (!location.has(id)) { + report.errors.push( + id === "preprocessor" || id === "failure" + ? `${at} cannot include the '${id}' module` + : `${at} references '${id}', which is not a step of this flow`, + ); + return; + } + } + const start = location.get(startId)!; + const end = location.get(endId)!; + if (start.list !== end.list) { + report.errors.push( + `${at} starts at '${startId}' and ends at '${endId}', which are not in the same branch`, + ); + return; + } + if (start.index > end.index) { + report.errors.push( + `${at} starts at '${startId}', which comes after its end '${endId}'`, + ); + return; + } + placed.push({ at, list: start.list, start: start.index, end: end.index }); + }); + + for (let a = 0; a < placed.length; a++) { + for (let b = a + 1; b < placed.length; b++) { + const x = placed[a]; + const y = placed[b]; + if (x.list !== y.list) continue; + const disjoint = x.end < y.start || y.end < x.start; + const nested = (x.start <= y.start && y.end <= x.end) || + (y.start <= x.start && x.end <= y.end); + if (!disjoint && !nested) { + report.errors.push( + `${y.at} partly overlaps ${x.at}: groups may nest but not overlap`, + ); + } + } + } +} + +function checkNotes(notes: unknown, report: FlowSemanticReport) { + if (!Array.isArray(notes)) return; + const ids = new Set(); + notes.forEach((n: any, i: number) => { + const at = `/value/notes/${i}`; + checkColor(at, n?.color, report); + if (typeof n?.id === "string") { + if (ids.has(n.id)) { + report.warnings.push(`${at} reuses the note id '${n.id}'`); + } + ids.add(n.id); + } + if (n?.type === "group") { + report.warnings.push( + `${at} is a 'group' note, which is deprecated: use value.groups instead`, + ); + } else if (!n?.position || !n?.size) { + report.warnings.push( + `${at} has no position or size, so the editor places it at the origin and cannot resize it`, + ); + } + }); +} + +export function checkFlowSemantics(flow: unknown): FlowSemanticReport { + const report: FlowSemanticReport = { errors: [], warnings: [] }; + const value = (flow as any)?.value; + if (!value || typeof value !== "object") return report; + + const lists: ModuleList[] = []; + const agents: { pointer: string; module: any }[] = []; + walkModules(value.modules, "/value/modules", lists, agents); + + for (const agent of agents) { + checkAgentTools(agent, report); + } + checkGroups(value.groups, lists, report); + checkNotes(value.notes, report); + return report; +} diff --git a/cli/src/commands/lint/lint.ts b/cli/src/commands/lint/lint.ts index d031c16b3f..7029c851e0 100644 --- a/cli/src/commands/lint/lint.ts +++ b/cli/src/commands/lint/lint.ts @@ -36,6 +36,7 @@ import { getScriptBasePathFromModulePath, } from "../../utils/resource_folders.ts"; import { isFilesetResource } from "../../utils/utils.ts"; +import { checkFlowSemantics } from "./flow_semantics.ts"; import { exts, findContentFile, @@ -823,6 +824,16 @@ export async function runLint( ...formatYamlDiagnostics(result.parsed), ...result.errors.map((error) => formatValidationError(error)), ]; + if (target.type === "flow") { + const semantics = checkFlowSemantics(result.parsed?.data); + fileErrors.push(...semantics.errors); + warnings.push( + ...semantics.warnings.map((message) => ({ + path: normalizedPath, + message, + })), + ); + } if (fileErrors.length > 0) { issues.push({ path: normalizedPath, @@ -979,7 +990,7 @@ async function lintWatch(opts: LintOptions, directory?: string) { const command = new Command() .description( - "Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file", + "Validate Windmill flow, schedule, and trigger YAML files in a directory (including AI agent tool names and flow groups/notes), and report script metadata that has no deployable content file", ) .arguments("[directory:string]") .option("--json", "Output results in JSON format") diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 6185decc5a..9dfc26ca85 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -772,12 +772,20 @@ export function generateAgentsDocumentation( tables?: string[]; datatable?: string; schema?: string; + roles?: Record; } | undefined, ): string { const tables = data?.tables ?? []; const defaultDatatable = data?.datatable; const defaultSchema = data?.schema; + // A bare `wmill.datatable()` always means `main`, and a datatable under a role + // is only reachable through that role, so the example names both. + const role = defaultDatatable ? data?.roles?.[defaultDatatable] : undefined; + const datatableCall = `wmill.datatable('${defaultDatatable || "main"}'${ + role ? `, { role: '${role}' }` : "" + })`; + const tableRef = defaultSchema ? `${defaultSchema}.table` : "table"; return `# AI Agent Instructions @@ -791,7 +799,13 @@ This file contains **app-specific configuration** for this raw app instance. ${ defaultDatatable - ? `**Default Datatable:** \`${defaultDatatable}\`${defaultSchema ? ` | **Default Schema:** \`${defaultSchema}\`` : ""}` + ? `**Default Datatable:** \`${defaultDatatable}\`${defaultSchema ? ` | **Default Schema:** \`${defaultSchema}\`` : ""}${ + role ? ` | **Role:** \`${role}\` (pass it to every \`wmill.datatable\` call)` : "" + }${ + defaultSchema + ? `\n\nWrite this app's tables as \`${defaultSchema}.\`, in migrations and queries: an unqualified name means the \`public\` schema.` + : "" + }` : "**No default datatable configured.** Set \`data.datatable\` in \`raw_app.yaml\` to enable database access." } @@ -833,8 +847,8 @@ const result = await backend.({ arg: 'value' }); **Query datatable (TypeScript):** \`\`\`typescript -const sql = wmill.datatable(); -const rows = await sql\`SELECT * FROM table WHERE id = \${id}\`.fetch(); +const sql = ${datatableCall}; +const rows = await sql\`SELECT * FROM ${tableRef} WHERE id = \${id}\`.fetch(); \`\`\` **SQL migrations:** Add \`.sql\` files to \`sql_to_apply/\`, run \`wmill app dev\`, then whitelist tables diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 0aca0984ff..eedd9c0842 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -13,6 +13,7 @@ import { renameCurrentGitBranch, } from "../../utils/git.ts"; import process from "node:process"; +import { apiErrorMessage } from "../../utils/utils.ts"; import { WM_FORK_PREFIX } from "../../core/constants.ts"; import { tryResolveBranchWorkspace } from "../../core/context.ts"; import { @@ -22,6 +23,38 @@ import { readConfigFile, } from "../../core/conf.ts"; +/** Wait for a fork creation started in the background, by the id its request answered with. */ +async function waitForForkCreation(parentWorkspace: string, creationId: string) { + let failingSince: number | undefined; + let lastStep: string | undefined; + while (true) { + let result: + | Awaited> + | undefined; + try { + result = await wmill.getForkCreationStatus({ + workspace: parentWorkspace, + creationId, + }); + failingSince = undefined; + } catch (e) { + // A failed poll says nothing about the fork: it can be lost to a proxy, or reach a replica + // that has no status route yet during a rolling deploy. + failingSince ??= Date.now(); + if (Date.now() - failingSince >= 3 * 60 * 1000) throw e; + } + if (result?.status === "running" && result.step && result.step !== lastStep) { + lastStep = result.step; + log.info(colors.gray(`${lastStep}...`)); + } + if (result?.status === "completed") return; + if (result?.status === "failed") { + throw new Error(result.error ?? "Unknown error"); + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } +} + async function createWorkspaceFork( opts: GlobalOptions & { createWorkspaceName: string | undefined; @@ -376,8 +409,9 @@ async function createWorkspaceFork( // --- Create the fork workspace --- try { - const result = await wmill.createWorkspaceFork({ + const response = await wmill.createWorkspaceFork({ workspace: workspace.workspaceId, + background: true, requestBody: { id: trueWorkspaceId, name: opts.createWorkspaceName ?? workspaceName ?? trueWorkspaceId, @@ -385,11 +419,17 @@ async function createWorkspaceFork( forked_datatables: forkedDatatables, }, }); + // A server without background forks ignores the flag and answers once the fork is created. + if (!response.startsWith("Created forked workspace")) { + await waitForForkCreation(workspace.workspaceId, response); + } - log.info(colors.green(`✅ ${result}`)); + log.info(colors.green(`✅ Created forked workspace ${trueWorkspaceId}`)); } catch (error) { log.error( - colors.red(`Failed to create forked workspace: ${(error as Error).message}`), + colors.red( + `Failed to create forked workspace: ${apiErrorMessage(error) ?? (error as Error).message}`, + ), ); throw error; } diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index ef6e42d6f3..1d14d4f4e2 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.818.0"; +export const VERSION = "1.819.0"; diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index 202a6feaad..6060f6b8a5 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -70,7 +70,8 @@ You are a helpful assistant that can help with Windmill scripts, flows, apps, an ## Important Notes - Every new entity MUST be created using the skills listed below. - Every modification of an entity MUST be done using the skills listed below. -- User MUST be asked where to create the entity. It can be in its user folder, under u/{user_name} folder, or in a new folder, /f/{folder_name}/. folder_name and user_name must be provided by the user. +- User MUST be asked where to create the entity: in their user space, \`u/{user_name}/\`, or in a folder, \`f/{folder_name}/\` (no leading \`/\`; a bare \`f/\` with no folder segment is invalid). folder_name and user_name must be provided by the user. +- A new folder needs its \`f/{folder_name}/folder.meta.yaml\`: create it with \`wmill folder new {folder_name}\`. \`wmill sync push\` refuses a folder without one for non-admins. ## Script Writing Guide @@ -82,6 +83,10 @@ For Workflow-as-Code scripts, use the \`write-workflow-as-code\` skill. You MUST use the \`write-flow\` skill to create or modify flows. When a new flow needs to be created, YOU run \`wmill flow new \` yourself (with \`--summary\` and optional \`--description\`) to scaffold the folder and \`flow.yaml\`, then edit \`flow.yaml\` to fill in modules and schema. Do NOT scaffold the folder + yaml by hand and do NOT tell the user to run \`wmill flow new\`. If path or summary are missing from the user's request, ask via \`AskUserQuestion\` (one call, all missing fields) — never invent them. See the \`write-flow\` skill for the procedure. +## Data Pipelines + +A data pipeline is NOT a flow: it is a set of independent scripts in one folder, marked \`pipeline\` and wired together by \`on\` / \`materialize\` annotations. When the user asks for a data pipeline (or to ingest, transform or materialize data across steps), you MUST use the \`write-pipeline\` skill and build pipeline scripts, not a flow. + ## Raw App Development You MUST use the \`raw-app\` skill to create or modify raw apps. diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 81895997b9..02f5d3a27d 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -33,6 +33,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "schedules", description: "MUST use when configuring schedules." }, { name: "resources", description: "MUST use when managing resources." }, { name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." }, + { name: "write-pipeline", description: "MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations." }, { name: "cli-commands", description: "MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`." }, { name: "preview", description: "MUST use when opening the Windmill dev page / visual preview of a flow, script, or app. Triggers on words like preview, open, navigate to, visualize, see the flow/app/script, and after writing a flow/script/app for visual verification." }, ]; @@ -89,6 +90,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Ansible Windmill runs Ansible playbooks with \`ansible-playbook\`. A script is a single YAML @@ -229,6 +253,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Bash ## Structure @@ -329,6 +376,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # BigQuery Arguments use \`@name\` syntax. @@ -422,6 +492,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # TypeScript (Bun) Bun runtime with full npm ecosystem and fastest execution. **Bun is the default and preferred TypeScript runtime** — choose it for any TypeScript script unless there is a major reason to use Deno for that specific use-case. @@ -476,7 +569,7 @@ import * as wmill from "windmill-client"; **Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill. -The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to \`fetch\`. +The full \`windmill-client\` API reference (every exported function and its signature) is included below — consult it for the exact method to use instead of guessing or falling back to \`fetch\`. ## Preprocessor Scripts @@ -494,7 +587,9 @@ type Event = { | "postgres" | "sqs" | "mqtt" - | "gcp"; + | "amqp" + | "gcp" + | "azure"; body: any; headers: Record; query: Record; @@ -542,7 +637,6 @@ const result: wmill.S3Object = await wmill.writeS3File( ); \`\`\` - # TypeScript SDK (windmill-client) Import: import * as wmill from 'windmill-client' @@ -1218,6 +1312,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # TypeScript (Bun Native) 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. @@ -1269,7 +1386,7 @@ export async function main(url: string) { \`windmill-client\` works on the native worker (its calls go over \`fetch\`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). It handles auth, the workspace, and the base URL for you. Reserve raw \`fetch\` for calling *external* HTTP APIs that aren't Windmill. -The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a \`fetch\` against the Windmill API. +The full \`windmill-client\` API reference (every exported function and its signature) is included below — consult it for the exact method instead of hand-rolling a \`fetch\` against the Windmill API. ## Preprocessor Scripts @@ -1288,7 +1405,9 @@ type Event = { | "postgres" | "sqs" | "mqtt" - | "gcp"; + | "amqp" + | "gcp" + | "azure"; body: any; headers: Record; query: Record; @@ -1338,7 +1457,6 @@ const result: wmill.S3Object = await wmill.writeS3File( ); \`\`\` - # TypeScript SDK (windmill-client) Import: import * as wmill from 'windmill-client' @@ -2014,6 +2132,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # C# The script must contain a public static \`Main\` method inside a class: @@ -2106,6 +2247,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # TypeScript (Deno) Deno runtime with npm support via \`npm:\` prefix and native Deno libraries. @@ -2162,7 +2326,7 @@ import * as wmill from "windmill-client"; **Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill. -The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to \`fetch\`. +The full \`windmill-client\` API reference (every exported function and its signature) is included below — consult it for the exact method instead of guessing or falling back to \`fetch\`. ## Preprocessor Scripts @@ -2180,7 +2344,9 @@ type Event = { | "postgres" | "sqs" | "mqtt" - | "gcp"; + | "amqp" + | "gcp" + | "azure"; body: any; headers: Record; query: Record; @@ -2228,7 +2394,6 @@ const result: wmill.S3Object = await wmill.writeS3File( ); \`\`\` - # TypeScript SDK (windmill-client) Import: import * as wmill from 'windmill-client' @@ -2904,6 +3069,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # DuckDB Arguments are defined with comments and used with \`$name\` syntax: @@ -3030,6 +3218,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Go ## Structure @@ -3139,6 +3350,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # GraphQL ## Structure @@ -3235,6 +3469,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Java The script must contain a Main public class with a \`public static main()\` method: @@ -3324,6 +3581,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Microsoft SQL Server (MSSQL) Arguments use \`@P1\`, \`@P2\`, etc. @@ -3416,6 +3696,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # MySQL Arguments use \`?\` placeholders. @@ -3509,6 +3812,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # PHP ## Structure @@ -3617,6 +3943,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # PostgreSQL Arguments are obtained directly in the statement with \`$1::{type}\`, \`$2::{type}\`, etc. @@ -3708,6 +4057,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # PowerShell ## Structure @@ -3814,6 +4186,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Python ## Structure @@ -3897,7 +4292,7 @@ For preprocessor scripts, the function should be named \`preprocessor\` and rece from typing import TypedDict, Literal, Any class Event(TypedDict): - kind: Literal["webhook", "http", "websocket", "kafka", "email", "nats", "postgres", "sqs", "mqtt", "gcp"] + kind: Literal["webhook", "http", "websocket", "kafka", "email", "nats", "postgres", "sqs", "mqtt", "amqp", "gcp", "azure"] body: Any headers: dict[str, str] query: dict[str, str] @@ -3948,7 +4343,6 @@ result: S3Object = wmill.write_s3_file( ) \`\`\` - # Python SDK (wmill) Import: import wmill @@ -4745,7 +5139,6 @@ async def parallel(items, fn, *, concurrency: Optional[int] = None) # partition: Partition number (from event['partition']) # offset: Message offset to commit (from event['offset']) def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None - `, "write-script-rlang": `--- name: write-script-rlang @@ -4797,6 +5190,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # R ## Structure @@ -4933,6 +5349,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Rust ## Structure @@ -5059,6 +5498,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Snowflake Arguments use \`?\` placeholders. @@ -5189,7 +5651,6 @@ An input typed as a resource (\`format: resource-\` in the schema) takes t To open the flow visually in the dev page (graph + live reload), use the \`preview\` skill. Always **offer** it as a one-sentence next step (e.g. "Want me to open the visual preview?") rather than opening it automatically — opening the dev page has side effects (browser window, possibly a \`launch.json\` entry under MCP-preview branches) the user should consent to. If the user already asked to see/preview/visualize the flow in their original request, skip the offer and just invoke the skill. - # Windmill Flow Building Guide ## OpenFlow Schema @@ -5362,8 +5823,8 @@ names, so neither is name-checked at all — leave those summaries as they are. - Always set \`summary\`. It must be unique among that agent's tools, and must not be one of the reserved ids (\`do\`, \`bg\`, \`ctx\`, \`state\`, \`if\`, \`else\`, \`for\`, \`delete\`, \`while\`, \`new\`, \`in\`, \`failure\`, \`preprocessor\`, \`as\`, \`Input\`, \`Result\`, \`Trigger\`) -- A tool name outside that character set is rejected: flow write tools refuse it, and a flow that - reaches the worker with one fails every run with \`Invalid tool name\` +- A tool name outside that character set fails any run that offers the tool to the agent, with \`Invalid tool name\`. + \`wmill lint \` reports it before anything runs. - Tool \`id\` follows the same rules as any module ID — unique across the flow, underscores not spaces - \`description\` is optional free text telling the agent when and how to call the tool. Set it whenever the name alone does not make that obvious; it overrides the description derived from the @@ -5386,9 +5847,11 @@ names, so neither is name-checked at all — leave those summaries as they are. ## Loop Structure Rules +- A \`forloopflow\` runs its \`modules\` once per element of \`iterator\`, a javascript expression returning an array (e.g. \`results.get_items\`); \`parallel: true\` runs the iterations concurrently, and \`skip_failures: true\` carries on past a failed iteration - For \`whileloopflow\`, break the loop with a module-level \`stop_after_if\`: on the loop module itself, or on an inner step (required when that step carries state via its own \`results\` — see below) - \`stop_after_if\` is always a sibling of \`id\` and \`value\` on a flow module — never a direct key of the loop's \`value\` object - \`stop_after_all_iters_if\` is for checks after the whole loop finishes, not the normal per-iteration break condition +- \`stop_after_if\` is evaluated after each iteration: on the loop module, \`result\` is that iteration's result (what its last step returned); on an inner step, it is that step's result - \`flow_input.iter.value\` in a \`whileloopflow\` is just the iteration index (same number as \`flow_input.iter.index\`) — it never carries state, so \`flow_input.iter.value.\` is always undefined and a loop whose stop condition depends on it never terminates - To carry state across iterations, a step reads its own previous-iteration result via \`results.\` with a first-iteration fallback (e.g. \`results.b ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.\` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own \`stop_after_if\`, retry or similar, resolve \`results\` across iterations regardless of stop placement) - For state that is just a counter, derive it from the index instead (e.g. \`flow_input.iter.index + 1\`) — that works in every configuration, including with \`stop_after_if\` on the loop module @@ -5526,6 +5989,7 @@ Incorrect shape (identity has no resume URLs — not a real approval): ## Branch Result Scope Rules +- A \`branchone\` runs the first of its \`branches\` whose \`expr\` is true, in order, and its \`default\` modules when none is; a \`branchall\` runs every branch (concurrently with \`parallel: true\`) - Inside a branch, you may reference earlier outer steps and earlier steps in the same branch - Outside a \`branchone\`, do NOT reference ids of steps that only exist inside its branches or default branch. Use \`results.\` instead - Outside a \`branchall\`, do NOT reference ids of steps inside its branches. Use \`results.\` instead @@ -5585,6 +6049,41 @@ JavaScript transform (dynamic expression): - For flow inputs: Use type \`"object"\` with format \`"resource-{type}"\` (e.g., \`"resource-postgresql"\`) - For step inputs: Use static value \`"$res:path/to/resource"\` +## Reusing Existing Scripts and Flows + +Unless the user asked for new code, look for a workspace script or flow that already does a step's job before writing it, and reuse it by path instead of copying its logic into a rawscript: + +- a workspace script: \`type: script\` with \`path\` (e.g. \`f/folder/send_email\`) +- a workspace flow, run as a subflow: \`type: flow\` with \`path\` +- a Hub script: \`type: script\` with a \`hub///\` path + +The step's \`input_transforms\` must cover the reused item's inputs, so read its input schema first. +Find candidates in the local tree (a \`.script.yaml\` sits next to each script and holds its input schema, a \`flow.yaml\` in each flow folder) and on the workspace with \`wmill script list\` / \`wmill flow list\`; \`wmill script get \` and \`wmill flow get \` show an item's details. + +## Organizing Flows: Groups and Notes + +Groups and notes shape how a flow reads in the editor; neither changes what it does. + +**Segment every non-trivial flow into groups without waiting to be asked.** Whenever a flow has more than a couple of steps, or consecutive steps form a stage ("fetch", "transform", "notify"), put them in a group, and aim for every meaningful step to belong to one. Use notes sparingly, for flow-wide information that belongs to no span of steps: the flow's purpose, key assumptions, warnings, TODOs. One note is usually enough; never label a run of steps with a note, which is what a group is for. + +\`value.groups\` lists the groups, each spanning the steps from \`start_id\` to \`end_id\`: + +- \`start_id\`, \`end_id\` (required): ids of the group's first and last step; the same id for both makes a one-step group +- \`summary\`: the group's title +- \`note\`: markdown shown under the title +- \`color\`: one of \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\`, never a hex code or CSS color; leave it out and the editor picks one +- \`autocollapse\`: \`true\` shows the group collapsed by default + +The editor refuses to draw a flow whose groups break any of these rules: + +- \`start_id\` and \`end_id\` are steps of the same list: both top-level, or both in the same loop body or branch. A group can hold a loop or branch step whole, but cannot start outside one and end inside it +- \`start_id\` does not come after \`end_id\` in that list +- groups nest (one entirely inside another) but never partly overlap, and no two groups share both \`start_id\` and \`end_id\` +- groups hold ordinary steps only: never \`preprocessor\`, \`failure\`, \`Input\`, \`Result\`, \`Trigger\`, or an AI agent's tools + +\`value.notes\` lists sticky notes, each with a unique \`id\`, markdown \`text\`, a \`color\` from the same list, and \`type: free\`. The \`group\` note type is deprecated; use \`value.groups\` instead. +Give each note a \`position\` (\`{ x, y }\`) and a \`size\` (\`{ width, height }\`): the editor draws a note without them at the origin and cannot resize it. \`x: -400\` with \`width: 275\` places it beside the graph. + ## Final Structural Self-Check Before finalizing a flow, verify: @@ -5594,6 +6093,8 @@ Before finalizing a flow, verify: - any approval step has module-level \`suspend\` - no downstream step references inner branch step ids from outside the branch - every AI agent flowmodule tool has a unique \`summary\` made only of letters, numbers and underscores +- every group starts and ends on steps of the same list, start before end, nesting without partial overlap +- \`wmill lint \` reports no error ## S3 Object Operations @@ -5648,10 +6149,10 @@ 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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\`: it is ignored when memory is off, and an older \`auto\` or\\n\`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\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"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\`: it is ignored when memory is off, and an older \`auto\` or\\n\`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\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"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["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. @@ -5822,7 +6323,7 @@ The \`data\` block in \`raw_app.yaml\` controls which tables the app can query. \`\`\`yaml data: datatable: main # Default datatable - schema: app_schema # Default schema (optional) + schema: app_schema # Schema the app's tables go in (optional); still write them as app_schema.
tables: - main/users # Table in public schema - main/app_schema:items # Table in specific schema @@ -5867,6 +6368,8 @@ data: - main/users \`\`\` +A migration runs with no default schema, so a table outside \`public\` is created with its schema, \`CREATE TABLE IF NOT EXISTS app_schema.items (...)\`, listed as \`main/app_schema:items\`, and queried as \`app_schema.items\`. + ### Migration best practices - **Use idempotent SQL**: \`CREATE TABLE IF NOT EXISTS\`, etc. @@ -5876,8 +6379,9 @@ data: ## CLI Commands -Two commands you run yourself, not the user: +Commands you run yourself, not the user: - \`wmill app new\` — run it with flags, per the "Creating a Raw App" section above. +- \`wmill app lint \` — checks the app's structure and that it builds. Run it after editing, before offering a preview or a deploy; a bundle that compiles still says nothing about behavior, so a preview is what checks that. - \`wmill generate-metadata\` — (re)generates local lock files and refreshes \`wmill-lock.yaml\` content hashes; writes local files only (not a deploy). After adding or editing a runnable, offer it and run it on agreement — or automatically if the project's \`AGENTS.md\` opts into that (see "After creating a runnable" above). For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time: @@ -5889,8 +6393,6 @@ For the rest, tell the user which command fits their intent and let them run it | \`wmill sync push\` | Deploy app to Windmill | | \`wmill sync pull\` | Pull latest from Windmill | - - # Windmill Raw Apps Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. @@ -5941,6 +6443,8 @@ Import the generated bindings and call the runnable like a function. \`./wmill\` | \`getJob(jobId)\` | a \`Job\` (\`{ type, success, result, duration_ms, ... }\`) | polling status without blocking | | \`streamJob(jobId, onUpdate?)\` | the final result, calling \`onUpdate\` per chunk | showing output as it is produced | +A runnable is always called with **one object** whose keys are its \`main\` parameters — \`main(user_id: string, limit: number)\` is called as \`backend.get_users({ user_id, limit })\`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the \`wmill\` client are paths (\`u//\` or \`f//\`). + Run and wait — the common case: \`\`\`tsx @@ -6014,9 +6518,10 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo ### Inline runnables -Inline runnables carry their own source code. For file-based raw apps, the runnable language is determined by the backend file extension. The script must expose a \`main\` function as its entrypoint. +Inline runnables carry their own source code, and must expose a \`main\` function as their entrypoint. +On disk, a runnable's language is determined by its backend file extension. -**TypeScript example** (\`backend/get_user.ts\`): +**TypeScript example** (runnable \`get_user\`): \`\`\`typescript import * as wmill from 'windmill-client'; @@ -6028,7 +6533,7 @@ export async function main(user_id: string) { } \`\`\` -**Python example** (\`backend/get_user.py\`): +**Python example** (runnable \`get_user\`): \`\`\`python import wmill @@ -6045,12 +6550,14 @@ An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'w **Don't read \`WM_TOKEN\` or \`BASE_INTERNAL_URL\` and build an API URL to \`fetch\`.** The client's own \`setClient\` already reads exactly those, and it also sets the credentials mode a raw app needs (\`WM_RAW_APP\` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with \`Access-Control-Allow-Origin: *\`). Rebuilding that by hand drops the parts you can't see. Use \`wmill.*\` for everything Windmill, and \`fetch\` only for third-party APIs. -Prefer the \`wmill\` functions that appear in the SDK reference; for an endpoint none of them covers, the generated service classes (\`JobService\`, \`ScriptService\`, ...) are importable from \`windmill-client\`. What is not available is a name you guessed at: \`getBaseUrl\` and \`getWorkspaceToken\` are inventions, not API. +Use only \`wmill\` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (\`JobService\`, \`ScriptService\`, ...) are importable from \`windmill-client\`. What is not available is a name you guessed at: \`getBaseUrl\` and \`getWorkspaceToken\` are inventions, not API. ### Path runnables (script / flow / hubscript) When \`type\` is \`script\`, \`flow\`, or \`hubscript\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface. +Before writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at \`hub///\`) for a third-party service, and reference it instead of copying its logic. + ### Draft code vs deployed code This decides whether an app works before anything is deployed: @@ -6070,15 +6577,29 @@ Prefer a **path runnable of type \`flow\`** over an inline runnable that calls \ \`staticInputs\` is an optional \`Record\` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller. +## Who can open a deployed app + +A draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to: + +- **anonymous** users: anyone with the URL, without logging in; +- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace. + +Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. +\`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again. + ## Data Tables -Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. +Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table. ### Critical rules -1. **Whitelisted tables only**: a runnable can only query tables listed in the app's \`data.tables\` config. Tables not in this list are not accessible. -2. **Add tables before using**: queries against unlisted tables fail at runtime. When you introduce a new table, register it in \`data.tables\` first. -3. **Use the configured datatable/schema**: the app's \`data\` config sets the default datatable and schema; reference them consistently across runnables. +1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a \`main\` data table exists. +2. **Whitelisted tables only**: a runnable can only query tables listed in the app's \`data.tables\` config. Queries against unlisted tables fail at runtime, so register a new table there before using it. +3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable. +4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables. +5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`. + +\`wmill datatable list\` lists the workspace's data tables. Create or change tables with a migration in \`sql_to_apply/\` (see "SQL Migrations" above), then add them to \`data.tables\` in \`raw_app.yaml\`. ### Querying in TypeScript (Bun/Deno) @@ -6313,6 +6834,14 @@ Reference variables in resource values: - \`$var:u/username/name\` - User variable - \`$var:f/folder/name\` - Folder variable +## Secrets + +Never put a secret (password, API key, token) inline in a resource value. Store it in a secret variable and reference that variable as \`$var:\`. + +- \`$var:\` is a reference, not a value: it resolves to the variable's value at run time. Never invent a value for a variable. +- A resource that references a variable needs the variable to exist first, so create or deploy the variable before the resource. +- A secret's plaintext never goes in a file of the repo: a \`.variable.yaml\` holding it would be committed. Ask the user to create the secret on the workspace instead, e.g. \`wmill variable add '' \` (a secret by default), then reference it by path. + ## Resource References Reference other resources: @@ -6594,14 +7123,13 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. -Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands. - # Windmill Workflow-as-Code Writing Guide ## Scope Use this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts. WAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow. +Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands. Supported WAC authoring targets: - Bun TypeScript scripts that import from \`windmill-client\` @@ -6786,7 +7314,6 @@ TypeScript: avoid broad \`try/catch\` around WAC SDK calls. The SDK uses an inte A caught failure reads the same whether it came from a task or from a \`step()\`, and the same in the round that ran the failing body as in every round replaying it. It carries \`step_key\`, \`child_job_id\` (absent for a \`step()\`, which runs in the workflow job and has no child job), a \`message\` that is the failure's own message, and \`result\` = \`{"error": {"name", "message", "stack"?, "extra"?}}\`. \`name\`, \`message\` and \`stack\` are the fields that read the same whichever side failed; \`name\` and \`message\` are always there, \`stack\` only when the failure had a traceback to give. \`extra\` carries the failure's own custom fields (an exception's attributes, an error's properties) and is best-effort: it is absent when there were none, and a task can report entries a step does not, so read it defensively and don't branch on its absence. \`extra\` is dropped when it is too large to keep in the checkpoint, and \`extra_omitted: true\` says so — absent \`extra\` with no \`extra_omitted\` means the failure simply had no custom fields. Branch on those, not on the original exception type: the workflow body re-runs from the top every round and a replay rebuilds the failure from the checkpoint, so nothing outside that record survives. Python raises \`TaskError\`; TypeScript throws an \`Error\` named \`TaskError\` carrying the same fields. Nothing is chained onto \`__cause__\` / \`cause\` — the traceback is in \`result.error.stack\`, and is also printed to the job log when the step fails. - ## TypeScript Workflow-as-Code API (windmill-client) Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"\` @@ -7155,6 +7682,132 @@ def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict # results = await parallel(items, process, concurrency=5) async def parallel(items, fn, *, concurrency: Optional[int] = None) \`\`\` +`, + "write-pipeline": `--- +name: write-pipeline +description: MUST use when creating or modifying a data pipeline, a set of scripts marked \`pipeline\` and wired together by \`on\` / \`materialize\` annotations. +--- + +# Data pipeline authoring + +A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates steps internally. A data pipeline is a set of **independent scripts**, each deployed on its own, that form a DAG by reading and writing shared **storage assets** (DuckLake tables, data tables, S3 objects, volumes, resources) and by declaring execution **triggers**. The pipeline is visualized and edited at \`/pipeline/\`; every node is a normal workspace script that happens to carry pipeline annotations. When the user asks for a "data pipeline" (or to "ingest / transform / materialize" data across steps), build pipeline-annotated scripts — do NOT build a flow. + +## Default to DuckDB + DuckLake + +A pipeline node that produces a table should almost always be a **\`duckdb\`** node that materializes its output into a **DuckLake** table with \`-- materialize ducklake:///
\` (in a DuckDB node the annotation uses SQL \`--\` comment syntax; write the body as a bare \`SELECT\` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else: + +- \`postgresql\` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live). +- \`bun\` / \`python3\` — only for non-tabular work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node still produces tabular data for downstream steps, land it in DuckLake (write it with the wmill SDK / ducklake helpers) rather than inventing a parallel store. + +Do not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal. + +## Storage prerequisites + +A DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and \`s3://\` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build. +\`wmill ducklake list\` lists them. +Drafting the annotated scripts does not require storage, but the pipeline can't ingest, materialize, or read its assets until it exists. So if there is none (or the user hits "storage not configured" errors), say so and give the right next step **by role**: + +- a workspace **admin** sets it up in Workspace settings → Object Storage (add an S3/Azure/GCS storage), then adds a DuckLake catalog on top of it; +- anyone **without admin rights** should ask a workspace admin to configure object storage + a DuckLake catalog. + +Never hand back a DuckLake pipeline that cannot run without flagging the missing storage and pointing to who sets it up. + +## What makes a script a pipeline node + +A script joins the pipeline when its source begins with the \`pipeline\` annotation as a top-of-file comment, **written in the script's own comment syntax** — \`//\` for TS/JS (bun), \`--\` for SQL (DuckDB/Postgres), \`#\` for Python/Bash. So it's \`-- pipeline\` in a DuckDB node, \`# pipeline\` in a Python node, \`// pipeline\` in a bun node. Every annotation below uses that same prefix (the \`//\` shown is the TS form). All other wiring is expressed as annotation comments near the top of the file: + +- \`// on \` — declares an execution-DAG **input** (what triggers/feeds this node). \`\` is either: + - an **asset URI** (the node runs when that asset is produced upstream): \`ducklake://main/orders\`, \`datatable://main/users\`, \`$res:f/folder/my_resource\`, \`volume://name/path\`, or an S3 object (see the S3 storage-form rule below). + - a **native trigger kind**: \`schedule\`, \`webhook\`, \`email\`, \`kafka\`, \`mqtt\`, \`amqp\`, \`nats\`, \`postgres\`, \`sqs\`, \`gcp\`, or \`data_upload\` (a user-uploaded S3 file). For these the actual trigger row (cron, topic, …) is created separately; the annotation only declares the binding. **\`data_upload\` is special**: there is no trigger row — the node instead declares an **\`S3Object\` input parameter** fed by the auto-generated upload picker; it never hard-codes a key. Any language can be the \`data_upload\` node: + - Python (has \`import wmill\`): \`def main(file: wmill.S3Object):\` then \`wmill.load_s3_file(file)\`; TS (has \`import * as wmill from "windmill-client"\`): \`export async function main(file: wmill.S3Object)\`. Qualify the type as \`wmill.S3Object\` (or add \`from wmill import S3Object\` / \`import { S3Object } from "windmill-client"\`) — a bare \`S3Object\` is undefined. + - **DuckDB** takes the s3object arg via a \`-- $ (s3object)\` declaration and reads it directly, so a single DuckDB node can ingest **and** materialize: + \`\`\` + -- pipeline + -- on data_upload + -- materialize ducklake://main/raw_uploads + -- $file (s3object) + SELECT * FROM read_csv($file) + \`\`\` +- **Outputs** are inferred from what the body writes — a \`CREATE TABLE\`, a \`wmill.writeS3File(...)\`, a DuckLake/datatable write. To declare a managed output explicitly, use \`// materialize \`. +- Optional badges: \`// partitioned \`, \`// freshness \` (e.g. \`1h\`), \`// tag \`, \`// retry [delay]\`, \`// data_test ...\` (managed DuckLake targets only — deploy rejects it beside a \`dbt://\` one), \`// measure = [where ]\` and \`// dimension = \` (see "Declared metrics" below). + +## S3 object wiring (storage form matters) + +An \`s3://\` URI's first slashes select the **storage**, not part of the key — get this wrong and the producer/consumer edge silently won't connect: + +- \`s3:///\` (**triple** slash, empty first segment) = the **default** workspace storage. A downstream node reading or triggering on that object uses \`s3:///\` — e.g. DuckDB \`-- on s3:///orders/2024.parquet\` and \`read_parquet('s3:///orders/2024.parquet')\`. +- \`s3:///\` (**double** slash, non-empty first segment) = a **named secondary** storage called \`\` — so \`s3://ingest/x\` means storage \`ingest\`, key \`x\`, NOT key \`ingest/x\`. Only use this when the object genuinely lives in a configured secondary storage; never invent a bucket/storage name for a default-storage object (it breaks the edge). + +To make the producer side visible to lineage, a Python/TS node MUST pass the **\`S3Object\` form**, not a bare key string: Python \`wmill.write_s3_file(wmill.S3Object(s3=""), data)\` (or the import-free dict \`{"s3": ""}\`), TS \`wmill.writeS3File({ s3: "" }, data)\`. That records the default-storage asset \`/\`, which a downstream \`s3:///\` reader connects to (same key both sides). A bare \`write_s3_file("", ...)\` records **no** asset and produces **no** edge. Add \`storage=""\` only for a named secondary storage. + +The key must be a **string literal** — the graph parser is static and cannot follow a variable, f-string, or computed path, so \`write_s3_file(wmill.S3Object(s3=key_var), ...)\` records no edge. Inline the literal (\`s3="events/user_events.parquet"\`) on both the writing and reading node. The same rule applies to every asset URI in an annotation or SDK call (\`ducklake://\`, \`datatable://\`, \`s3://\`): write them literally, not via a variable. + +## Materialize (the managed output) + +> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake:///
\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the output via the SDK (\`wmill.writeS3File(...)\`, ducklake helpers, …) and let it be inferred. Use \`duckdb\` when a node should materialize a DuckLake table. +> +> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: \`// materialize manual dbt:////\`, where \`\` is a warehouse the workspace configures under Settings → dbt. \`manual\` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql \`CREATE TABLE\` / \`INSERT\`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a \`source\`: the declared relation and the dbt model land on ONE graph node, and a downstream \`// on dbt:////\` fires when the ingestion node completes. + +A managed \`// materialize ducklake:///
\` tells the runtime to write the node's output table **for you**: write the body as a single \`SELECT\` and the runtime wraps it in the create/replace — do **not** also write your own \`CREATE TABLE\` / \`INSERT\`. (The opposite holds for the \`dbt://\` target above: there the node writes its own DDL and the strategies below do not apply.) Write strategy: + +- no option → **replace** the whole table each run (full refresh; the only mode whose output columns may change); +- \`// materialize append\` → INSERT-append rows (incremental); +- \`// materialize key=\` → merge/upsert on \`\`. + +\`// materialize manual \` opts **out** of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. + +\`materialize\` pairs with partitioning for incremental pipelines: a \`// partitioned \` node runs **once per partition** (append/merge into a fixed-schema table). The \`{partition}\` token, usable in any asset URI **and** in the body SQL, is replaced at run time by the current partition's **identity string**: + +- To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition() = {partition}\`. \`wm_partition(ts)\` buckets a timestamp in exactly the identity format the runtime uses for daily/hourly/weekly/monthly, so it always matches; never hand-write a \`strftime\` format. +- Do NOT write \`= TIMESTAMP {partition}\`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at run time. +- For \`dynamic\` partitioning the identity is the caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE = {partition}\`. + +\`materialize\` is an output **declaration** on a node — not a command. There is no "materialize run". + +## Declared metrics (\`measure\` / \`dimension\`) + +On a node that materializes a DuckLake table, \`// measure = [where ]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension = \` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents reuse the definition instead of re-deriving it and silently disagreeing. + +- Keep the predicate in the \`where\` clause rather than folding it into the aggregate: it is rendered as \` FILTER (WHERE )\`, which is what lets two measures with different predicates share one GROUP BY. +- DuckLake-only, and only meaningful next to \`// materialize\`. +- Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures — an obvious \`count(*)\` earns nothing. +- To use a metric another node declares, read that node and reuse its exact expression rather than guessing it. + +## How to build one + +1. Put every node in the **same folder**: \`f//\`. The folder is the pipeline. +2. Write each node as its own script. Default to \`duckdb\` materializing into DuckLake (see "Default to DuckDB + DuckLake" above); pick \`postgresql\`, \`bun\`, or \`python3\` only when that section says the work calls for it. +3. Start each body with \`// pipeline\`, then the \`// on\` input declarations, then the transform that writes the output. +4. **Chain nodes by asset URI**: read an upstream node's output asset, then \`// on \` in the downstream node so the edge forms. Reuse exact asset paths from existing nodes rather than inventing parallel ones. +5. Don't deploy nodes unless the user asks to. A pipeline only "runs" once its scripts are deployed and their triggers exist. + +Locally: + +- create each node with \`wmill script new f// \`, then write its body; +- \`wmill pipeline show --local\` draws the graph from your working tree — check that every edge you meant to form is there; +- \`wmill pipeline dev \` live-previews the pipeline, and \`wmill pipeline run --local\` runs the cascade from local files without deploying (\`--dry-run\` prints the plan first); +- a trigger such as \`// on schedule\` only declares the binding: the schedule or trigger itself is created separately (see the \`schedules\` and \`triggers\` skills). + +## Example (DuckDB → DuckLake, scheduled ingest + downstream transform) + +Node \`f/sales/orders_ingest\` (runs on a schedule, materializes a DuckLake table): + +\`\`\`sql +-- pipeline +-- on schedule +-- materialize ducklake://main/orders +SELECT * FROM read_csv('s3:///raw/orders/*.csv') +\`\`\` + +Node \`f/sales/orders_daily\` (runs when \`orders\` is produced, writes a rollup): + +\`\`\`sql +-- pipeline +-- on ducklake://main/orders +-- materialize ducklake://main/orders_daily +SELECT date_trunc('day', ts) AS day, count(*) AS n +FROM ducklake.main.orders GROUP BY 1 +\`\`\` `, "cli-commands": `--- name: cli-commands @@ -7538,7 +8191,7 @@ Manage jobs (import/export) ### lint -Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file +Validate Windmill flow, schedule, and trigger YAML files in a directory (including AI agent tool names and flow groups/notes), and report script metadata that has no deployable content file **Arguments:** \`[directory:string]\` diff --git a/cli/test/lint_command_unit.test.ts b/cli/test/lint_command_unit.test.ts index 10ecd80bdb..4afe905b3f 100644 --- a/cli/test/lint_command_unit.test.ts +++ b/cli/test/lint_command_unit.test.ts @@ -386,3 +386,167 @@ raw_string: false ).toBeTruthy(); }); }); + +async function lintFlow(tempDir: string, value: string) { + await mkdir(`${tempDir}/f/sem/check.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/sem/check.flow/flow.yaml`, + `summary: Semantic checks\nvalue:\n${value}`, + "utf-8", + ); + return await runLint({} as any, tempDir); +} + +test("lint: rejects AI agent tool names the worker refuses", async () => { + await withTempDir(async (tempDir) => { + const report = await lintFlow( + tempDir, + ` modules: + - id: agent + value: + type: aiagent + input_transforms: {} + tools: + - id: t_spaced + summary: Search docs + value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} } + - id: t_missing + value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} } + - id: t_first + summary: get_user + value: { tool_type: flowmodule, type: script, path: f/sem/get_user, input_transforms: {} } + - id: t_dup + summary: get_user + value: { tool_type: flowmodule, type: script, path: f/sem/get_user, input_transforms: {} } + - id: t_reserved + summary: if + value: { tool_type: flowmodule, type: script, path: f/sem/branch, input_transforms: {} } + - id: t_mcp + summary: "" + value: { tool_type: mcp, resource_path: f/sem/mcp } + - id: t_web + summary: Web Search + value: { tool_type: websearch } + - id: linked + value: + type: aiagent + agent: f/sem/support_agent + input_transforms: {} + tools: + - id: t_ignored + summary: Stale name + value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} } +`, + ); + + const errors = report.issues.flatMap((i) => i.errors); + expect(errors).toEqual([ + "/value/modules/0/value/tools/0 tool name 'Search docs' may only contain letters, numbers and underscores (a run that offers this tool fails with 'Invalid tool name')", + "/value/modules/0/value/tools/1 has no summary: a tool's summary is the name the agent calls it by", + "/value/modules/0/value/tools/3 tool name 'get_user' is already used by tool 2 of this agent", + ]); + expect(report.warnings.map((w) => w.message)).toEqual([ + "/value/modules/0/value/tools/4 tool name 'if' is a reserved id; the flow editor refuses it", + ]); + expect(report.exitCode).toEqual(1); + }); +}); + +test("lint: rejects flow groups the editor cannot draw", async () => { + await withTempDir(async (tempDir) => { + const step = (id: string) => + ` - id: ${id}\n value: { type: identity }\n`; + const report = await lintFlow( + tempDir, + ` modules: +${step("a")}${step("b")}${step("c")} - id: loop + value: + type: forloopflow + iterator: { type: javascript, expr: "[1]" } + skip_failures: false + modules: + - id: inner + value: { type: identity } + groups: + - { start_id: c, end_id: a } + - { start_id: a, end_id: inner } + - { start_id: a, end_id: gone } + - { start_id: a, end_id: b } + - { start_id: b, end_id: c } + - { start_id: a, end_id: b } +`, + ); + + expect(report.issues.flatMap((i) => i.errors)).toEqual([ + "/value/groups/0 starts at 'c', which comes after its end 'a'", + "/value/groups/1 starts at 'a' and ends at 'inner', which are not in the same branch", + "/value/groups/2 references 'gone', which is not a step of this flow", + "/value/groups/5 spans the same steps as another group (a to b)", + "/value/groups/4 partly overlaps /value/groups/3: groups may nest but not overlap", + ]); + }); +}); + +test("lint: accepts nested groups and flags notes the editor renders wrong", async () => { + await withTempDir(async (tempDir) => { + const report = await lintFlow( + tempDir, + ` modules: + - { id: a, value: { type: identity } } + - { id: b, value: { type: identity } } + - id: loop + value: + type: forloopflow + iterator: { type: javascript, expr: "[1]" } + skip_failures: false + modules: + - { id: x, value: { type: identity } } + - { id: y, value: { type: identity } } + groups: + - { summary: Fetch, start_id: a, end_id: loop, color: blue } + - { summary: Prep, start_id: a, end_id: b } + - { summary: Body, start_id: x, end_id: y, color: "#ff0000" } + notes: + - { id: n1, text: Purpose, color: yellow, type: free, position: { x: -400, y: 0 }, size: { width: 275, height: 80 } } + - { id: n2, text: Unplaced, color: yellow, type: free } + - { id: n3, text: Old, color: gray, type: group, contained_node_ids: [a] } +`, + ); + + expect(report.issues).toEqual([]); + expect(report.warnings.map((w) => w.message)).toEqual([ + "/value/groups/2 color '#ff0000' renders unstyled; use one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray", + "/value/notes/1 has no position or size, so the editor places it at the origin and cannot resize it", + "/value/notes/2 is a 'group' note, which is deprecated: use value.groups instead", + ]); + expect(report.exitCode).toEqual(0); + }); +}); + +test("lint: reports a flow with malformed collections instead of aborting the run", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/sem/other.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/sem/other.flow/flow.yaml`, + `summary: Other\nvalue:\n modules: {}\n`, + "utf-8", + ); + const report = await lintFlow( + tempDir, + ` modules: + - id: route + value: { type: branchall, branches: {} } + - id: agent + value: { type: aiagent, input_transforms: {}, tools: {} } + groups: {} + notes: {} +`, + ); + + expect(report.issues.map((i) => i.path).sort()).toEqual([ + "f/sem/check.flow/flow.yaml", + "f/sem/other.flow/flow.yaml", + ]); + expect(report.exitCode).toEqual(1); + }); +}); diff --git a/docs/external-instance-datatables.md b/docs/external-instance-datatables.md new file mode 100644 index 0000000000..acfb93b90a --- /dev/null +++ b/docs/external-instance-datatables.md @@ -0,0 +1,88 @@ +# External instance data tables + +A data table is backed by one of three things: a Postgres resource a workspace brings +(`postgresql`), a database on Windmill's own cluster (`instance`), or a database on a separate +cluster Windmill administers (`external_instance`, Enterprise Edition). The third is what this +document covers; Ducklake catalogs take the same three shapes. + +Windmill administers the external cluster the way it administers its own: it creates and drops +databases there, owns `custom_instance_user` and `custom_instance_replication_user`, and creates +the data table roles of that cluster. It logs in as the admin in the `external_instance_pg` +instance setting, and keeps what it generates in the hidden `external_instance_pg_state` setting. + +## Code + +| Where | What | +|---|---| +| `windmill-common/src/external_instance_pg.rs` | Setting, state, usage accounting, the lifecycle lock, the OSS forwarders | +| `windmill-common/src/external_instance_pg_ee.rs` | Setup, database create and drop, the admin connection | +| `windmill-common/src/datatable_roles.rs` | Per-cluster role catalogs (`DatatableRoleCluster`) | +| `windmill-common/src/workspaces.rs` | Resolution (`resolve_datatable_connection_unchecked`), `managed_database_uses` | +| `windmill-api-settings/src/lib.rs` | `/settings/external_instance_pg/*`, `/settings/datatable_roles` | + +## What holds it together + +- **One lifecycle lock.** `lock_external_instance_pg_state` serializes everything that changes + which databases exist on the cluster or which entries name them: setup, create, drop, data table + and Ducklake saves, external role DDL, and writes to the setting itself. Anything reading the + configuration to reach the cluster reads it under that lock, so a database is never created on + one cluster and registered while the setting names another. +- **Windmill only touches what it made.** Databases it creates carry a comment, and a drop + requires it. The two managed roles and every data table role carry their own comment, and setup + refuses a `custom_instance_user` without it rather than resetting the password of someone else's + role. +- **Creation needs a successful setup.** `set_up_for` records the `host:port` the last successful + setup converged. Creating a database on a cluster that setup has not succeeded on is refused. +- **Nothing is dropped from under a user.** `managed_database_uses` lists every data table naming + a database, every fork pointing at those, every Ducklake catalog on it, and every fork Ducklake + metadata schema still to be dropped. Fork cleanup exempts exactly the entry it is cleaning up. +- **Fork copies belong to a workspace.** `wm_fork_*` is a name, not an authorization: every + database of a cluster answers to the same `custom_instance_user`. The registry records the + workspace a copy was created for, and a member can only import into or fork onto a copy of their + own workspace. +- **Roles are per cluster.** `datatable_role.cluster` splits the catalog, so the same role name can + exist on both clusters. Role names are unique per cluster, as they are in Postgres. + +## Running one locally + +```bash +docker run -d --name wm-external-pg -e POSTGRES_PASSWORD=external -p 5497:5432 postgres:18 \ + -c wal_level=logical +psql "postgresql://postgres:external@127.0.0.1:5497/postgres" \ + -c "CREATE ROLE wm_admin LOGIN PASSWORD 'adminpw' CREATEDB CREATEROLE REPLICATION" +``` + +A non-superuser admin with `CREATEDB` and `CREATEROLE` is the realistic case: managed Postgres +gives nothing more. `REPLICATION` is only needed for Postgres triggers on external data tables. + +Then, as superadmin (`$T` is a token): + +```bash +api=http://localhost:8000/api +curl -s -X POST $api/settings/global/external_instance_pg -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + --data '{"value":{"host":"127.0.0.1","port":5497,"user":"wm_admin","password":"adminpw","sslmode":"disable"}}' +curl -s -X POST $api/settings/external_instance_pg/setup -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' --data '{}' # report per step +curl -s -X POST $api/settings/external_instance_pg/databases/dt_demo -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' --data '{}' +curl -s -X POST $api/w/admins/workspaces/edit_datatable_config -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + --data '{"settings":{"datatables":{"demo":{"database":{"resource_type":"external_instance","resource_path":"dt_demo"}}}}}' +``` + +`sslmode` defaults to `verify-full`; `disable` is for a local container only. With `verify-full` +against a server with a private CA, put the CA in `root_certificate_pem` — `pg_dump`, `psql` and +DuckDB attaches all verify against the system trust store plus that certificate. + +Jobs then reach it as any data table: `ATTACH 'datatable://demo' AS d` from DuckDB, or +`datatable://demo` as the database of a PostgreSQL script, with `-- role ` to connect as a +data table role of that cluster. + +Worth knowing while testing: + +- A worker needs the `postgresql` and `duckdb` tags for those jobs + (`update config set config = jsonb_set(config, '{worker_tags}', …) where name = 'worker__default'`). +- DuckDB jobs load `libwindmill_duckdb_ffi_internal.so` by name, so a binary built into its own + `CARGO_TARGET_DIR` needs that library on `LD_LIBRARY_PATH`. +- Setup holds the lifecycle lock for its whole run, so a settings save during it waits. diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 9dacfc1cbd..c0d7b9c027 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,7 +4,7 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 58 registered actions across twenty features (`ai_session`, `ai_chat`, +It currently carries 61 registered actions across twenty features (`ai_session`, `ai_chat`, `ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`, `flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the @@ -48,7 +48,7 @@ vocabulary closed and small — enumerate the values in a TS union next to the c ## The recipe -Four steps. Skipping step 1 or 3 fails quietly. +Four steps. Skipping step 1 fails quietly. **1. Register the pair** in `FEATURE_USAGE_KINDS` (`backend/windmill-common/src/feature_usage_ee.rs`, tracked in `windmill-ee-private`). An @@ -68,9 +68,10 @@ Fire-and-forget. Events sum locally per `(workspace, feature, kind, key, entityI every 30s, on `visibilitychange` → hidden, and on `pagehide`; 50 events per request, and a failed batch is dropped rather than retried. -**3. Update the disclosure.** `InstanceSettings.svelte` lists what a non-minimal payload contains -(two places — the copy appears twice). A new counter that isn't named there means the instance -under-discloses what it sends. This has already drifted once. +**3. Check the disclosure.** `InstanceSettings.svelte` describes a non-minimal payload by +category, not counter by counter (two places — the copy appears twice); keep it short. A plain +counter needs no edit. Only a key that sends a new kind of value beyond a count — a name or an +identifier, like the AI model and hub names it already lists — must be added there. **4. Verify a row lands.** The silent-drop path means "no error" proves nothing: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d15b98308..42e9804adc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.818.0", + "version": "1.819.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.818.0", + "version": "1.819.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 46a1f38e47..db71f92e1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.818.0", + "version": "1.819.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 68c1c558b1..8940f265ce 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "60b6904", - "sha256": "85b68e8418c97d96f3a419b3e345ef8a1987976943b8439a7df841f9217a9b34" + "version": "9fddc52", + "sha256": "b45558951f3d4c9dce7b88b5139d66b0f5f01fe561a22efcb0b8400a13240522" } diff --git a/frontend/serve_ui_builder.sh b/frontend/serve_ui_builder.sh index e396c6f1b4..4bf7e48a4d 100755 --- a/frontend/serve_ui_builder.sh +++ b/frontend/serve_ui_builder.sh @@ -9,8 +9,6 @@ os.chdir('ui_builder_serve') from http.server import HTTPServer, SimpleHTTPRequestHandler class H(SimpleHTTPRequestHandler): def end_headers(self): - self.send_header('Cross-Origin-Opener-Policy', 'same-origin') - self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') self.send_header('Cross-Origin-Resource-Policy', 'cross-origin') super().end_headers() HTTPServer(('', 4000), H).serve_forever() diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 1bf9e7fde3..750524457e 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -4,7 +4,7 @@ import { argsToJsonPayload } from '$lib/schema' import JobLoader from '$lib/components/JobLoader.svelte' import { Button } from '$lib/components/common' - import { WindmillIcon } from '$lib/components/icons' + import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte' import LogPanel from '$lib/components/scriptEditor/LogPanel.svelte' import { type CompletedJob, diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index d0de2026cf..a49f09bb02 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -22,7 +22,6 @@ Database, Loader2 } from 'lucide-svelte' - import DucklakeResultPreview from './assets/AssetGraph/DucklakeResultPreview.svelte' import DataTestsResult from './DataTestsResult.svelte' import Portal from '$lib/components/Portal.svelte' import DisplayResultControlBar from './DisplayResultControlBar.svelte' @@ -1105,11 +1104,15 @@ /> {#if showMaterializedPreview}
- + + {#await import('./assets/AssetGraph/DucklakeResultPreview.svelte') then DucklakeResultPreview} + + {/await}
{/if} diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 30adb56c7c..b4bc4471a2 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -307,7 +307,6 @@ import { MinusIcon, SearchIcon } from 'lucide-svelte' import { assignObjInPlace, clone } from '$lib/utils' import GenericDropdown from './select/GenericDropdown.svelte' - import SimpleEditor from './SimpleEditor.svelte' import TaggedTextInput from './TaggedTextInput.svelte' import { DebouncedTempValue, useTransformedSyncedValue } from '$lib/svelte5Utils.svelte' import { untrack } from 'svelte' @@ -885,20 +884,23 @@ {:else if filter.type === 'string' && filter.format === 'json'}
- String(value[currentTag!] ?? ''), - (v) => { - setValueForCurrentTag(v ?? '') - taggedTextInput?.preventCursorMoveOnNextSync() + + {#await import('./SimpleEditor.svelte') then SimpleEditor} + String(value[currentTag!] ?? ''), + (v) => { + setValueForCurrentTag(v ?? '') + taggedTextInput?.preventCursorMoveOnNextSync() + } } - } - class="border border-border-light rounded min-h-[4rem]" - /> + class="border border-border-light rounded min-h-[4rem]" + /> + {/await}
{:else if filter.type === 'string'} {#if menuItems.length} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index f341e327ee..60577ef124 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -106,11 +106,12 @@ import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from './sessions/openInSessionContext' import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext' - import { triggerLock } from '$lib/operatorWriteRights' + import { useTriggerLock } from '$lib/operatorWriteRights' import { useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + const triggerLock = useTriggerLock() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 9ccac1d8c6..5489e4abb5 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -1,5 +1,6 @@ + +{#if count > 0} + + {count > 9 ? '9+' : count} + +{/if} + + diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 2d88d1deb7..387eea15d0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -247,8 +247,16 @@ function onWindowKeydownCapture(e: KeyboardEvent) { if (e.key !== 'Escape' || !chatHost.loading) return const active = document.activeElement + // Focus parked on the body answers for the panel on screen only: the flow chat keeps + // a panel per conversation mounted, several of which can be loading, and a hidden + // one's listener would otherwise swallow the press (immediate form, below) and stop + // its own turn instead. `inert` marks the panels behind, and is matched as an + // attribute so the browsers without `checkVisibility` read it too. + const hidden = + panelEl?.closest('[inert]') != null || + panelEl?.checkVisibility?.({ visibilityProperty: true }) === false const focusOnChat = - !active || active === document.body || (panelEl?.contains(active) ?? false) + !hidden && (!active || active === document.body || panelEl?.contains(active) === true) // An Escape while a run form is open must not discard what the user typed, so the action // row alone stops the turn — wherever it is mounted, since the preview panel holds the // form outside `panelEl`. Matched by call: two chats can be loading at once, and one's @@ -487,22 +495,30 @@ const flatFiles = Array.from(dt.files ?? []) const topLevelImages = flatFiles.filter(isImageFile) const imageWork: Promise[] = [] + // The composer this drop landed on, held for the whole routing: a panel destroyed + // while a file is being read clears the binding, and the file would then reach no + // composer at all. + const dropped = aiChatInput if (topLevelImages.length > 0) { - imageWork.push(aiChatInput?.addImages(topLevelImages) ?? Promise.resolve()) + imageWork.push(dropped?.addImages(topLevelImages) ?? Promise.resolve()) } // Text-file routing must await handle/entry resolution before it can call // addTextFiles — hold sending across that window (taken BEFORE the first // await) or a send mid-resolution would land the drop on the next message. - const releaseSendHold = aiChatInput?.holdSendForIngestion() + const releaseSendHold = dropped?.holdSendForIngestion() try { - await routeDroppedTextAndFolders(dt, flatFiles) + await routeDroppedTextAndFolders(dt, flatFiles, dropped) } finally { releaseSendHold?.() } await Promise.all(imageWork) } - async function routeDroppedTextAndFolders(dt: DataTransfer, flatFiles: File[]) { + async function routeDroppedTextAndFolders( + dt: DataTransfer, + flatFiles: File[], + input: typeof aiChatInput + ) { if (canUseFsAccess) { // getAsFileSystemHandle calls are kicked off synchronously inside this call. const handles = await handlesFromDataTransfer(dt) @@ -514,7 +530,10 @@ ? flatFiles : await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile())) // Loose files attach to the message, like images. - await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f))) + await attachNonImageFiles( + looseFiles.filter((f) => !isImageFile(f)), + input + ) // Folders link as a live handle. const dirs = handles.filter(isDirectoryHandle) if (dirs.length > 0 && !canLinkFolders) { @@ -551,7 +570,7 @@ if (canLinkFolders) await handleAddFiles(folderEntries) else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true) } - await attachNonImageFiles(topLevelText) + await attachNonImageFiles(topLevelText, input) } } @@ -563,8 +582,10 @@ input.value = '' // allow re-selecting the same file } - async function attachNonImageFiles(files: File[]) { - await aiChatInput?.addNonImageFiles(files) + /** `input` is the composer the files are for, captured before any await: read off the + * binding afterwards it would be null once the panel has been destroyed. */ + async function attachNonImageFiles(files: File[], input: typeof aiChatInput) { + await input?.addNonImageFiles(files) } async function attachPickedFiles(picked: File[]) { @@ -572,7 +593,7 @@ const others = picked.filter((f) => !isImageFile(f)) // Reserved before the other work is awaited — see onPanelDrop. const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined - await attachNonImageFiles(others) + await attachNonImageFiles(others, aiChatInput) await imageWork } diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index c9c786ccbc..7bf1d99693 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -23,7 +23,6 @@ import { WorkspaceService, type AIProvider, type AIProviderModel } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { base } from '$lib/base' - import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte' import { getAiChatManager } from './aiChatManagerContext' import { thinkingPreferences } from './thinkingPreferences.svelte' import { @@ -95,6 +94,11 @@ // ---- prompt parameters (User / Workspace custom prompts) ---- let mode = $derived(aiChatManager.mode) let modalOpen = $state(false) + // Sticky, so the prompts modal mounts on first open and then stays for its close animation. + let promptsModalRequested = $state(false) + $effect(() => { + if (modalOpen) promptsModalRequested = true + }) let modalScope = $state<'user' | 'workspace'>('user') let customPrompts = $state>({}) let initialPrompt = $state('') @@ -285,20 +289,24 @@ -{#if promptSettings} - +{#if promptSettings && promptsModalRequested} + + {#await import('$lib/components/settings/AIPromptsModal.svelte') then AIPromptsModal} + + {/await} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 4f3ef368ad..1a1412b9f8 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -1,7 +1,6 @@ - - drawer?.closeDrawer()}> - {#if activeDrawer} - {#key activeDrawer.key} - {#await activeDrawer.promise} -
- -
- {:then Module} - {@const Editor = Module.default as EditorComponent} - - {/await} - {/key} - {/if} -
-
- - - +{#if inner} + {#await inner then Inner} + + {/await} +{/if} diff --git a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte new file mode 100644 index 0000000000..9dd8fe643c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte @@ -0,0 +1,192 @@ + + + + drawer?.closeDrawer()}> + {#if activeDrawer} + {#key activeDrawer.key} + {#await activeDrawer.promise} +
+ +
+ {:then Module} + {@const Editor = Module.default as EditorComponent} + + {/await} + {/key} + {/if} +
+
+ + + diff --git a/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte b/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte index 0808d6e172..191f6c7267 100644 --- a/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/WebSearchSourcesDisplay.svelte @@ -43,9 +43,9 @@ // consulted hostname to a third party from the user's browser — an accepted // tradeoff for now (blocked/air-gapped environments degrade to the Globe // icon via onerror). Hit gstatic directly rather than www.google.com/s2/ - // favicons: the app is served with COEP require-corp, and the s2 redirect - // hop carries no Cross-Origin-Resource-Policy header, so the browser blocks - // the image. The gstatic endpoint itself responds with CORP: cross-origin. + // favicons: the s2 redirect hop carries no Cross-Origin-Resource-Policy + // header, so a cross-origin isolated page would block the image. The + // gstatic endpoint itself responds with CORP: cross-origin. function faviconUrl(hostname: string): string { return `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(hostname)}&size=64` } diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 634341b966..7964f1a908 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -19,6 +19,7 @@ import { type AppDatatableElement } from '../context' import { appDatatableRole, sdkDatatableCall } from '$lib/components/raw_apps/dataTableRefUtils' +import { RAW_APP_BASE } from '$system_prompts' // Backend runnable types export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline' @@ -937,36 +938,14 @@ export function prepareAppSystemMessage(customPrompt?: string): ChatCompletionSy )}. Always pass that role when calling \`wmill.datatable\` on them, as in the examples. The role only reaches what it was granted, so a query on a table it lacks privileges on fails with \`permission denied\`.` : '' - let content = `You are a helpful assistant that creates and edits apps on the Windmill platform. Apps are defined as a collection of files that contains both the frontend and the backend. + // Domain guidance for raw apps (structure, runnables, data tables, access) is RAW_APP_BASE, + // shared with global mode and the CLI's raw-app skill; this prompt adds only the app editor's + // tools and this app's data-table policy. + let content = `You are a helpful assistant that creates and edits apps on the Windmill platform. Apps are defined as a collection of files that contains both the frontend and the backend; the reference below describes how they work. The sections after it cover this editor's tools and this app's own configuration, which take precedence over the reference's generic examples. Frontend files are managed separately from backend runnables, and inline backend runnables are TypeScript (Bun) or Python. -## App Structure +${RAW_APP_BASE} -### Frontend -- The frontend is bundled using esbuild, with entrypoint \`index.tsx\` for React and \`index.ts\` for Svelte and Vue -- The entrypoint is also the **mount** entrypoint: nothing is auto-rendered, so it must mount a top-level \`App\` into \`#root\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` / \`App.vue\` and keep the entrypoint as the mount shim: - \`\`\`tsx - import React from 'react' - import { createRoot } from 'react-dom/client' - import App from './App' - - createRoot(document.getElementById('root')!).render() - \`\`\` - (Svelte \`index.ts\`: \`mount(App, { target: document.getElementById('root')! })\`; Vue \`index.ts\`: \`createApp(App).mount('#root')\`.) -- **Never replace the entrypoint with a bare component.** A component that is defined but never mounted renders a blank screen with **no error** — it never executes, so nothing throws. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`. -- Frontend files are managed separately from backend runnables -- The \`wmill.d.ts\` file is generated automatically from the backend runnables shape -- Begin every React file (\`.tsx\`/\`.jsx\`) that uses JSX with \`import React from 'react'\`. Raw apps bundle with the classic JSX transform, so \`React\` must be in scope wherever JSX is used — a missing import compiles fine but throws \`React is not defined\` at runtime. - -### Backend -Backend runnables can be of different types: -- **inline**: Custom code written directly in the app (TypeScript/Bun or Python) -- **script**: Reference to a workspace script by path -- **flow**: Reference to a workspace flow by path -- **hubscript**: Reference to a hub script by path - -Frontend calls backend using \`await backend.(args...)\`. - -For inline scripts, the code must have a \`main\` function as its entrypoint. +# App editor ## Available Tools @@ -1004,13 +983,11 @@ Use \`patch_file\` for small, localized edits when you can copy an exact snippet ## Data Storage with Data Tables -**When the app needs to store or persist data, you MUST use datatables.** Datatables provide a managed PostgreSQL database that integrates seamlessly with Windmill apps, with near-zero setup and workspace-scoped access. - -### Key Principles +Persist app data in data tables, following "Data Tables" in the reference above. 1. **Always check existing tables first**: Use \`list_datatables()\` to see what tables are already available. If a suitable table exists, **always reuse it** rather than creating a new one. For dashboards that only show available tables or row counts, \`list_datatables()\` is enough. Only call \`get_datatable_table_schema()\` for tables whose column names/types you need. -2. **CRITICAL: Create tables ONLY via exec_datatable_sql tool**: When you need to create a new table, you MUST use the \`exec_datatable_sql\` tool with the \`new_table\` parameter. **NEVER** create tables inside backend runnables using SQL queries - this will not register the table properly and it won't be available for future use. +2. **CRITICAL: Create tables ONLY via exec_datatable_sql tool**: When you need to create a new table, you MUST use the \`exec_datatable_sql\` tool with the \`new_table\` parameter, which also registers the table with the app. **NEVER** create tables inside backend runnables. \`\`\` exec_datatable_sql({ datatable_name: "main", @@ -1019,17 +996,9 @@ Use \`patch_file\` for small, localized edits when you can copy an exact snippet }) \`\`\` -3. **Use schemas to organize data**: Use PostgreSQL schemas to organize tables logically. Reference schemas with \`schema.table\` syntax. +### Accessing this app's data tables from backend runnables -4. **Use datatables for**: - - User data, settings, preferences - - Application state that needs to persist - - Lists, records, logs, history - - Any data the app needs to store and retrieve - -### Accessing Data Tables from Backend Runnables - -Backend runnables should only perform **data operations** (SELECT, INSERT, UPDATE, DELETE) on **existing tables**. Never use CREATE TABLE, DROP TABLE, or ALTER TABLE inside runnables. +For this app's tables, use these calls rather than the generic \`wmill.datatable()\` examples in the reference: they carry the app's data table, role and schema. **TypeScript (Bun) example**: \`\`\`typescript @@ -1106,31 +1075,6 @@ When creating a backend runnable with \`set_backend_runnable\`: } \`\`\` - -Windmill expects all backend runnable calls to use an object parameter structure. For example for: -\`\`\`typescript -export async function main(arg1: string, arg2: string, arg3: number, arg4: { field1: string, field2: number }) { - ... -} -\`\`\` - -You would call it like this: -\`\`\`typescript -await backend.myFunction({ arg1: 'value1', arg2: 'value2', arg3: 3, arg4: { field1: 'value1', field2: 2 } }) -\`\`\` -If the runnable has no parameters, you can call it without an object: -\`\`\`typescript -await backend.myFunction() -\`\`\` - -When you are using the windmill-client, do not forget that as id for variables or resources, those are path that are of the form \'u//\' or \'f//\'. - -Besides \`backend\`, the generated \`./wmill\` module exports \`backendAsync.(args)\` (resolves the job id as a string), \`waitJob(jobId)\` (resolves that job's result, rejects if it failed), \`getJob(jobId)\` (the current job state, for rendering progress) and \`streamJob(jobId, onUpdate)\`. Use \`backendAsync\` + \`waitJob\`/\`getJob\` for long-running work — never hand-write a runnable that polls job status, and never \`fetch\` the Windmill API from frontend code, which holds no token. - -A \`script\`/\`flow\` runnable runs the DEPLOYED item at that path, and so do \`wmill.runFlowAsync\`/\`wmill.runScriptByPath\` called inside a runnable — a draft is invisible to them, so an app pointed at an undeployed flow fails at runtime. The app itself does not need deploying — the preview runs its draft — so the fix is to deploy that one referenced item, not the whole change set. Say so instead of working around it, and never reimplement the flow inline to dodge the deployment. An \`inline\` runnable runs the app's own code and needs nothing deployed. - -Inside an inline runnable the \`wmill\` client configures itself from the job environment: don't read \`WM_TOKEN\` or \`BASE_INTERNAL_URL\` and build an API URL by hand — the client already does that, plus the credentials mode a raw app needs. Only call \`wmill\` functions that actually exist; \`getBaseUrl\` and \`getWorkspaceToken\` are inventions. - ## Instructions 1. Use the smallest context needed. If the target file or runnable is clear, inspect only that item with \`get_frontend_file(path)\` or \`get_backend_runnable(key)\`. diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index 9744085567..6cb398ee44 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -2,7 +2,7 @@ import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte' import { getContext, tick, untrack } from 'svelte' import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types' - import type { InputTransform } from '$lib/gen' + import { ApiError, type InputTransform } from '$lib/gen' import type { FlowAIChatHelpers } from './core' import { chatMemoryId } from '../global/core' import { createInlineScriptSession } from './inlineScriptsUtils' @@ -174,7 +174,19 @@ previewArgs.val = args } // Call the UI test function which opens preview panel - return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value)) + try { + return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value)) + } catch (e) { + // The flow chat is still answering in that conversation, and the server takes one + // turn at a time. Said plainly here: the raw refusal names a job the model has + // no use for. + if (e instanceof ApiError && e.status === 409) { + throw new Error( + 'That chat is still answering an earlier message; wait for it to finish before testing again.' + ) + } + throw e + } }, getLintErrors: async (moduleId: string): Promise => { diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 96b2858957..4adbe1634b 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -854,7 +854,9 @@ export const flowTools: Tool[] = [ ] export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam { - // Get base flow documentation from centralized prompts (includes FLOW_BASE, OPENFLOW_SCHEMA, RESOURCE_TYPES) + // Flow domain guidance (module shapes, loops, groups, AI agent tools…) is FLOW_BASE, shared with + // global mode and the CLI's write-flow skill: it belongs in system_prompts/base/flow-base.md. + // This prompt adds only the flow editor's tools. const flowBaseContext = getFlowPrompt() // Chat-specific tool instructions @@ -923,16 +925,8 @@ Use the \`set_flow_json\` tool to set the entire flow structure at once. Provide - \`schema\`: Flow input schema in JSON Schema format (optional) - \`preprocessor_module\`: Special module that runs before \`modules\` (optional, separate from \`modules\`) - \`failure_module\`: Special module that runs on failure (optional, separate from \`modules\`) -- \`groups\`: Array of semantic groups for organizing modules in the editor (optional, but **strongly recommended** — proactively segment any non-trivial flow into groups so it reads clearly; don't wait to be asked). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups. -- \`notes\`: Array of free-floating sticky notes shown in the editor (optional). Each note has \`id\` (unique string), \`text\` (markdown content), \`color\` (same palette as groups: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — never hex codes), and optional \`position\` {x, y} / \`size\` {width, height} (omit both — the editor auto-places and sizes the note). Always set \`type\` to \`free\`. The \`group\` note type is **deprecated** — do not create group notes; use the \`groups\` field to segment a flow instead. Notes are documentation only and do not affect execution. Pass \`null\` to clear existing notes. - -### When to use notes vs groups - -**Strongly prefer \`groups\` to organize flows.** Groups are the primary way to make a flow readable: whenever a flow has more than a couple of steps, or any time consecutive steps form a logical stage (e.g. "fetch", "transform", "notify"), segment them into \`groups\`. Each group spans a range of steps (\`start_id\`..\`end_id\`), carries its own \`summary\`, \`note\` (markdown under the group header), and \`color\`, and can be collapsed. Proactively add or update groups when building or restructuring a flow — do not wait to be asked. Aim for every meaningful step to belong to a semantic group. - -- **\`groups\` (default, use liberally):** segment a flow into labelled semantic sections. This is the main organizational tool — reach for it on essentially any non-trivial flow, not just "complex" ones. -- **\`notes\` (free sticky notes, use sparingly):** reserve for important flow-wide information that does not belong to a specific span of steps — overall purpose, key assumptions, warnings, or TODOs. Usually a single note is enough; do not use notes to label sequences of steps (that is what \`groups\` are for). -- Do **not** use \`group\`-type notes (deprecated) — \`groups\` is the supported way to group steps. +- \`groups\`: Array of semantic groups (optional, but add them to any non-trivial flow without being asked; see "Organizing Flows: Groups and Notes" below). Pass \`null\` to clear existing groups. +- \`notes\`: Array of free-floating sticky notes (optional; see the same section). Pass \`null\` to clear existing notes. **Example - Simple flow:** \`\`\`javascript diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index b8f44d2635..3bbd043a26 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.813.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window`: it is ignored when memory is off, and an older `auto` or\n`manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\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"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file +{"openapi":"3.0.3","info":{"version":"1.818.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"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, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","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","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window`: it is ignored when memory is off, and an older `auto` or\n`manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"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"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\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"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 5664f2349d..84715538b2 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -2296,6 +2296,9 @@ async function listWorkspaceItems( return items } +// The get_instructions builders below add global mode's tools and draft rules on top of the shared +// reference each ends with. Windmill domain guidance belongs in that reference +// (system_prompts/base, shared with the other chat modes and the CLI's skills), not here. function getScriptInstructions(language: ScriptLang | undefined): string { const selected = language ?? 'bun' const note = language @@ -2333,18 +2336,9 @@ async function getFlowInstructions(workspace: string | undefined): Promise/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`). - \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly. - Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item. -- Inline runnables run the app's DRAFT code, so they work in the preview with nothing deployed. Path runnables — and \`wmill.runFlow*\` / \`runScriptByPath\` called from inside any runnable — run the DEPLOYED item at that path, and a draft is invisible to them. An app wired to a flow you just drafted does nothing until that flow is deployed — but the APP does not have to be deployed for that: the preview runs its draft. So offer to deploy just the referenced flow/script with deploy_workspace_item and leave the app a draft the user keeps testing in the preview; don't route a one-item dependency deploy through the compare page, and don't ask them to deploy the app unless they want to ship it. Never dodge it by reimplementing the flow inside an inline runnable — that leaves two copies of the same logic and an app that ignores the flow they asked for. +- When a path runnable points at a draft (see "Draft code vs deployed code" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page. ${sdkLine} - Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app. - Use \`read_workspace_item\` with \`type: 'app'\` for a metadata summary (file paths and runnable list, no contents). Use \`read_app_file\` to read an individual file; large files are truncated to a head slice, so pass \`offset\`/\`limit\` to page through the rest rather than re-reading the whole file. - To find where a symbol or string lives across the app, call \`search_app\` (greps every frontend file and inline runnable, returns matching \`file:line\` rows) instead of reading files one by one — then \`read_app_file\` only the ranges you need. The loop is list (\`read_workspace_item\`) → locate (\`search_app\`) → inspect (\`read_app_file\` with \`offset\`/\`limit\`). -- Note: the authoring reference below mentions the CLI on-disk layout (\`backend/.\`, \`raw_app.yaml\`, \`sql_to_apply/\`). That layout is only relevant for the terminal workflow — in chat, apps are addressed via the tool surface above. # Windmill raw app authoring reference @@ -2403,9 +2396,8 @@ function getResourceInstructions(): string { - A resource draft is a workspace item: \`{ type: 'resource', path, summary?, value, isDraft }\`. \`value\` is a CreateResource body: \`{ path, value, description?, resource_type, labels? }\` where the inner \`value\` is the resource type's data shape. - Reading a variable returns \`{ type: 'variable', path, summary?, isSecret, isDraft }\` — never its value, secret or not. \`isSecret\` tells you whether the value is encrypted. - \`write_variable\` takes \`{ path, value?, is_secret?, description?, account?, is_oauth?, expires_at?, labels? }\`. Creating a variable needs \`value\` and \`is_secret\`; editing one needs only the fields you are changing. Omitting \`value\` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any \`value\` you did not get from the user destroys it. -- For secret fields in a resource value, do NOT inline the raw secret. Create a Variable first with \`is_secret: true\`, then in the resource value reference it as \`"$var:path/to/variable"\`. +- For secret fields in a resource value, create the variable with \`write_variable\` and \`is_secret: true\`, and deploy it before the resource (see "Secrets" in the reference below). - Reference formats inside resource values: \`$var:g/all/name\` (global), \`$var:u/user/name\` (user), \`$var:f/folder/name\` (folder). Reference another resource with \`$res:path/to/resource\`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value. -- When deploying drafts that depend on each other (e.g., a resource and the variables it references), deploy the variables first. - Use \`search_resource_types\` to discover valid \`resource_type\` names and their JSON Schemas. Match the resource value to that schema. - For OAuth resources, the \`is_oauth: true\` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones. diff --git a/frontend/src/lib/components/copilot/chat/global/gate.ts b/frontend/src/lib/components/copilot/chat/global/gate.ts index 8fb650aa36..d1dafe29ed 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -1,11 +1,11 @@ /** * Beta opt-out gate for AI Sessions (and the Global AI chat mode). * - * Sessions ship enabled by default. Users can switch back to the legacy - * docked chat from the beta banner under the session chat, which stores an - * opt-out in this browser's localStorage; the mirror banner in the legacy - * chat switches back. Both toggles do a full page reload: every call site - * reads the gate once at init, so a live flip would leave the UI half-switched. + * Sessions ship enabled by default. The opt-out lives in this browser's + * localStorage; nothing in the UI sets it any more, so only browsers that + * opted out earlier keep the legacy docked chat, whose banner switches them + * back. The toggle does a full page reload: every call site reads the gate + * once at init, so a live flip would leave the UI half-switched. * * When the beta ends, replace every call to `isGlobalAiEnabled()` with `true` * and delete this file. The references are intentionally narrow (chat mode @@ -35,29 +35,21 @@ export function prefersSessionHandoff(isOperator: boolean | undefined): boolean return isGlobalAiEnabled() && !isOperator } -/** Persist the opt-out choice, then hard-reload so every gated site re-reads it. */ -export function setSessionsBetaOptOut(optOut: boolean, target: string) { +/** Clear the opt-out, then hard-reload so every gated site re-reads it. */ +export function clearSessionsBetaOptOut(target: string) { // Navigate even when persistence throws (quota, private browsing) — the // button must not be a silent no-op. The reload then shows the unchanged // mode, which is the honest feedback that the toggle didn't stick. let persisted = true try { - if (optOut) { - localStorage.setItem(OPT_OUT_KEY, '1') - // Land with the legacy pane open — on a fresh profile the pane - // defaults to closed, and the round-trip must end in a visible chat - // (with its reactivation banner), not on a bare workspace page. - localStorage.setItem('ai-chat-open', 'true') - } else { - localStorage.removeItem(OPT_OUT_KEY) - } + localStorage.removeItem(OPT_OUT_KEY) } catch { persisted = false } // Anonymous usage counter on the shared feature_usage channel. The buffer's // pagehide flush + keepalive fetch carry it across the hard navigation below. if (persisted) { - logFeatureUsage('ai_session', optOut ? 'beta_optout' : 'beta_optin') + logFeatureUsage('ai_session', 'beta_optin') } window.location.href = target } diff --git a/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts b/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts index dd7c04c29a..62dce8af5c 100644 --- a/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts +++ b/frontend/src/lib/components/copilot/chat/global/rawAppBundlerBridge.ts @@ -132,9 +132,8 @@ export function bundleRawAppFiles({ iframe.title = 'Raw app bundler' iframe.tabIndex = -1 - // No `credentialless`: /ui_builder/* is itself served with COEP=require-corp, so it - // embeds in isolated pages as is, while a credentialless frame gets an empty cookie - // jar, which would leave its npm installer unauthenticated against /api/w/*. + // No `credentialless`: a credentialless frame gets an empty cookie jar, which would + // leave its npm installer unauthenticated against /api/w/*. iframe.style.position = 'fixed' iframe.style.width = '0' iframe.style.height = '0' diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 1e486c7d41..6df8b68518 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -60,7 +60,7 @@ import { logFeatureUsage, logHubScriptPick } from '$lib/utils/featureUsage' import { forLater } from '$lib/forLater' import { scriptLangToEditorLang } from '$lib/scripts' import { getCurrentModel } from '$lib/aiStore' -import { type editor as meditor } from 'monaco-editor' +import type { editor as meditor } from 'monaco-editor' import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions' // Prettify function for code arguments - extracts and formats code from JSON diff --git a/frontend/src/lib/components/copilot/shared.ts b/frontend/src/lib/components/copilot/shared.ts index ed37052298..1cdfc239ad 100644 --- a/frontend/src/lib/components/copilot/shared.ts +++ b/frontend/src/lib/components/copilot/shared.ts @@ -1,5 +1,5 @@ import { createLongHash } from '$lib/editorLangUtils' -import { type editor as meditor } from 'monaco-editor' +import type { editor as meditor } from 'monaco-editor' export type VisualChange = | { diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index 7ae5e9850c..d75d1b33d3 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -23,8 +23,7 @@ async function importRaw() { const parsed = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw) if (appKind === 'fullcode') { - // Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation), - // so the in-memory importStore would be lost. Use sessionStorage instead. + // The raw app editor reads the payload from sessionStorage on mount. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) await goto('/apps_raw/add') } else { diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 01e2f60850..04671e061b 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -1,13 +1,22 @@
- {#if chat && chatState} + {#if listChat && listState && pool && poolState} {#if !hideSidebar} @@ -136,24 +204,45 @@ -
- - {#key chat} - - {/key} +
+ + {#each poolState.mounted as key (key)} + {@const panel = pool.get(key)} + {#if panel} + {@const shown = key === shownKey} +
+ + listState?.conversations.find((c) => c.id === id)?.isTest ?? + conversationKinds.get(id)} + {deploymentInProgress} + {additionalInputsSchema} + {flowModules} + {path} + {identity} + {workspace} + {description} + {wideLayout} + {conversationKind} + {subject} + /> +
+ {/if} + {/each}
{/if}
diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index cad5688d89..f502be90cb 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -3,14 +3,14 @@ import { Loader2, MessageCircle, Settings2 } from 'lucide-svelte' import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte' import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost' - import { FlowChatViewHost } from './flowChatViewHost.svelte' + import type { FlowChatViewHost } from './flowChatViewHost.svelte' import Modal from '$lib/components/common/modal/Modal.svelte' import SchemaForm from '$lib/components/SchemaForm.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' import { emptyString, type DynamicInput } from '$lib/utils' - import { onDestroy, tick, untrack } from 'svelte' + import { tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' - import { chatFlowKey } from './flowChatProps' + import { saveFlowChatInputs } from './flowChatProps' import type { FlowModule } from '$lib/gen' import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte' import { @@ -30,6 +30,14 @@ interface Props { chat: Chat + /** The conversation's host, which outlives this panel. One panel follows one chat for + * its whole life, so a later value of either prop never reaches it. */ + chatHost: FlowChatViewHost + /** The flow inputs the reader chose, shared by every conversation in the flow: FlowChat + * holds them, since each conversation has a panel of its own mounted. */ + inputValues: Record + /** Whether a conversation is a test chat, once the list has said. */ + isTestOf?: (conversationId: string) => boolean | undefined deploymentInProgress?: boolean additionalInputsSchema?: Record /** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring @@ -50,6 +58,9 @@ let { chat, + chatHost: chatHostProp, + inputValues = $bindable(), + isTestOf = undefined, deploymentInProgress = false, additionalInputsSchema, flowModules, @@ -97,12 +108,7 @@ const modelGap = $derived(agentModelGap(modelWiring, subject)) const showModelButton = $derived(showsModelButton(modelWiring)) - // LocalStorage helpers - const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_' - let showInputsModal = $state(false) - // Conversation settings, persisted per flow: what the reader chose, and nothing else. - let inputValues = $state>(loadInputsFromStorage() ?? {}) let modalDraft = $state>({}) /** What the flow's own form would open on. */ @@ -128,31 +134,9 @@ // value, an author's default — is made safe before it reaches the provider. const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs)) - function getStorageKey(): string { - return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}` - } - - function loadInputsFromStorage(): Record | null { - try { - const stored = localStorage.getItem(getStorageKey()) - return stored ? JSON.parse(stored) : null - } catch (e) { - console.error('Failed to load inputs from localStorage:', e) - return null - } - } - - function saveInputsToStorage(values: Record) { - try { - localStorage.setItem(getStorageKey(), JSON.stringify(values)) - } catch (e) { - console.error('Failed to save inputs to localStorage:', e) - } - } - function setInputValue(name: string, value: any) { inputValues = { ...inputValues, [name]: value } - saveInputsToStorage(inputValues) + saveFlowChatInputs({ path, identity }, inputValues) } function handleModalConfirm() { @@ -167,47 +151,48 @@ ) ) inputValues = kept - saveInputsToStorage(inputValues) + saveFlowChatInputs({ path, identity }, inputValues) showInputsModal = false } function openInputsModal() { - modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) } + modalDraft = { ...effectiveInputs, ...inputValues } showInputsModal = true } - // The host follows the chat it was built on for the life of this component: FlowChat - // remounts the interface under `{#key chat}`, so a later value of the prop never reaches it. - const chatHost = new FlowChatViewHost( - untrack(() => chat), - { - additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined), - attachmentsTarget: () => attachmentsTarget, - attachmentsUnavailable: () => - workspaceStorage.current - ? undefined - : 'This workspace has no object storage, so files cannot be attached.', - workspace: () => workspace, - sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason, - // The model controls only: a retry changes model when the reader did, but replays - // the run's own attachments rather than whatever the composer holds now. - inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined) - } - ) + // The host belongs to the conversation, not to this panel: the pool keeps it alive so a + // message queued here still goes out once the reader has moved on. What it reads is this + // panel's, set on mount; one panel shows one conversation for its whole life. + const chatHost = untrack(() => chatHostProp) + chatHost.setOptions({ + additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined), + attachmentsTarget: () => attachmentsTarget, + attachmentsUnavailable: () => + workspaceStorage.current + ? undefined + : 'This workspace has no object storage, so files cannot be attached.', + workspace: () => workspace, + sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason, + // The model controls only: a retry changes model when the reader did, but replays + // the run's own attachments rather than whatever the composer holds now. + inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined) + }) setChatViewHost(chatHost) + // Read off the host's own conversation: a message queued here goes out after the reader + // has moved on, when the shown conversation may be of the other kind. + const isTest = $derived.by(() => { + const id = chatHost.state.conversationId + return id === undefined ? undefined : isTestOf?.(id) + }) // A chat of the other kind can be read from here but not added to: the server refuses a // preview run into a deployed conversation and the reverse, so the composer says why first. const wrongKindReason = $derived.by(() => { - const { conversationId, conversations } = chatHost.state - const open = conversations.find((c) => c.id === conversationId) - if (open?.isTest === undefined || open.isTest === (conversationKind === 'test')) - return undefined - return open.isTest + if (isTest === undefined || isTest === (conversationKind === 'test')) return undefined + return isTest ? 'This chat was run from the flow editor. Start a new chat to continue here.' : 'This chat belongs to the deployed flow. Start a new chat to test.' }) - onDestroy(() => chatHost.dispose()) // What the Configure-inputs modal asks for: every flow input the composer does not // edit itself. diff --git a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte index d2ea5c5061..8c6a102fef 100644 --- a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte @@ -5,11 +5,13 @@ Plus, Trash2, Pen, + PencilLine, Filter, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte' - import CountBadge from '$lib/components/common/badge/CountBadge.svelte' + import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte' + import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte' import InfiniteList from '$lib/components/InfiniteList.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import Popover from '$lib/components/meltComponents/Popover.svelte' @@ -21,11 +23,16 @@ import { twMerge } from 'tailwind-merge' import { fade } from 'svelte/transition' import { tick, untrack } from 'svelte' - import type { Chat, ChatState, Conversation, ConversationKind } from 'windmill-chat' + import type { Chat, Conversation, ConversationKind } from 'windmill-chat' + import type { FlowChatPool, FlowChatPoolState } from './flowChatPool' + import type { ComposerAttachment, FlowChatViewHost } from './flowChatViewHost.svelte' interface Props { - chat: Chat - chatState: ChatState + /** Lists, renames and deletes the flow's conversations; runs no turn itself. */ + listChat: Chat + /** The conversations' own chats: which one is shown, and what each is doing. */ + pool: FlowChatPool + poolState: FlowChatPoolState /** * Which conversations the list holds at first. The editor shows its own test chats, * since testing is what happens there; a deployed flow shows the chats its users @@ -40,7 +47,13 @@ canFilterKind?: boolean } - let { chat, chatState, defaultKind = 'deployed', canFilterKind = false }: Props = $props() + let { + listChat, + pool, + poolState, + defaultKind = 'deployed', + canFilterKind = false + }: Props = $props() let expanded = $state(false) let list = $state(undefined) @@ -58,13 +71,13 @@ let renameDraft = $state('') let renameInput = $state(undefined) - const turnInFlight = $derived( - chatState.status === 'submitted' || chatState.status === 'streaming' + const totalUnread = $derived( + Object.values(poolState.unread).reduce((total, count) => total + count, 0) ) $effect(() => { const l = list - const c = chat + const c = listChat if (!l) return untrack(() => { // Every load goes through here, the first one and infinite scroll included. A @@ -72,13 +85,18 @@ // selected kind brings its own, whichever of the two lands last. l.setLoader(async (page, perPage) => { const requested = kind + // Only a listing read now says which turns run: the rows the chat holds keep + // what the last one said, and a rename or delete publishes those again. + const since = pool.listingStarted() const rows = await c.loadConversations({ page, perPage, kind: requested }) + pool.setListed(rows, since) return requested === kind ? rows : items }) l.setDeleteItemFn(async (id: string) => { deletingId = id try { await c.deleteConversation(id) + pool.forget(id) sendUserToast('Conversation deleted successfully') } catch (error) { console.error('Failed to delete conversation:', error) @@ -103,10 +121,10 @@ await list?.loadData('forceRefresh') } - const draftShown = $derived(draft && !items.some((c) => c.id === chatState.conversationId)) + const draftShown = $derived(draft && poolState.selectedId === undefined) function newChat() { - chat.newConversation() + pool.newChat() draft = true } @@ -118,17 +136,16 @@ /** * Narrow the list to one kind of chat and reload it. The open conversation goes with it - * when it is not of the new kind: the composer sends into whatever is selected, and a + * when it is not of the new kind: the composer sends into whatever is shown, and a * conversation keeps the kind it was created with, so a turn sent into one the list no - * longer shows would be stored where nothing here lists it. + * longer shows would be stored where nothing here lists it. A turn running in it goes on. */ async function setKind(next: ConversationKind) { - // A turn writes into the open conversation, which a kind that excludes it would close. - if (next === kind || turnInFlight) return + if (next === kind) return kind = next - const open = items.find((c) => c.id === chatState.conversationId) + const open = items.find((c) => c.id === poolState.selectedId) const stillListed = open === undefined || next === 'all' || (next === 'test') === open.isTest - if (!stillListed) chat.newConversation() + if (!stillListed) pool.newChat() await list?.loadData('forceRefresh') } @@ -149,11 +166,11 @@ const current = items.find((c) => c.id === id) if (!current || title === '' || title === current.title) return try { - await chat.renameConversation(id, title) + await listChat.renameConversation(id, title) // The list holds its own rows, loaded through the loader: patched rather than // reloaded, so the row keeps its place without a round trip. The title is read // back from the chat, which holds it as the server stored it (a long one is cut). - const stored = chat.getState().conversations.find((c) => c.id === id)?.title ?? title + const stored = listChat.getState().conversations.find((c) => c.id === id)?.title ?? title items = items.map((c) => (c.id === id ? { ...c, title: stored } : c)) } catch (error) { console.error('Failed to rename conversation:', error) @@ -177,8 +194,34 @@ function getConversationTitle(conversation: Conversation): string { return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}` } + + /** The session sidebar's dot vocabulary, which knows no `queued`: that is its own mark. */ + function dotStatus(conversationId: string): 'streaming' | 'error' | 'idle' { + const activity = poolState.activity[conversationId] + return activity === 'running' ? 'streaming' : activity === 'error' ? 'error' : 'idle' + } +{#snippet statusDot(conversation: Conversation)} + + + {#snippet resting()} + + {/snippet} + +{/snippet} + {/if} @@ -299,7 +341,6 @@ onClick={(e) => { e?.stopPropagation() draft = false - chat.newConversation() }} title="Discard draft" destructive @@ -314,7 +355,7 @@
{:else} + {@const unread = poolState.unread[conversation.id] ?? 0} + {@const queued = !!poolState.queued[conversation.id]} {#if $wacSubOpen} +
{#each option.variants ?? [] as variant (variant.label)} {@const VariantIcon = variant.icon} @@ -593,7 +595,7 @@
{#each importActions as action, i (action.label)}