diff --git a/.github/DockerfileBackendTests b/.github/DockerfileBackendTests index 87a65214ab..2ce47ea714 100644 --- a/.github/DockerfileBackendTests +++ b/.github/DockerfileBackendTests @@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv ENV TZ=Etc/UTC diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 4780eb02eb..580a757c21 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -16,7 +16,7 @@ on: default: "global cli" models: description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for" - default: "sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro" + default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro" runs: description: "Runs per case" default: "3" @@ -28,7 +28,7 @@ concurrency: group: ai-evals-scheduled-${{ github.ref }} env: - MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro' }} + MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro' }} RUNS: ${{ inputs.runs || '3' }} REASONING: ${{ inputs.reasoning }} # Routes on the cloud instance are prefixed with their workspace. diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index 54a62ec495..3468449753 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -63,7 +63,7 @@ jobs: - uses: denoland/setup-deno@v2 with: - # Pin to the Deno version shipped in the runtime image (Dockerfile) so CI + # Pin to the Deno version shipped in the full runtime image (docker/DockerfileFull) so CI # tests what production runs, instead of floating on the latest v2.x. deno-version: 2.2.1 @@ -81,7 +81,7 @@ jobs: - uses: astral-sh/setup-uv@v7 with: - version: "0.11.24" + version: "0.13.0" - uses: shivammathur/setup-php@v2 with: diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index a2087025b7..73f955131e 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -50,7 +50,7 @@ jobs: dotnet-version: "9.0.x" - uses: denoland/setup-deno@v2 with: - # Pin to the Deno version shipped in the runtime image (Dockerfile) so CI + # Pin to the Deno version shipped in the full runtime image (docker/DockerfileFull) so CI # tests what production runs, instead of floating on the latest v2.x. deno-version: 2.2.1 - uses: actions/setup-go@v7 @@ -64,7 +64,7 @@ jobs: node-version: "24" - uses: astral-sh/setup-uv@v7 with: - version: "0.11.24" + version: "0.13.0" - uses: shivammathur/setup-php@v2 with: php-version: "8.3" @@ -295,4 +295,4 @@ jobs: # never reaches it. Pin the target dir (matching the cache step above) so # its own tests run off this compile rather than a second bundled build. (cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal) - DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp,http_trigger --all -- --nocapture --test-threads=10 + DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp,http_trigger,openidconnect --all -- --nocapture --test-threads=10 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index d420ff1f00..475d7860d8 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -27,7 +27,7 @@ jobs: DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always --health-interval 10s --health-timeout 5s --health-retries 5 --health-cmd "curl @@ -111,7 +111,7 @@ jobs: DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always --health-interval 10s --health-timeout 5s --health-retries 5 --health-cmd "curl @@ -125,7 +125,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -136,7 +136,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -147,7 +147,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -188,7 +188,7 @@ jobs: DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always --health-interval 10s --health-timeout 5s --health-retries 5 --health-cmd "curl @@ -202,7 +202,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -213,7 +213,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -224,7 +224,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -235,7 +235,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -246,7 +246,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -257,7 +257,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always @@ -268,7 +268,7 @@ jobs: LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} MODE: worker WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always steps: @@ -309,7 +309,7 @@ jobs: DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} WORKER_GROUP: main - WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + WORKER_TAGS: bun,go,python3,bash,dependency,flow,nativets options: >- --pull always --health-interval 10s --health-timeout 5s --health-retries 5 --health-cmd "curl diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 631ab53153..a2bd974daa 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -18,16 +18,19 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_chat_sdk: - runs-on: ubicloud-standard-8 + # Published by npm trusted publishing (OIDC), which npm accepts only from + # GitHub-hosted runners and npm >= 11.5.1: keep both, there is no token to fall back on. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v7 with: - node-version: "20.x" + node-version: "24.x" registry-url: "https://registry.npmjs.org" - run: cd chat-sdk && npm ci && npm run build && npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_cli: runs-on: ubicloud-standard-8 steps: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3f035f45c0..e47ce1f0a3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.826.0" + ".": "1.833.0" } diff --git a/AGENTS.md b/AGENTS.md index e4915cdcb6..8c30fed2f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,12 +33,17 @@ Open-source platform for internal tools, workflows, API integrations, background 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 +- **Data table viewer roles**: `docs/datatable-viewer-roles.md`, on what a connection naming no + role holds: the default role as a floor, whose roles are added (the job's own, or its viewer's + for an inline app runnable that opts in), why an admin's roles are not added, the per-role-set + logins and the order their rows and logins are written in - **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` fields and a jsonb merge rather than a serde default -- **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace setting that lets - operators compose flows, what the composition check must cover, and why it costs a full seat +- **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace settings that let + operators compose flows and full-code apps, what the composition check must cover, which + raw-app endpoint stays closed on purpose, and why either right costs a full seat - **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce5ffcde3..0edb6b5dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,176 @@ # Changelog +## [1.833.0](https://github.com/windmill-labs/windmill/compare/v1.832.0...v1.833.0) (2026-10-13) + + +### Features + +* make the assistant aware of the user's role permissions ([#11722](https://github.com/windmill-labs/windmill/issues/11722)) ([633135b](https://github.com/windmill-labs/windmill/commit/633135bb917ce1be711a75991531c3e478d73463)) +* read data as the job's identity, viewer is opt-in per runnable ([#11744](https://github.com/windmill-labs/windmill/issues/11744)) ([b7b3306](https://github.com/windmill-labs/windmill/commit/b7b33061952fde247447fa652663ffd730990d5e)) +* show a raw app's document.title in the page tab ([#11747](https://github.com/windmill-labs/windmill/issues/11747)) ([5153a5e](https://github.com/windmill-labs/windmill/commit/5153a5e7b81e9d56ff860129be7c49f444c3c4e2)) +* shrink the page header in stages and fold page actions by measured width ([#11732](https://github.com/windmill-labs/windmill/issues/11732)) ([3857ce3](https://github.com/windmill-labs/windmill/commit/3857ce36931dff40322db2b765ab1e65763eac42)) + + +### Bug Fixes + +* encrypt ai session backups with aes-256-gcm ([#11734](https://github.com/windmill-labs/windmill/issues/11734)) ([c7ee201](https://github.com/windmill-labs/windmill/commit/c7ee2016559b8083c83c696c41d226d9ca04ed68)) +* error handler picker keeps no per-tab state ([#11742](https://github.com/windmill-labs/windmill/issues/11742)) ([2f9a111](https://github.com/windmill-labs/windmill/commit/2f9a111bbb14a40e1341879e1ef63c3457e7a08d)) +* show the email handler's SMTP status to non-superadmin admins ([#11745](https://github.com/windmill-labs/windmill/issues/11745)) ([130adfd](https://github.com/windmill-labs/windmill/commit/130adfdd20af0e30d87986cf77b61023a9732186)) + +## [1.832.0](https://github.com/windmill-labs/windmill/compare/v1.831.0...v1.832.0) (2026-10-13) + + +### Features + +* record each instance's python version and start new ones on 3.13 ([#11656](https://github.com/windmill-labs/windmill/issues/11656)) ([7558ce2](https://github.com/windmill-labs/windmill/commit/7558ce27a9736bef1a9b9db9b0799150dccb11a5)) + + +### Bug Fixes + +* badge the unavailable sign-in option in the oauth resource form ([#11728](https://github.com/windmill-labs/windmill/issues/11728)) ([9c44f5a](https://github.com/windmill-labs/windmill/commit/9c44f5a917fc628e870073c126bf9480c98c7f73)) +* serve s3 proxy objects with nosniff and a sandbox csp ([#11731](https://github.com/windmill-labs/windmill/issues/11731)) ([e29a0dc](https://github.com/windmill-labs/windmill/commit/e29a0dc5dfed4bb62b894e059c9506873d32a62c)) +* widen variable.value without breaking live servers' cached queries ([#11737](https://github.com/windmill-labs/windmill/issues/11737)) ([89e6bdb](https://github.com/windmill-labs/windmill/commit/89e6bdbcf50e065f6906e1ab93b3c4109aba3ab6)) + +## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13) + + +### Features + +* add python 3.14 and 3.15 to the supported instance versions ([#11654](https://github.com/windmill-labs/windmill/issues/11654)) ([74c9b0c](https://github.com/windmill-labs/windmill/commit/74c9b0cb2eebc16dd5e23f23be89b127bcbe6ec2)) +* add the viewer's roles to unnamed data table connections ([#11709](https://github.com/windmill-labs/windmill/issues/11709)) ([a298ed3](https://github.com/windmill-labs/windmill/commit/a298ed3fc19a1b7fc4f8e75a95c8d38652b74b5b)) +* audit declarative instance config writes ([#11684](https://github.com/windmill-labs/windmill/issues/11684)) ([afbbc0b](https://github.com/windmill-labs/windmill/commit/afbbc0b1b2da1e468b10d4cd11e9f3ac7478a34d)) +* **cli:** declare app sandbox and frontend api scopes in app yaml ([#11703](https://github.com/windmill-labs/windmill/issues/11703)) ([26afeec](https://github.com/windmill-labs/windmill/commit/26afeecbd5b0a11c084813182e92805a15d21bbb)) +* data table migration tools in ai sessions ([#11723](https://github.com/windmill-labs/windmill/issues/11723)) ([3d74f6e](https://github.com/windmill-labs/windmill/commit/3d74f6ea2a44e85d056411c293087741f6f1e12a)) +* encrypt workspace secrets with AES-256-GCM ([#11678](https://github.com/windmill-labs/windmill/issues/11678)) ([4942149](https://github.com/windmill-labs/windmill/commit/4942149b05c9384b39402f0e3cb7771565888f45)) +* external signer for OIDC tokens and a log line per mint ([#11614](https://github.com/windmill-labs/windmill/issues/11614)) ([157183a](https://github.com/windmill-labs/windmill/commit/157183ae9cad4b6b2d38f1f4d915f32464792453)) +* give app builder operators app tools in AI sessions ([#11716](https://github.com/windmill-labs/windmill/issues/11716)) ([133613c](https://github.com/windmill-labs/windmill/commit/133613c1add5b6fcdbb0937de6479976b716c90a)) +* redesign the s3 explorer and show data files as tables ([#11642](https://github.com/windmill-labs/windmill/issues/11642)) ([9fa9b77](https://github.com/windmill-labs/windmill/commit/9fa9b7792676fbedcbc4dc37ebb883f5ff5abb35)) +* support array arguments in snowflake scripts ([#11702](https://github.com/windmill-labs/windmill/issues/11702)) ([1451988](https://github.com/windmill-labs/windmill/commit/14519884e4c9aa4d0a006cbc019a776f9a038967)) + + +### Bug Fixes + +* bump git sync hub scripts to cli 1.830.0 ([#11681](https://github.com/windmill-labs/windmill/issues/11681)) ([7bbe032](https://github.com/windmill-labs/windmill/commit/7bbe03227d56688893aafda305a13d65b2bb84f4)) +* end other sessions when a user changes their password ([#11710](https://github.com/windmill-labs/windmill/issues/11710)) ([add042e](https://github.com/windmill-labs/windmill/commit/add042ef2f05828a8ea5f6d0a089204bae2322d5)) +* fill app ctx inputs from the viewer's session, not the request ([#11698](https://github.com/windmill-labs/windmill/issues/11698)) ([7627c1b](https://github.com/windmill-labs/windmill/commit/7627c1be8d82c298173ad4a7002483d1caee6662)) +* **groups:** recompute member roles when an instance group role is cleared ([#11708](https://github.com/windmill-labs/windmill/issues/11708)) ([4be67fe](https://github.com/windmill-labs/windmill/commit/4be67fe3ac09f722f31ef85e6fc757bb0a2fabe4)) +* hide the sidebar triggers header when no trigger entry is visible ([#11715](https://github.com/windmill-labs/windmill/issues/11715)) ([e443eb3](https://github.com/windmill-labs/windmill/commit/e443eb37079734469a78af8ff4bc0f4289b28d0a)) +* keep a custom error handler's slack argument on save ([#11696](https://github.com/windmill-labs/windmill/issues/11696)) ([c6692ac](https://github.com/windmill-labs/windmill/commit/c6692ace156f5295196af07015ad82aee6391116)) +* keep ai agent schema property order stable across jobs ([#11687](https://github.com/windmill-labs/windmill/issues/11687)) ([a8e2f1f](https://github.com/windmill-labs/windmill/commit/a8e2f1f1ca28e3352aff8a450188e3ff75a9637c)) +* keep preserve_step_tags on saved branch and loop flow nodes ([#11660](https://github.com/windmill-labs/windmill/issues/11660)) ([dedd775](https://github.com/windmill-labs/windmill/commit/dedd77587f3234f3e480388b00340394794ac5bb)) +* keep the app editor's parent_version out of deployed apps ([#11719](https://github.com/windmill-labs/windmill/issues/11719)) ([2c6de9d](https://github.com/windmill-labs/windmill/commit/2c6de9d896ef37993da974418133a88798afa2bb)) +* keep the resource type index whole when a refresh fails ([#11727](https://github.com/windmill-labs/windmill/issues/11727)) ([71caac7](https://github.com/windmill-labs/windmill/commit/71caac7aec49bafe8f691e8a82b3e6df84f7e4bc)) +* keep the resource type search current between daily fills, and serve it without an index ([#11712](https://github.com/windmill-labs/windmill/issues/11712)) ([a365359](https://github.com/windmill-labs/windmill/commit/a3653593f2b064b762a19a1d4294988c27baaec0)) +* load the s3 explorer table lazily to keep monaco out of the app shell ([#11701](https://github.com/windmill-labs/windmill/issues/11701)) ([a92b29d](https://github.com/windmill-labs/windmill/commit/a92b29dae6950d29c2b8970e07d055fa02ff979e)) +* parse python 3.12-3.15 syntax in the python parsers ([#11675](https://github.com/windmill-labs/windmill/issues/11675)) ([aae5f2e](https://github.com/windmill-labs/windmill/commit/aae5f2efaa772d5bc6d7d1e4537d7f6748e50e61)) +* persist hidden languages in edit default scripts drawer ([#11693](https://github.com/windmill-labs/windmill/issues/11693)) ([aeeeb04](https://github.com/windmill-labs/windmill/commit/aeeeb042f93df32214e00040d360f20e8ed8c98a)) +* pin the floating sidebar and let page clicks through it ([#11711](https://github.com/windmill-labs/windmill/issues/11711)) ([ce45ab5](https://github.com/windmill-labs/windmill/commit/ce45ab507370662266728bf57240cc873ef28eb8)) +* resolve $var: references in mcp resource headers ([#11679](https://github.com/windmill-labs/windmill/issues/11679)) ([478eef7](https://github.com/windmill-labs/windmill/commit/478eef735222e60de8ed6086f6b036e2346f1de4)) +* resolve a new item's chosen name in session preview tools ([#11688](https://github.com/windmill-labs/windmill/issues/11688)) ([cd502a5](https://github.com/windmill-labs/windmill/commit/cd502a5270e787aeb1e5cb14e5a0e0aad8bb97b3)) +* resolve variables in the url and headers of an mcp resource ([#11680](https://github.com/windmill-labs/windmill/issues/11680)) ([4bc2394](https://github.com/windmill-labs/windmill/commit/4bc2394322f957e5cf212fa1913b2b39c1b9ca06)) +* run a workflow-as-code task called from inside a task in place ([#11699](https://github.com/windmill-labs/windmill/issues/11699)) ([72d1092](https://github.com/windmill-labs/windmill/commit/72d1092de97d7449ff03c3a44835c9d23cfe5e02)) +* stop marking run form fields as disabled for assistive tech ([#11714](https://github.com/windmill-labs/windmill/issues/11714)) ([c7df009](https://github.com/windmill-labs/windmill/commit/c7df0093d0d9a38362222b7350bf387c31af9392)) +* stop session dom tools reading a raw app preview that is not current ([#11700](https://github.com/windmill-labs/windmill/issues/11700)) ([05c7b68](https://github.com/windmill-labs/windmill/commit/05c7b688e5e963c6c313cb5da8e777cee11cd122)) +* surface oauth provider errors returned with http 200 ([#11713](https://github.com/windmill-labs/windmill/issues/11713)) ([d8ac4f5](https://github.com/windmill-labs/windmill/commit/d8ac4f59c8d570777e43a7aa7ffbc97598897cc2)) + + +### Performance Improvements + +* run shellcheck directly from the lsp launcher ([#11707](https://github.com/windmill-labs/windmill/issues/11707)) ([8cdb42a](https://github.com/windmill-labs/windmill/commit/8cdb42aaae764977008f8ac4598709c7ae09af3f)) + +## [1.830.0](https://github.com/windmill-labs/windmill/compare/v1.829.0...v1.830.0) (2026-10-09) + + +### Features + +* add a jobs:run:dependencies token scope for lock generation ([#11643](https://github.com/windmill-labs/windmill/issues/11643)) ([1dc4ed5](https://github.com/windmill-labs/windmill/commit/1dc4ed5e7fb86c9658f1f877e7b92cd9dab5e283)) +* let operators compose full-code apps when the workspace grants it ([#11229](https://github.com/windmill-labs/windmill/issues/11229)) ([02e4697](https://github.com/windmill-labs/windmill/commit/02e4697157112581e9e2a9303180077126db714b)) +* operators get AI sessions ([#11443](https://github.com/windmill-labs/windmill/issues/11443)) ([338c4e9](https://github.com/windmill-labs/windmill/commit/338c4e921dc3185d886c8b040f253285adf20efb)) +* replace the hub_sync script with an opt-in daily server sync ([#11624](https://github.com/windmill-labs/windmill/issues/11624)) ([2b55a8a](https://github.com/windmill-labs/windmill/commit/2b55a8afbc8a86c6cebe97b1cab02c313b986f22)) +* retry transient AI provider failures before output reaches readers ([#11623](https://github.com/windmill-labs/windmill/issues/11623)) ([d3ddef0](https://github.com/windmill-labs/windmill/commit/d3ddef0b7ec4705aa578c4eca24b96e9152485bd)) +* stop offering deno for new scripts, ship it only on full images ([#11648](https://github.com/windmill-labs/windmill/issues/11648)) ([cda7883](https://github.com/windmill-labs/windmill/commit/cda78830fde1b96bd3de2674e23c245b50e3c051)) + + +### Bug Fixes + +* **cli:** count superadmins as able to preserve ownership on push ([#11653](https://github.com/windmill-labs/windmill/issues/11653)) ([70db567](https://github.com/windmill-labs/windmill/commit/70db567a78b1e1bc9e2dcc9ff2139951d778117e)) +* harden app secret validation ([#11655](https://github.com/windmill-labs/windmill/issues/11655)) ([8556a7b](https://github.com/windmill-labs/windmill/commit/8556a7bebe093e0bd61c33d67415fd553f0070ad)) +* open the chat + menu without waiting on the skills and mcp fetches ([#11645](https://github.com/windmill-labs/windmill/issues/11645)) ([1fea1aa](https://github.com/windmill-labs/windmill/commit/1fea1aa6609d644e02bac8d16186f9a2dfcb63cd)) +* say Running on a chat run card only once its job exists ([#11627](https://github.com/windmill-labs/windmill/issues/11627)) ([d046187](https://github.com/windmill-labs/windmill/commit/d046187e3edd01535240c6080b483afe154ae189)) + +## [1.829.0](https://github.com/windmill-labs/windmill/compare/v1.828.0...v1.829.0) (2026-10-09) + + +### Features + +* add an api base url instance setting for displayed urls ([#11628](https://github.com/windmill-labs/windmill/issues/11628)) ([6acf185](https://github.com/windmill-labs/windmill/commit/6acf1858540e83b65b5218c50c98af692092d22e)) +* add Pi template to the AI sandbox picker ([#11619](https://github.com/windmill-labs/windmill/issues/11619)) ([a85d0b7](https://github.com/windmill-labs/windmill/commit/a85d0b76cc494aaa4a484a273ec534bdc2958102)) +* agent nits: multi-select, memory-off notice, agent step label ([#11622](https://github.com/windmill-labs/windmill/issues/11622)) ([c52ba2f](https://github.com/windmill-labs/windmill/commit/c52ba2fd30e19986522420ff02fae224a075b4f6)) +* export worker cpu, memory and occupancy metrics ([#11629](https://github.com/windmill-labs/windmill/issues/11629)) ([5d90f96](https://github.com/windmill-labs/windmill/commit/5d90f96863b64e9bc88ed35429f8068a6a788d1e)) +* let a job see that its worker is draining ([#11612](https://github.com/windmill-labs/windmill/issues/11612)) ([588d846](https://github.com/windmill-labs/windmill/commit/588d8463dfa1c8936b8753b5f0fd794ecb115203)) +* let workspace admins delete other users' drafts ([#11644](https://github.com/windmill-labs/windmill/issues/11644)) ([68376fc](https://github.com/windmill-labs/windmill/commit/68376fc011ef6406a820c69217058316514d3f8d)) +* per-script resource rollup and heaviest scripts table ([#11631](https://github.com/windmill-labs/windmill/issues/11631)) ([442357d](https://github.com/windmill-labs/windmill/commit/442357dbe8db0c52995572acd7385dedf4efdc42)) +* pipeline editor: assets-only view, Fix actions and draft triggers ([#11440](https://github.com/windmill-labs/windmill/issues/11440)) ([3dae553](https://github.com/windmill-labs/windmill/commit/3dae553576ba23b2c1f56e4deac01305c4645c01)) + + +### Bug Fixes + +* advertise the actual signing algorithm in OIDC discovery ([#11637](https://github.com/windmill-labs/windmill/issues/11637)) ([98fc65a](https://github.com/windmill-labs/windmill/commit/98fc65a16bbe2bc9515cdd5443304053c4322274)) +* ask a workspace for its session backups before pushing to it ([#11641](https://github.com/windmill-labs/windmill/issues/11641)) ([2ea846f](https://github.com/windmill-labs/windmill/commit/2ea846f563f5a77cbd3eae2eddee9e38a3700d00)) +* keep instance settings users sub-tabs from being squashed ([#11633](https://github.com/windmill-labs/windmill/issues/11633)) ([fb22e5c](https://github.com/windmill-labs/windmill/commit/fb22e5ce368eba9146e0d51b8fdabb52ca9f68f0)) +* keep raw app deploy panel settings shared with the editor ([#11616](https://github.com/windmill-labs/windmill/issues/11616)) ([0a95ea4](https://github.com/windmill-labs/windmill/commit/0a95ea47facaede2545cc4741d4a70c1c847e11a)) +* load schedules once per page load, poll critical alerts every 30s ([#11639](https://github.com/windmill-labs/windmill/issues/11639)) ([1b4e686](https://github.com/windmill-labs/windmill/commit/1b4e686b2f5de3138df0c3b6ed627ea0fe0a2f7d)) +* name the open workspace settings tab in the page header breadcrumb ([#11617](https://github.com/windmill-labs/windmill/issues/11617)) ([e233908](https://github.com/windmill-labs/windmill/commit/e233908c1e99a246dc7c94fa86ea82c5cbe9d6b3)) +* **python:** add missing import-to-package mappings for common modules ([#11638](https://github.com/windmill-labs/windmill/issues/11638)) ([ab8606f](https://github.com/windmill-labs/windmill/commit/ab8606f37921913ef1410da735abb5742c025225)) +* send hub script completions to the workspace handlers ([#11613](https://github.com/windmill-labs/windmill/issues/11613)) ([d685332](https://github.com/windmill-labs/windmill/commit/d685332771dcdfc718d7a2077fe1cdb3b323909b)) +* set WM_END_USER_EMAIL on direct script and flow runs ([#11636](https://github.com/windmill-labs/windmill/issues/11636)) ([2bdeceb](https://github.com/windmill-labs/windmill/commit/2bdeceba7fb6815cf3150ecb856afb8eb3872e44)) +* store a WAC parent's first start and wall duration ([#11625](https://github.com/windmill-labs/windmill/issues/11625)) ([8f28a58](https://github.com/windmill-labs/windmill/commit/8f28a5860595dbe558e6777a06bb97b6ec6a3e67)) +* sync back volume files rewritten to the same size ([#11620](https://github.com/windmill-labs/windmill/issues/11620)) ([2758920](https://github.com/windmill-labs/windmill/commit/2758920f1182ddd12e6473a644a4a966a435522e)) +* uniform decrypt error and clear stored credentials on logout ([#11632](https://github.com/windmill-labs/windmill/issues/11632)) ([987c321](https://github.com/windmill-labs/windmill/commit/987c321ebcd305df79fa945e941abc2b2e1d3e95)) + +## [1.828.0](https://github.com/windmill-labs/windmill/compare/v1.827.0...v1.828.0) (2026-10-08) + + +### Features + +* add gpt-6.1-sol to the chat, agent steps and weekly ai evals ([#11584](https://github.com/windmill-labs/windmill/issues/11584)) ([8f7e0c7](https://github.com/windmill-labs/windmill/commit/8f7e0c72848b56fa97f6f0141d930c7d15044864)) +* add wmill user list and document post-suspend approvers context ([#11600](https://github.com/windmill-labs/windmill/issues/11600)) ([1c2667c](https://github.com/windmill-labs/windmill/commit/1c2667cd5530925893cf69fd622938b6ffa55d1d)) +* ask before minting the frontend sdk token in the raw app editor ([#11604](https://github.com/windmill-labs/windmill/issues/11604)) ([6e948b0](https://github.com/windmill-labs/windmill/commit/6e948b07d13b85e9251c3a200bf149f167649bc4)) +* improve data table roles and grants UX ([#11562](https://github.com/windmill-labs/windmill/issues/11562)) ([d7d18f0](https://github.com/windmill-labs/windmill/commit/d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6)) +* one page header across routes, and one sidebar for operators and developers ([#11310](https://github.com/windmill-labs/windmill/issues/11310)) ([41162b2](https://github.com/windmill-labs/windmill/commit/41162b270c70ddac6a256bde9acf31a629990e24)) + + +### Bug Fixes + +* add retry to runScriptByPathAsync and runScriptByHashAsync ([#11485](https://github.com/windmill-labs/windmill/issues/11485)) ([3f3b1c2](https://github.com/windmill-labs/windmill/commit/3f3b1c2c308ddaba377db45308711e7c660d8775)) +* describe exponential retry as multiplier × seconds^attempt ([#11546](https://github.com/windmill-labs/windmill/issues/11546)) ([a2e51cd](https://github.com/windmill-labs/windmill/commit/a2e51cdbaafb7d71b8e8bbd818e40c472098f9ba)) +* keep capturing job logs after a non-UTF-8 output line ([#11595](https://github.com/windmill-labs/windmill/issues/11595)) ([0e7d974](https://github.com/windmill-labs/windmill/commit/0e7d974ce9b468353ecb6618dace710704690687)) + + +### Performance Improvements + +* load public raw apps without the loading screen ([#11608](https://github.com/windmill-labs/windmill/issues/11608)) ([c09764f](https://github.com/windmill-labs/windmill/commit/c09764fa6516badddbdbb520af32e5d1541cf996)) + +## [1.827.0](https://github.com/windmill-labs/windmill/compare/v1.826.0...v1.827.0) (2026-10-07) + + +### Features + +* audit instance settings writes, stable audit OTel target ([#11593](https://github.com/windmill-labs/windmill/issues/11593)) ([0792332](https://github.com/windmill-labs/windmill/commit/0792332859a88b1359ea1be64250d38dd071a0a9)) +* default new raw apps to react 19 and ask for the data setup ([#11586](https://github.com/windmill-labs/windmill/issues/11586)) ([818ddd5](https://github.com/windmill-labs/windmill/commit/818ddd5d31bd81507e274dd18022c1cdbd9be842)) +* export queue delay as OTel and Prometheus metrics ([#11588](https://github.com/windmill-labs/windmill/issues/11588)) ([7565349](https://github.com/windmill-labs/windmill/commit/75653496e542550039cbe5c413d8fcb9d395cea8)) +* pause a worker group without stopping its workers ([#11596](https://github.com/windmill-labs/windmill/issues/11596)) ([95c4b30](https://github.com/windmill-labs/windmill/commit/95c4b3096cf10c1fe59de7a1a6876dfb5f02c39f)) +* run AI decision steps on OpenAI's Decisions API ([#11590](https://github.com/windmill-labs/windmill/issues/11590)) ([046df9e](https://github.com/windmill-labs/windmill/commit/046df9e44db2f2fae3059ccd7a3d6c6955ee8971)) + + +### Bug Fixes + +* avoid URLSearchParams.keys() so the frontend check passes ([#11599](https://github.com/windmill-labs/windmill/issues/11599)) ([fc6c84c](https://github.com/windmill-labs/windmill/commit/fc6c84c6974bba1850e90a088fad5460c8c93ba3)) +* let recover: true from the error handler turn nested failures green ([#11559](https://github.com/windmill-labs/windmill/issues/11559)) ([03e484e](https://github.com/windmill-labs/windmill/commit/03e484e59757c0d4d6f74511d9aa5a1e2f94af67)) +* run dependency resolution and build steps under nsjail ([#11609](https://github.com/windmill-labs/windmill/issues/11609)) ([a238d01](https://github.com/windmill-labs/windmill/commit/a238d01ef6a8c285aef6886c974904f24339f7a2)) +* scope the python wheel cache to workspaces with their own registry ([#11597](https://github.com/windmill-labs/windmill/issues/11597)) ([14381ce](https://github.com/windmill-labs/windmill/commit/14381cefee6fb2b0ece52fbf0001c2ce61bde1a1)) +* type snowflake columns in database manager insert and alter forms ([#11578](https://github.com/windmill-labs/windmill/issues/11578)) ([f2fb156](https://github.com/windmill-labs/windmill/commit/f2fb15603b2e0d11bd5d04dd675212a5e4a7b9d7)) + ## [1.826.0](https://github.com/windmill-labs/windmill/compare/v1.825.0...v1.826.0) (2026-10-07) diff --git a/Dockerfile b/Dockerfile index b1b3f939e7..4e022d4b53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,7 +83,6 @@ COPY /python-client/docs/ /frontend/static/pydocs/ RUN npm run generate-backend-client ENV NODE_OPTIONS "--max-old-space-size=8192" -ARG VITE_BASE_URL "" # Must be declared for the build-arg to reach the bundle. See frontend/svelte.config.js. ARG WM_BUILD_VERSION="" # Read more about macro in docker/dev.nu @@ -153,11 +152,12 @@ ARG WITH_HELM=true ARG WITH_GIT=true ARG features="" -# To change latest stable version: -# 1. Change placeholder in instanceSettings.ts -# 2. Change LATEST_STABLE_PY in dockerfile -# 3. Change #[default] annotation for PyVersion in backend +# Instances keep the Python version they were initialized with, so both are preinstalled: +# LATEST_STABLE_PY is what instances initialized before the version was raised are on +# (PyVAlias::default() in the backend), NEW_INSTANCE_PY the one new instances start on +# (PyVAlias::NEW_INSTANCE). When raising the latter, keep installing every earlier value. ARG LATEST_STABLE_PY=3.12 +ARG NEW_INSTANCE_PY=3.13 ENV UV_PYTHON_INSTALL_DIR=/tmp/windmill/cache/py_runtime ENV UV_PYTHON_PREFERENCE=only-managed @@ -242,7 +242,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtimes to temp build location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run @@ -250,6 +250,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 --compile-bytecode RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode RUN curl -sL https://deb.nodesource.com/setup_24.x | bash - @@ -287,8 +288,6 @@ COPY --from=builder /frontend/build /static_frontend COPY --from=builder /windmill/target/release/windmill ${APP}/windmill COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so ${APP}/libwindmill_duckdb_ffi_internal.so -COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno - COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun # Install windmill CLI diff --git a/README.md b/README.md index 87fc66f399..e9ed6381f1 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ You can run scripts locally by passing the right environment variables for the ` - **Frontend**: Svelte 5 - **Sandboxing**: [nsjail](https://github.com/google/nsjail) and PID namespace isolation - **Runtimes**: - - TypeScript/JavaScript: Bun (default) and Deno + - TypeScript/JavaScript: Bun (default); Deno only on the `windmill-full` images - Python: python3 with uv for dependency management - Go, Bash, PowerShell, PHP, Rust, C#, Java, Ansible @@ -318,7 +318,7 @@ running options. This will also avoid compile time issue with sqlx's `query!` macro. 2. (optional, linux only) Install [nsjail](https://github.com/google/nsjail) and have it accessible in your PATH -3. Install bun, deno and python3 (+ any languages you want to use), have the bins at `/usr/bin/bun`,`/usr/bin/deno`, and +3. Install bun and python3 (+ any languages you want to use), have the bins at `/usr/bin/bun` and `/usr/local/bin/python3` or set the corresponding environment variables. 4. (optional) Install the [lld linker](https://lld.llvm.org/) 5. Go to `frontend/`: diff --git a/ai_evals/README.md b/ai_evals/README.md index 44870ba2a2..9b8850c435 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -196,7 +196,10 @@ not have read is itself the failure — e.g. `write_variable.value` on a secret Global (and flow) initial fixtures can seed `workspace.datatables` so the `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` tools return seeded data during evals. Each entry is -`{ datatable_name, schemas: { : { : { columns, rows? } } } }`. +`{ datatable_name, schemas: { : {
: { columns, rows? } } } }`, +plus `migrations_enabled: true` and `migrations: [{ timestamp, name, code_up, code_down?, applied? }]` +for a datatable that keeps its schema as migrations, which the migration tools then +read and write in memory. SQL runs through a small in-memory engine (`datatableSqlEngine.ts`), not a real database. Writes are **stateful within a case**: `CREATE`/`DROP`/`INSERT`/`UPDATE`/ `DELETE` mutate the seeded datatable in place, so a later `list_datatables`, diff --git a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts index f32bf1e22e..b6af04e6ec 100644 --- a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts +++ b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts @@ -17,6 +17,7 @@ import { previewLocationLabel, promptSafe, resolvePreviewTab, + slotShowsPage, } from "../../../../../frontend/src/lib/components/sessions/previewRouter"; import type { ArtifactVersionTarget } from "../../../../../frontend/src/lib/components/sessions/previewRouter"; import type { SessionTarget } from "../../../../../frontend/src/lib/components/sessions/sessionState.svelte"; @@ -136,9 +137,8 @@ export function createEvalPreviewPanel(input: { activePreview: () => { const tab = owner.displayedTab; if (!tab) return undefined; - // Artifact and editor tabs are not iframes: they carry no page location, and an - // artifact's pinned version reaches the chat only through get_preview_status. - if (resolvePreviewTab(tab.url).kind !== "iframe") return undefined; + // An artifact's pinned version reaches the chat only through get_preview_status. + if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined; return previewLocationContext(whereIs(tab)); }, dispose: () => { diff --git a/ai_evals/adapters/frontend/datatableSqlEngine.ts b/ai_evals/adapters/frontend/datatableSqlEngine.ts index 453eca286e..96a62f6804 100644 --- a/ai_evals/adapters/frontend/datatableSqlEngine.ts +++ b/ai_evals/adapters/frontend/datatableSqlEngine.ts @@ -30,6 +30,17 @@ export interface BenchmarkDatatableSeed { [table: string]: BenchmarkDatatableTableSeed } } + /** Whether the datatable keeps its schema as migrations. */ + migrations_enabled?: boolean + migrations?: BenchmarkDatatableMigrationSeed[] +} + +export interface BenchmarkDatatableMigrationSeed { + timestamp: number + name: string + code_up: string + code_down?: string + applied?: boolean } export interface DatatableSqlResult { diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 2f47e85be6..d8284039b9 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -692,10 +692,138 @@ export function listBenchmarkDatatables(workspace: string): DataTableTables[] | datatable_name: datatable.datatable_name, schemas: Object.fromEntries( Object.entries(datatable.schemas).map(([schema, tables]) => [schema, Object.keys(tables)]) - ) + ), + ...(datatable.migrations_enabled && { migrations_enabled: true }) })) } +// ============= Datatable migrations ============= + +function benchmarkDatatable(workspace: string, datatableName: string): BenchmarkDatatableSeed { + const datatable = (benchmarkWorkspaceRunnables.get(workspace)?.datatables ?? []).find( + (entry) => entry.datatable_name === datatableName + ) + if (!datatable) { + throw new Error(`datatable "${datatableName}" not found`) + } + return datatable +} + +/** Run a migration body through the in-memory engine, one statement at a time and without the + * transaction frame, so the tables it creates show up in later reads. */ +function applyBenchmarkMigrationSql(datatable: BenchmarkDatatableSeed, sql: string): void { + // Split on the semicolons outside single-quoted literals. + for (const statement of sql.match(/(?:'(?:[^']|'')*'|[^;'])+/g) ?? []) { + const trimmed = statement.replace(/^\s*--.*$/gm, '').trim() + if (trimmed !== '' && !/^(begin|end|commit)$/i.test(trimmed)) { + applyDatatableSql(datatable, trimmed) + } + } +} + +export function getBenchmarkDatatableMigrationsStatus(input: { + workspace: string + datatableName: string +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + if (!datatable.migrations_enabled) { + return { enabled: false, migrations: [] } + } + return { + enabled: true, + migrations: (datatable.migrations ?? []).map(({ applied, ...migration }) => ({ + ...migration, + status: applied ? ('ran' as const) : ('not_run' as const) + })) + } +} + +export function createBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + requestBody: { name: string; code_up: string; code_down?: string } +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + if (!datatable.migrations_enabled) { + throw new Error( + `Migrations are not enabled for data table '${input.datatableName}'. Enable them first.` + ) + } + const migrations = (datatable.migrations ??= []) + const timestamp = Math.max(20260101000000, ...migrations.map((m) => m.timestamp)) + 1 + migrations.push({ timestamp, ...input.requestBody }) + return { datatable: input.datatableName, timestamp, ...input.requestBody } +} + +export function upsertBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + requestBody: { timestamp: number; name: string; code_up: string; code_down?: string } +}): string { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const migrations = (datatable.migrations ??= []) + const existing = migrations.find((m) => m.timestamp === input.requestBody.timestamp) + if (existing?.applied) { + throw new Error( + `Migration ${existing.timestamp} on data table '${input.datatableName}' has already been applied and cannot be modified` + ) + } + if (existing) { + // Replaced whole, as the server does: a down left out is stored as none. + delete existing.code_down + Object.assign(existing, input.requestBody) + } else { + migrations.push({ ...input.requestBody }) + migrations.sort((a, b) => a.timestamp - b.timestamp) + } + return `Upserted migration ${input.requestBody.timestamp} in ${input.datatableName}` +} + +export function runBenchmarkDatatableMigrations(input: { + workspace: string + datatableName: string + upTo?: number + only?: number +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const applied: { version: number; name: string }[] = [] + for (const migration of datatable.migrations ?? []) { + if ( + migration.applied || + (input.only !== undefined && migration.timestamp !== input.only) || + (input.only === undefined && input.upTo !== undefined && migration.timestamp > input.upTo) + ) { + continue + } + applyBenchmarkMigrationSql(datatable, migration.code_up) + migration.applied = true + applied.push({ version: migration.timestamp, name: migration.name }) + } + return { applied } +} + +export function rollbackBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + only?: number +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const last = (datatable.migrations ?? []) + .filter((m) => m.applied && (input.only === undefined || m.timestamp === input.only)) + .at(-1) + if (!last) { + return { rolled_back: [] } + } + if (last.code_down === undefined) { + throw new Error( + `Cannot roll back migration ${last.timestamp} (${last.name}): it has no down migration` + ) + } + applyBenchmarkMigrationSql(datatable, last.code_down) + last.applied = false + return { rolled_back: [{ version: last.timestamp, name: last.name }] } +} + // ============= DuckLake catalogs and declared metrics ============= /** Seeded DuckLake names, or `null` for a non-benchmark workspace. */ diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 288c67e3eb..cb7469b45e 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -92,6 +92,11 @@ vi.mock('$lib/gen', async () => { getBenchmarkCompletedJob, getBenchmarkCompletedJobResultMaybe, getBenchmarkDatatableSchema, + getBenchmarkDatatableMigrationsStatus, + createBenchmarkDatatableMigration, + upsertBenchmarkDatatableMigration, + runBenchmarkDatatableMigrations, + rollbackBenchmarkDatatableMigration, getBenchmarkDraftForUser, getBenchmarkFlowByPath, getBenchmarkFlowAllResults, @@ -400,7 +405,27 @@ vi.mock('$lib/gen', async () => { schemaName: data.schemaName, tableName: data.tableName }) - : actual.WorkspaceService.getDataTableTableSchema(data) + : actual.WorkspaceService.getDataTableTableSchema(data), + getDatatableMigrationsStatus: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkDatatableMigrationsStatus(data) + : actual.WorkspaceService.getDatatableMigrationsStatus(data), + createDatatableMigration: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? createBenchmarkDatatableMigration(data) + : actual.WorkspaceService.createDatatableMigration(data), + upsertDatatableMigration: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? upsertBenchmarkDatatableMigration(data) + : actual.WorkspaceService.upsertDatatableMigration(data), + runDatatableMigrations: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? runBenchmarkDatatableMigrations(data) + : actual.WorkspaceService.runDatatableMigrations(data), + rollbackDatatableMigrations: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? rollbackBenchmarkDatatableMigration(data) + : actual.WorkspaceService.rollbackDatatableMigrations(data) }), DataMetricService: wrapService(actual.DataMetricService, { listDataMetrics: async (data: { workspace: string }) => diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b66af3e8dd..4b6704b229 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -901,6 +901,57 @@ - treats creating the table as a normal SQL statement and does not claim a separate registration step is needed - does not write a script to create the table +# The same request on a data table that keeps its schema as migrations: the table is +# created by a migration, since plain SQL would leave it out of the history. +- id: global-datatable-schema-change-uses-migration + prompt: |- + Add a new table called refunds to our workspace data table, with an order id and a refund amount. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - create_datatable_migration + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: create_datatable_migration + field: code_up + stringIncludesAnyOf: + - create table + skipJudge: true + judgeChecklist: + - notices that the data table keeps its schema as migrations + - creates the refunds table through a migration rather than a plain CREATE TABLE statement + - includes an order id and a refund amount column + - gives the migration a down that drops the table + +# Row data is not schema: it is written with plain SQL even where migrations are enabled. +- id: global-datatable-row-change-skips-migration + prompt: |- + Mark order number 2 as shipped in our workspace data table. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json + runtime: + maxTurns: 12 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - exec_datatable_sql + forbiddenToolsUsed: + - create_datatable_migration + - write_script + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - runs an UPDATE on the orders table setting the status of order id 2 to shipped + - does not create a migration for a change to row data + - id: global-test25-datatable-mutate-rows prompt: |- Mark order number 2 as shipped in our workspace data table. diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 22af20d5c3..cf131337fd 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -148,6 +148,15 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-6-astra", }, }, + { + id: "gpt-6.1-sol", + label: "GPT-6.1 Sol", + aliases: ["gpt-6.1-sol"], + frontend: { + provider: "openai", + model: "gpt-6.1-sol", + }, + }, { id: "gpt-6-sol", label: "GPT-6 Sol", diff --git a/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json b/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json new file mode 100644 index 0000000000..d5227d9af8 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json @@ -0,0 +1,36 @@ +{ + "workspace": { + "datatables": [ + { + "datatable_name": "main", + "migrations_enabled": true, + "migrations": [ + { + "timestamp": 20260501090000, + "name": "create_orders", + "code_up": "BEGIN;\n\nCREATE TABLE orders (id serial PRIMARY KEY, customer_id int, total numeric, status text, created_at timestamptz DEFAULT now());\n\nEND;", + "code_down": "BEGIN;\n\nDROP TABLE orders;\n\nEND;", + "applied": true + } + ], + "schemas": { + "public": { + "orders": { + "columns": { + "id": "int4", + "customer_id": "int4", + "total": "numeric", + "status": "text", + "created_at": "timestamptz" + }, + "rows": [ + { "id": 1, "customer_id": 1, "total": 42.5, "status": "shipped", "created_at": "2026-05-01T10:00:00Z" }, + { "id": 2, "customer_id": 2, "total": 19.99, "status": "pending", "created_at": "2026-05-02T11:30:00Z" } + ] + } + } + } + } + ] + } +} diff --git a/backend/.sqlx/query-782c259ccdf71c5e2fa8dcb27515e7c5ff83009774dc15095ed0dbcb0ecdbdde.json b/backend/.sqlx/query-009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6.json similarity index 66% rename from backend/.sqlx/query-782c259ccdf71c5e2fa8dcb27515e7c5ff83009774dc15095ed0dbcb0ecdbdde.json rename to backend/.sqlx/query-009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6.json index f3449712c9..c687589db1 100644 --- a/backend/.sqlx/query-782c259ccdf71c5e2fa8dcb27515e7c5ff83009774dc15095ed0dbcb0ecdbdde.json +++ b/backend/.sqlx/query-009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, ws_error_handler_muted)\n VALUES ('test-workspace', $1, 'f/test/muted_failing_script', 'export function main() { throw new Error(\"fail\"); }', 'deno', 'script', 'test-user', '{}', '', '', '', true)\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, ws_error_handler_muted)\n VALUES ('test-workspace', $1, 'f/test/muted_failing_script', 'export function main() { throw new Error(\"fail\"); }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n', true)\n ", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "782c259ccdf71c5e2fa8dcb27515e7c5ff83009774dc15095ed0dbcb0ecdbdde" + "hash": "009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6" } diff --git a/backend/.sqlx/query-dbc05cbf6720cc85e01118bcdcbb478724ac0ea8f3e043da2de9d932216cc730.json b/backend/.sqlx/query-1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e.json similarity index 62% rename from backend/.sqlx/query-dbc05cbf6720cc85e01118bcdcbb478724ac0ea8f3e043da2de9d932216cc730.json rename to backend/.sqlx/query-1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e.json index 6e5f8645bf..a0847ae2ca 100644 --- a/backend/.sqlx/query-dbc05cbf6720cc85e01118bcdcbb478724ac0ea8f3e043da2de9d932216cc730.json +++ b/backend/.sqlx/query-1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'deno', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', '')\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'bun', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', E'{}\\n//bun.lock\\n')\n ", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "dbc05cbf6720cc85e01118bcdcbb478724ac0ea8f3e043da2de9d932216cc730" + "hash": "1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e" } diff --git a/backend/.sqlx/query-1333436e3d0466eccdaf99fb70aadad56b50619036fab3f2c1bdbbf280a80217.json b/backend/.sqlx/query-1333436e3d0466eccdaf99fb70aadad56b50619036fab3f2c1bdbbf280a80217.json new file mode 100644 index 0000000000..1946f55366 --- /dev/null +++ b/backend/.sqlx/query-1333436e3d0466eccdaf99fb70aadad56b50619036fab3f2c1bdbbf280a80217.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)\n UPDATE v2_job_queue q SET running = false, started_at = null,\n extras = CASE WHEN q.started_at IS NULL OR q.extras ? 'wac_first_started_at' THEN q.extras\n ELSE jsonb_set(COALESCE(q.extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(q.started_at))\n END\n FROM prev WHERE q.id = $1\n RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "int8", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1333436e3d0466eccdaf99fb70aadad56b50619036fab3f2c1bdbbf280a80217" +} diff --git a/backend/.sqlx/query-1ae4aab40783b4cd588e5a4da57eabca765789cd706ad682e8094c8960bc46a8.json b/backend/.sqlx/query-1ae4aab40783b4cd588e5a4da57eabca765789cd706ad682e8094c8960bc46a8.json new file mode 100644 index 0000000000..6ae002d4f7 --- /dev/null +++ b/backend/.sqlx/query-1ae4aab40783b4cd588e5a4da57eabca765789cd706ad682e8094c8960bc46a8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM runnable_job_stats s WHERE s.hour >= $1 AND NOT EXISTS (SELECT 1 FROM workspace w WHERE w.id = s.workspace_id)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "1ae4aab40783b4cd588e5a4da57eabca765789cd706ad682e8094c8960bc46a8" +} diff --git a/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json b/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json new file mode 100644 index 0000000000..036610e08c --- /dev/null +++ b/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "draining", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b" +} diff --git a/backend/.sqlx/query-1fd3d5e8918143c6e4828b092c5d7d376555217b037d7694efa5ceffc4980f80.json b/backend/.sqlx/query-1fd3d5e8918143c6e4828b092c5d7d376555217b037d7694efa5ceffc4980f80.json new file mode 100644 index 0000000000..988ff44bb4 --- /dev/null +++ b/backend/.sqlx/query-1fd3d5e8918143c6e4828b092c5d7d376555217b037d7694efa5ceffc4980f80.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\",\n COALESCE((operator_settings->>'builder_apps')::boolean, false) AS \"apps!\",\n COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",\n COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flows!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "apps!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "schedules!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "triggers!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "1fd3d5e8918143c6e4828b092c5d7d376555217b037d7694efa5ceffc4980f80" +} diff --git a/backend/.sqlx/query-212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead.json b/backend/.sqlx/query-212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead.json deleted file mode 100644 index 04b23832d9..0000000000 --- a/backend/.sqlx/query-212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)\n VALUES ('admins', $1, $2, $3, $4, $6, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END,\n edited_at = now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Jsonb", - "Text", - "Varchar", - "Bool", - "Varchar", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead" -} diff --git a/backend/.sqlx/query-24cfd719a45af5beb4eb264939290c01e1b684525bae4cb329515989cd341875.json b/backend/.sqlx/query-24cfd719a45af5beb4eb264939290c01e1b684525bae4cb329515989cd341875.json new file mode 100644 index 0000000000..eaa7d60dea --- /dev/null +++ b/backend/.sqlx/query-24cfd719a45af5beb4eb264939290c01e1b684525bae4cb329515989cd341875.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO runnable_job_stats\n (hour, workspace_id, runnable_path, worker_group, job_count, total_duration_ms,\n max_memory_peak, sum_memory_peak, memory_sample_count, total_cpu_ms)\n SELECT s.hour, s.workspace_id, s.runnable_path, s.worker_group, s.job_count,\n s.total_duration_ms, s.max_memory_peak, s.sum_memory_peak,\n s.memory_sample_count, s.total_cpu_ms\n FROM UNNEST($1::bigint[], $2::text[], $3::text[], $4::text[], $5::int[], $6::bigint[],\n $7::int[], $8::bigint[], $9::int[], $10::bigint[])\n AS s(hour, workspace_id, runnable_path, worker_group, job_count, total_duration_ms,\n max_memory_peak, sum_memory_peak, memory_sample_count, total_cpu_ms)\n JOIN workspace w ON w.id = s.workspace_id\n ORDER BY s.hour, s.workspace_id, s.runnable_path, s.worker_group\n ON CONFLICT (hour, workspace_id, runnable_path, worker_group) DO UPDATE SET\n job_count = runnable_job_stats.job_count + EXCLUDED.job_count,\n total_duration_ms = runnable_job_stats.total_duration_ms + EXCLUDED.total_duration_ms,\n max_memory_peak = GREATEST(runnable_job_stats.max_memory_peak, EXCLUDED.max_memory_peak),\n sum_memory_peak = runnable_job_stats.sum_memory_peak + EXCLUDED.sum_memory_peak,\n memory_sample_count = runnable_job_stats.memory_sample_count + EXCLUDED.memory_sample_count,\n total_cpu_ms = runnable_job_stats.total_cpu_ms + EXCLUDED.total_cpu_ms\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TextArray", + "TextArray", + "Int4Array", + "Int8Array", + "Int4Array", + "Int8Array", + "Int4Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "24cfd719a45af5beb4eb264939290c01e1b684525bae4cb329515989cd341875" +} diff --git a/backend/.sqlx/query-7f98c013682aca6d269ef6ef754a7247b6e2dd4958d0968a3f2eb49cc320e877.json b/backend/.sqlx/query-344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d.json similarity index 69% rename from backend/.sqlx/query-7f98c013682aca6d269ef6ef754a7247b6e2dd4958d0968a3f2eb49cc320e877.json rename to backend/.sqlx/query-344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d.json index 90b559a9b6..37f090c2b4 100644 --- a/backend/.sqlx/query-7f98c013682aca6d269ef6ef754a7247b6e2dd4958d0968a3f2eb49cc320e877.json +++ b/backend/.sqlx/query-344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 3333333333, 'f/test/error_handler', 'export function main() { return \"handled\"; }', 'deno', 'script', 'test-user', '{}', '', '', '')\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 3333333333, 'f/test/error_handler', 'export function main() { return \"handled\"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n')\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "7f98c013682aca6d269ef6ef754a7247b6e2dd4958d0968a3f2eb49cc320e877" + "hash": "344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d" } diff --git a/backend/.sqlx/query-386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34.json b/backend/.sqlx/query-386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34.json deleted file mode 100644 index 2f794e6be0..0000000000 --- a/backend/.sqlx/query-386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Jsonb", - "Text", - "Text", - "Bool", - "Text", - "Bool" - ] - }, - "nullable": [ - null - ] - }, - "hash": "386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34" -} diff --git a/backend/.sqlx/query-2d43c1889b492df6f77380e739e41e20fe6bea0553cc39e30b7aa8ae59963f95.json b/backend/.sqlx/query-42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779.json similarity index 58% rename from backend/.sqlx/query-2d43c1889b492df6f77380e739e41e20fe6bea0553cc39e30b7aa8ae59963f95.json rename to backend/.sqlx/query-42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779.json index 532fc430ab..402a406820 100644 --- a/backend/.sqlx/query-2d43c1889b492df6f77380e739e41e20fe6bea0553cc39e30b7aa8ae59963f95.json +++ b/backend/.sqlx/query-42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'deno', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', '')\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'bun', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', E'{}\\n//bun.lock\\n')\n ", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "2d43c1889b492df6f77380e739e41e20fe6bea0553cc39e30b7aa8ae59963f95" + "hash": "42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779" } diff --git a/backend/.sqlx/query-58ea7e2c6b07ffc7ab7387b2ebb542cba3a5532c64a9594d2e0ccdf21dee90d0.json b/backend/.sqlx/query-58ea7e2c6b07ffc7ab7387b2ebb542cba3a5532c64a9594d2e0ccdf21dee90d0.json new file mode 100644 index 0000000000..e03d995f15 --- /dev/null +++ b/backend/.sqlx/query-58ea7e2c6b07ffc7ab7387b2ebb542cba3a5532c64a9594d2e0ccdf21dee90d0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT canceled_by IS NOT NULL FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 AND running", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "58ea7e2c6b07ffc7ab7387b2ebb542cba3a5532c64a9594d2e0ccdf21dee90d0" +} diff --git a/backend/.sqlx/query-fe17aff543f28cf4d6d09ea7c4c7d18ff6a95a8402468131f5a5f33df91c59e4.json b/backend/.sqlx/query-69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927.json similarity index 59% rename from backend/.sqlx/query-fe17aff543f28cf4d6d09ea7c4c7d18ff6a95a8402468131f5a5f33df91c59e4.json rename to backend/.sqlx/query-69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927.json index c747e99795..751b727d0f 100644 --- a/backend/.sqlx/query-fe17aff543f28cf4d6d09ea7c4c7d18ff6a95a8402468131f5a5f33df91c59e4.json +++ b/backend/.sqlx/query-69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 1111111111, 'f/test/error_handler', $1, 'deno', 'script', 'test-user', '{}', 'Error handler script', 'Handles failed job completions', '')\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 1111111111, 'f/test/error_handler', $1, 'bun', 'script', 'test-user', '{}', 'Error handler script', 'Handles failed job completions', E'{}\\n//bun.lock\\n')\n ", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "fe17aff543f28cf4d6d09ea7c4c7d18ff6a95a8402468131f5a5f33df91c59e4" + "hash": "69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927" } diff --git a/backend/.sqlx/query-711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc.json b/backend/.sqlx/query-711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc.json new file mode 100644 index 0000000000..4735c589ac --- /dev/null +++ b/backend/.sqlx/query-711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "711927611ecf0144b6241b8f39f94b79f4ff03bdae60ab746f930f8758fe46bc" +} diff --git a/backend/.sqlx/query-71c9e0c0f1d9eba0f24c8e410ca2f2a64dc559e3228d3b763f68b1ad3491ae6e.json b/backend/.sqlx/query-71c9e0c0f1d9eba0f24c8e410ca2f2a64dc559e3228d3b763f68b1ad3491ae6e.json new file mode 100644 index 0000000000..ba1cc13fac --- /dev/null +++ b/backend/.sqlx/query-71c9e0c0f1d9eba0f24c8e410ca2f2a64dc559e3228d3b763f68b1ad3491ae6e.json @@ -0,0 +1,45 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,\n (extras->>'wac_first_started_at')::timestamptz AS first_started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , extras\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),\n CASE WHEN d.first_started_at IS NULL THEN d.run_ms\n ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,\n CASE WHEN d.first_started_at IS NOT NULL\n THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING cj.started_at, cj.duration_ms,\n COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",\n last_run_ms AS \"last_run_ms!\" FROM completed", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "started_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "duration_ms!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "last_run_ms!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Text", + "Bool", + "Varchar", + "Text", + "Bool", + "Int4", + "Int8", + "TextArray", + "Uuid", + "Text" + ] + }, + "nullable": [ + true, + false, + null + ] + }, + "hash": "71c9e0c0f1d9eba0f24c8e410ca2f2a64dc559e3228d3b763f68b1ad3491ae6e" +} diff --git a/backend/.sqlx/query-71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496.json b/backend/.sqlx/query-71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496.json deleted file mode 100644 index b647ec29fa..0000000000 --- a/backend/.sqlx/query-71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n runnable_path,\n permissioned_as_email,\n parent_job\n FROM v2_job\n WHERE workspace_id = 'test-workspace'\n AND permissioned_as_email = 'success_handler@windmill.dev'\n ORDER BY created_at DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "runnable_path", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "permissioned_as_email", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "parent_job", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - true, - false, - true - ] - }, - "hash": "71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496" -} diff --git a/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json b/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json new file mode 100644 index 0000000000..1c8dfcfa57 --- /dev/null +++ b/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wp.draining FROM v2_job j\n JOIN v2_job_queue q ON q.id = j.id\n JOIN worker_ping wp ON wp.worker = q.worker\n WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL\n AND ($3::text[] IS NULL OR j.tag = ANY($3))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "draining", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13" +} diff --git a/backend/.sqlx/query-51f18c817d4014e847486f5a8dbf205ffa0e53d5bd3fdb97777e988389b1d69e.json b/backend/.sqlx/query-8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0.json similarity index 61% rename from backend/.sqlx/query-51f18c817d4014e847486f5a8dbf205ffa0e53d5bd3fdb97777e988389b1d69e.json rename to backend/.sqlx/query-8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0.json index 6eceb86132..3420b8c337 100644 --- a/backend/.sqlx/query-51f18c817d4014e847486f5a8dbf205ffa0e53d5bd3fdb97777e988389b1d69e.json +++ b/backend/.sqlx/query-8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/success_script', 'export function main() { return \"ok\"; }', 'deno', 'script', 'test-user', '{}', '', '', '')\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/success_script', 'export function main() { return \"ok\"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n')\n ", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "51f18c817d4014e847486f5a8dbf205ffa0e53d5bd3fdb97777e988389b1d69e" + "hash": "8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0" } diff --git a/backend/.sqlx/query-8645e610aa35088376d0919132459bce3ad17df617855e5fcf838f6349e68c97.json b/backend/.sqlx/query-8645e610aa35088376d0919132459bce3ad17df617855e5fcf838f6349e68c97.json new file mode 100644 index 0000000000..446e9da6df --- /dev/null +++ b/backend/.sqlx/query-8645e610aa35088376d0919132459bce3ad17df617855e5fcf838f6349e68c97.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_settings (name, value) VALUES ($1, 'true'::jsonb) ON CONFLICT (name) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "8645e610aa35088376d0919132459bce3ad17df617855e5fcf838f6349e68c97" +} diff --git a/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json b/backend/.sqlx/query-8aef4f9d27ec6df07cd671bcf90bf378e9fac4986cd29f69c1f2b8a83ba515c3.json similarity index 54% rename from backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json rename to backend/.sqlx/query-8aef4f9d27ec6df07cd671bcf90bf378e9fac4986cd29f69c1f2b8a83ba515c3.json index e103a91c1e..53a9daaf9a 100644 --- a/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json +++ b/backend/.sqlx/query-8aef4f9d27ec6df07cd671bcf90bf378e9fac4986cd29f69c1f2b8a83ba515c3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS runnable_path,\n CASE\n WHEN a.agent THEN 'agents'\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id,\n LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2", + "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS runnable_path,\n CASE\n WHEN a.agent THEN 'agents'\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('dependencies', 'flowdependencies') THEN 'dependencies'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id,\n LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2", "describe": { "columns": [ { @@ -31,5 +31,5 @@ null ] }, - "hash": "7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec" + "hash": "8aef4f9d27ec6df07cd671bcf90bf378e9fac4986cd29f69c1f2b8a83ba515c3" } diff --git a/backend/.sqlx/query-91fdd565bd210f8aa4c6d5f5e309573aefaeb5dbc6ac07f5e664bc8fe2cb2683.json b/backend/.sqlx/query-91fdd565bd210f8aa4c6d5f5e309573aefaeb5dbc6ac07f5e664bc8fe2cb2683.json new file mode 100644 index 0000000000..ba917c82db --- /dev/null +++ b/backend/.sqlx/query-91fdd565bd210f8aa4c6d5f5e309573aefaeb5dbc6ac07f5e664bc8fe2cb2683.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "coalesce", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "91fdd565bd210f8aa4c6d5f5e309573aefaeb5dbc6ac07f5e664bc8fe2cb2683" +} diff --git a/backend/.sqlx/query-9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d.json b/backend/.sqlx/query-9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d.json deleted file mode 100644 index f105dd7825..0000000000 --- a/backend/.sqlx/query-9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "duration_ms!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Bool", - "Text", - "Bool", - "Varchar", - "Text", - "Bool", - "Int4", - "Int8", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d" -} diff --git a/backend/.sqlx/query-ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e.json b/backend/.sqlx/query-966b065bd6bbbff9e0ca15bee504f9cfe5e3a75427767de41e5b85c797f7d58a.json similarity index 57% rename from backend/.sqlx/query-ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e.json rename to backend/.sqlx/query-966b065bd6bbbff9e0ca15bee504f9cfe5e3a75427767de41e5b85c797f7d58a.json index bb7e4fa36b..b3a2ea6a05 100644 --- a/backend/.sqlx/query-ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e.json +++ b/backend/.sqlx/query-966b065bd6bbbff9e0ca15bee504f9cfe5e3a75427767de41e5b85c797f7d58a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS (SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 AND running)", + "query": "SELECT EXISTS (SELECT 1 FROM global_settings WHERE name = $1)", "describe": { "columns": [ { @@ -11,7 +11,6 @@ ], "parameters": { "Left": [ - "Uuid", "Text" ] }, @@ -19,5 +18,5 @@ null ] }, - "hash": "ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e" + "hash": "966b065bd6bbbff9e0ca15bee504f9cfe5e3a75427767de41e5b85c797f7d58a" } diff --git a/backend/.sqlx/query-975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5.json b/backend/.sqlx/query-975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5.json deleted file mode 100644 index 0b166b32a4..0000000000 --- a/backend/.sqlx/query-975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "duration_ms!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Bool", - "Text", - "Bool", - "Varchar", - "Text", - "Bool", - "Int4", - "Int8", - "TextArray", - "Uuid", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5" -} diff --git a/backend/.sqlx/query-97d8a9835344b8c72d59afc99d76b681c5120ef29eb09b8626a3fbc43a32cc0e.json b/backend/.sqlx/query-97d8a9835344b8c72d59afc99d76b681c5120ef29eb09b8626a3fbc43a32cc0e.json new file mode 100644 index 0000000000..abf8b56d79 --- /dev/null +++ b/backend/.sqlx/query-97d8a9835344b8c72d59afc99d76b681c5120ef29eb09b8626a3fbc43a32cc0e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM runnable_job_stats WHERE hour < $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "97d8a9835344b8c72d59afc99d76b681c5120ef29eb09b8626a3fbc43a32cc0e" +} diff --git a/backend/.sqlx/query-9e7a5e1ebba42217092971dd340cd9182a4812b14735e502958eaafeb4c6b33d.json b/backend/.sqlx/query-9e7a5e1ebba42217092971dd340cd9182a4812b14735e502958eaafeb4c6b33d.json new file mode 100644 index 0000000000..59ed0e1767 --- /dev/null +++ b/backend/.sqlx/query-9e7a5e1ebba42217092971dd340cd9182a4812b14735e502958eaafeb4c6b33d.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue\n SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null,\n extras = CASE WHEN started_at IS NULL OR extras ? 'wac_first_started_at' THEN extras\n ELSE jsonb_set(COALESCE(extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(started_at))\n END\n WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int4", + "Float8" + ] + }, + "nullable": [] + }, + "hash": "9e7a5e1ebba42217092971dd340cd9182a4812b14735e502958eaafeb4c6b33d" +} diff --git a/backend/.sqlx/query-9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096.json b/backend/.sqlx/query-9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096.json new file mode 100644 index 0000000000..bd3f00aa7d --- /dev/null +++ b/backend/.sqlx/query-9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096.json @@ -0,0 +1,330 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n NULL::boolean as viewer_data_access,\n job_perms.job_token_scopes\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "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": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "viewer_data_access", + "type_info": "Bool" + }, + { + "ordinal": 36, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + null, + true + ] + }, + "hash": "9ec675bd675c9915ed56f6cb91ca848b094b61475409156489319cb9d36ef096" +} diff --git a/backend/.sqlx/query-a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665.json b/backend/.sqlx/query-a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665.json deleted file mode 100644 index 2358af807f..0000000000 --- a/backend/.sqlx/query-a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "executions", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665" -} diff --git a/backend/.sqlx/query-a6d355b3121f199f3827c05aaa7c8f780ab4c9d3d373d3cd0bb489e0d173040c.json b/backend/.sqlx/query-a6d355b3121f199f3827c05aaa7c8f780ab4c9d3d373d3cd0bb489e0d173040c.json new file mode 100644 index 0000000000..e1657e156b --- /dev/null +++ b/backend/.sqlx/query-a6d355b3121f199f3827c05aaa7c8f780ab4c9d3d373d3cd0bb489e0d173040c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "coalesce", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a6d355b3121f199f3827c05aaa7c8f780ab4c9d3d373d3cd0bb489e0d173040c" +} diff --git a/backend/.sqlx/query-d2a9a9af4d00c1c05276c14e6f32044437800a4b0c60aa364ab2f362a4fd9a2c.json b/backend/.sqlx/query-b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a.json similarity index 61% rename from backend/.sqlx/query-d2a9a9af4d00c1c05276c14e6f32044437800a4b0c60aa364ab2f362a4fd9a2c.json rename to backend/.sqlx/query-b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a.json index ec3ae54f17..a5a8c00494 100644 --- a/backend/.sqlx/query-d2a9a9af4d00c1c05276c14e6f32044437800a4b0c60aa364ab2f362a4fd9a2c.json +++ b/backend/.sqlx/query-b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/failing_script', $2, 'deno', 'script', 'test-user', '{}', 'Failing test script', 'A script that always fails', '')\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/failing_script', $2, 'bun', 'script', 'test-user', '{}', 'Failing test script', 'A script that always fails', E'{}\\n//bun.lock\\n')\n ", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "d2a9a9af4d00c1c05276c14e6f32044437800a4b0c60aa364ab2f362a4fd9a2c" + "hash": "b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a" } diff --git a/backend/.sqlx/query-b159cc3ab6fddcfb561c90d20bee2ff605a1c3721a4cbcc080f98fbb87277c37.json b/backend/.sqlx/query-b159cc3ab6fddcfb561c90d20bee2ff605a1c3721a4cbcc080f98fbb87277c37.json new file mode 100644 index 0000000000..d65f75636d --- /dev/null +++ b/backend/.sqlx/query-b159cc3ab6fddcfb561c90d20bee2ff605a1c3721a4cbcc080f98fbb87277c37.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND hash = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b159cc3ab6fddcfb561c90d20bee2ff605a1c3721a4cbcc080f98fbb87277c37" +} diff --git a/backend/.sqlx/query-c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877.json b/backend/.sqlx/query-c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877.json new file mode 100644 index 0000000000..be4fbb73c9 --- /dev/null +++ b/backend/.sqlx/query-c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE email = $1 AND label = 'session' AND token_hash != $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877" +} diff --git a/backend/.sqlx/query-c5971326bdbfaa7dff74689d1365db4f26c41fa56b326dbd02e604c793a13477.json b/backend/.sqlx/query-c5971326bdbfaa7dff74689d1365db4f26c41fa56b326dbd02e604c793a13477.json new file mode 100644 index 0000000000..d84578d37c --- /dev/null +++ b/backend/.sqlx/query-c5971326bdbfaa7dff74689d1365db4f26c41fa56b326dbd02e604c793a13477.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT workspace_id, runnable_path, worker_group,\n SUM(job_count)::bigint AS \"job_count!\",\n SUM(total_duration_ms)::bigint AS \"total_duration_ms!\",\n MAX(max_memory_peak) AS \"max_memory_peak!\",\n SUM(sum_memory_peak)::bigint AS \"sum_memory_peak!\",\n SUM(memory_sample_count)::bigint AS \"memory_sample_count!\",\n SUM(total_cpu_ms)::bigint AS \"total_cpu_ms!\"\n FROM runnable_job_stats\n WHERE hour >= $1\n AND ($2::text IS NULL OR worker_group = $2)\n AND ($3::text IS NULL OR workspace_id = $3)\n GROUP BY workspace_id, runnable_path, worker_group\n ORDER BY CASE $4::text\n WHEN 'max_memory' THEN MAX(max_memory_peak)::numeric\n WHEN 'cpu' THEN SUM(total_cpu_ms)\n ELSE SUM(total_duration_ms)\n END DESC, workspace_id, runnable_path, worker_group\n LIMIT $5\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "worker_group", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "job_count!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "total_duration_ms!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "max_memory_peak!", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "sum_memory_peak!", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "memory_sample_count!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "total_cpu_ms!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "c5971326bdbfaa7dff74689d1365db4f26c41fa56b326dbd02e604c793a13477" +} diff --git a/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json b/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json deleted file mode 100644 index 7f6fe7a81c..0000000000 --- a/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "jobs_executed", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - "TextArray", - "Varchar", - "Varchar", - "TextArray", - "Varchar", - "Int8", - "Int8", - "Text", - "Bool" - ] - }, - "nullable": [ - false - ] - }, - "hash": "c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc" -} diff --git a/backend/.sqlx/query-cdc594b30855e9384481f561b2c16200d4e6dacb1eb274513c8f1795235dfe20.json b/backend/.sqlx/query-cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74.json similarity index 69% rename from backend/.sqlx/query-cdc594b30855e9384481f561b2c16200d4e6dacb1eb274513c8f1795235dfe20.json rename to backend/.sqlx/query-cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74.json index a68e5f1253..91cacf4123 100644 --- a/backend/.sqlx/query-cdc594b30855e9384481f561b2c16200d4e6dacb1eb274513c8f1795235dfe20.json +++ b/backend/.sqlx/query-cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 5555555555, 'f/test/error_handler', 'export function main() { return \"handled\"; }', 'deno', 'script', 'test-user', '{}', '', '', '')\n ", + "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 5555555555, 'f/test/error_handler', 'export function main() { return \"handled\"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n')\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "cdc594b30855e9384481f561b2c16200d4e6dacb1eb274513c8f1795235dfe20" + "hash": "cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74" } diff --git a/backend/.sqlx/query-ce39fd9650500b16f7247c8173a40c7e5d2d8a51aea2d36f2bc8c6f86d53a9b8.json b/backend/.sqlx/query-ce39fd9650500b16f7247c8173a40c7e5d2d8a51aea2d36f2bc8c6f86d53a9b8.json new file mode 100644 index 0000000000..ba7a89ef73 --- /dev/null +++ b/backend/.sqlx/query-ce39fd9650500b16f7247c8173a40c7e5d2d8a51aea2d36f2bc8c6f86d53a9b8.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,\n (extras->>'wac_first_started_at')::timestamptz AS first_started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , extras\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),\n CASE WHEN d.first_started_at IS NULL THEN d.run_ms\n ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,\n CASE WHEN d.first_started_at IS NOT NULL\n THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING cj.started_at, cj.duration_ms,\n COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms\n )\n SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",\n last_run_ms AS \"last_run_ms!\" FROM completed", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "started_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "duration_ms!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "last_run_ms!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Text", + "Bool", + "Varchar", + "Text", + "Bool", + "Int4", + "Int8", + "TextArray" + ] + }, + "nullable": [ + true, + false, + null + ] + }, + "hash": "ce39fd9650500b16f7247c8173a40c7e5d2d8a51aea2d36f2bc8c6f86d53a9b8" +} diff --git a/backend/.sqlx/query-d2fbaaaea6c542342862d8b535673d19dc4ca721ab656a39330bad5ea48999d8.json b/backend/.sqlx/query-d2fbaaaea6c542342862d8b535673d19dc4ca721ab656a39330bad5ea48999d8.json new file mode 100644 index 0000000000..55b335fba0 --- /dev/null +++ b/backend/.sqlx/query-d2fbaaaea6c542342862d8b535673d19dc4ca721ab656a39330bad5ea48999d8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM runnable_job_stats WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d2fbaaaea6c542342862d8b535673d19dc4ca721ab656a39330bad5ea48999d8" +} diff --git a/backend/.sqlx/query-de1adae1306fb9d877ec6f2d52235b3f57d4fa4c27861b51da6bfe8c74abd429.json b/backend/.sqlx/query-de1adae1306fb9d877ec6f2d52235b3f57d4fa4c27861b51da6bfe8c74abd429.json new file mode 100644 index 0000000000..48963d9ef7 --- /dev/null +++ b/backend/.sqlx/query-de1adae1306fb9d877ec6f2d52235b3f57d4fa4c27861b51da6bfe8c74abd429.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT q.id FROM v2_job_queue q\n JOIN native_retry_attempt n ON n.job_id = q.id\n JOIN v2_job r ON r.id = q.id\n JOIN v2_job a ON a.id = r.parent_job\n WHERE q.workspace_id = $2 AND a.parent_job = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "de1adae1306fb9d877ec6f2d52235b3f57d4fa4c27861b51da6bfe8c74abd429" +} diff --git a/backend/.sqlx/query-e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd.json b/backend/.sqlx/query-e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd.json deleted file mode 100644 index ae893b6791..0000000000 --- a/backend/.sqlx/query-e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)\n UPDATE v2_job_queue q SET running = false, started_at = null\n FROM prev WHERE q.id = $1\n RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "int8", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd" -} diff --git a/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json b/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json new file mode 100644 index 0000000000..9ebf9289c2 --- /dev/null +++ b/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE worker_ping SET draining = true WHERE worker = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919" +} diff --git a/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json b/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json new file mode 100644 index 0000000000..9f51c69616 --- /dev/null +++ b/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining)\n RETURNING jobs_executed", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jobs_executed", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar", + "Varchar", + "TextArray", + "Varchar", + "Int8", + "Int8", + "Text", + "Bool", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b" +} diff --git a/backend/.sqlx/query-e8779f1cddc475554dbbd2e3122db8a21a2eaacf971bf046cef1ee14cf04e323.json b/backend/.sqlx/query-e8779f1cddc475554dbbd2e3122db8a21a2eaacf971bf046cef1ee14cf04e323.json new file mode 100644 index 0000000000..e22953610d --- /dev/null +++ b/backend/.sqlx/query-e8779f1cddc475554dbbd2e3122db8a21a2eaacf971bf046cef1ee14cf04e323.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(COALESCE((c.extras->>'wac_last_segment_ms')::bigint, c.duration_ms) + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "executions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "e8779f1cddc475554dbbd2e3122db8a21a2eaacf971bf046cef1ee14cf04e323" +} diff --git a/backend/.sqlx/query-e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258.json b/backend/.sqlx/query-eecea4a5ba0de73129bde6ed0c229690c322bc376b1bfc13ed719126f72cabb9.json similarity index 80% rename from backend/.sqlx/query-e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258.json rename to backend/.sqlx/query-eecea4a5ba0de73129bde6ed0c229690c322bc376b1bfc13ed719126f72cabb9.json index 4ebad828df..1670a5009e 100644 --- a/backend/.sqlx/query-e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258.json +++ b/backend/.sqlx/query-eecea4a5ba0de73129bde6ed0c229690c322bc376b1bfc13ed719126f72cabb9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, NULL, NULL, true\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.status = 'failure'\n AND c.workspace_id = $2\n AND j.flow_step_id IS NULL\n AND j.runnable_id IS NOT DISTINCT FROM $3\n AND EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n JOIN native_retry_attempt nra ON nra.job_id = sc.id\n WHERE sc.status = 'success'\n AND sc.workspace_id = $2\n AND sj.parent_job = $1\n AND sj.runnable_id IS NOT DISTINCT FROM $3\n )\n AND (\n (j.parent_job = $1\n AND EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n OR (c.id = $1 AND j.parent_job IS NULL\n AND NOT EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n )\n ON CONFLICT (job_id) DO NOTHING", + "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, NULL, NULL, true\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.status = 'failure'\n AND c.workspace_id = $2\n AND j.flow_step_id IS NULL\n AND j.runnable_id IS NOT DISTINCT FROM $3\n AND EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n JOIN native_retry_attempt nra ON nra.job_id = sc.id\n WHERE sc.status = 'success'\n AND sc.workspace_id = $2\n AND sj.parent_job = $1\n AND sj.runnable_id IS NOT DISTINCT FROM $3\n )\n AND (\n (j.parent_job = $1\n AND EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n OR (c.id = $1\n AND NOT EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n )\n ON CONFLICT (job_id) DO NOTHING", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258" + "hash": "eecea4a5ba0de73129bde6ed0c229690c322bc376b1bfc13ed719126f72cabb9" } diff --git a/backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json b/backend/.sqlx/query-efc7aab19d729ea419c1fcaef47122096921dce2dac3979a8b788bf76f32646b.json similarity index 58% rename from backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json rename to backend/.sqlx/query-efc7aab19d729ea419c1fcaef47122096921dce2dac3979a8b788bf76f32646b.json index a2a87b680d..81505d64a3 100644 --- a/backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json +++ b/backend/.sqlx/query-efc7aab19d729ea419c1fcaef47122096921dce2dac3979a8b788bf76f32646b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\n active_authors as (SELECT distinct t.email FROM usr t LEFT JOIN workspace_settings ws ON ws.workspace_id = t.workspace_id WHERE NOT (t.operator AND NOT COALESCE((ws.operator_settings->>'builder_flows')::boolean, false)) AND t.email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", + "query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\n active_authors as (SELECT distinct t.email FROM usr t LEFT JOIN workspace_settings ws ON ws.workspace_id = t.workspace_id WHERE NOT (t.operator AND NOT (COALESCE((ws.operator_settings->>'builder_flows')::boolean, false) OR COALESCE((ws.operator_settings->>'builder_apps')::boolean, false))) AND t.email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", "describe": { "columns": [ { @@ -34,5 +34,5 @@ null ] }, - "hash": "cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b" + "hash": "efc7aab19d729ea419c1fcaef47122096921dce2dac3979a8b788bf76f32646b" } diff --git a/backend/.sqlx/query-f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed.json b/backend/.sqlx/query-f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed.json deleted file mode 100644 index 5f86fb97d8..0000000000 --- a/backend/.sqlx/query-f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_queue\n SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null\n WHERE id = $1 AND workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Int4", - "Float8" - ] - }, - "nullable": [] - }, - "hash": "f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed" -} diff --git a/backend/.sqlx/query-f338741f0866dc717ccfcf6b7d46b4e55ca07e472610b29fad24b64f45f98e18.json b/backend/.sqlx/query-f338741f0866dc717ccfcf6b7d46b4e55ca07e472610b29fad24b64f45f98e18.json new file mode 100644 index 0000000000..0f57f5d951 --- /dev/null +++ b/backend/.sqlx/query-f338741f0866dc717ccfcf6b7d46b4e55ca07e472610b29fad24b64f45f98e18.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3\n AND ($4::text IS NULL OR email = $4)\n RETURNING 1 as \"one!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "one!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f338741f0866dc717ccfcf6b7d46b4e55ca07e472610b29fad24b64f45f98e18" +} diff --git a/backend/.sqlx/query-f633095ebe2fdabd116d687c7f8cfb8cb76159d8db20a42e0fce71146f9b8aaa.json b/backend/.sqlx/query-f633095ebe2fdabd116d687c7f8cfb8cb76159d8db20a42e0fce71146f9b8aaa.json new file mode 100644 index 0000000000..c8134447e6 --- /dev/null +++ b/backend/.sqlx/query-f633095ebe2fdabd116d687c7f8cfb8cb76159d8db20a42e0fce71146f9b8aaa.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_settings (name, value) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now() WHERE global_settings.value IS DISTINCT FROM EXCLUDED.value", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "f633095ebe2fdabd116d687c7f8cfb8cb76159d8db20a42e0fce71146f9b8aaa" +} diff --git a/backend/.sqlx/query-fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220.json b/backend/.sqlx/query-fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220.json new file mode 100644 index 0000000000..bfda06948d --- /dev/null +++ b/backend/.sqlx/query-fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220.json @@ -0,0 +1,337 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email,\n NULL::boolean as viewer_data_access,\n job_perms.job_token_scopes", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "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": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "viewer_data_access", + "type_info": "Bool" + }, + { + "ordinal": 36, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "Jsonb", + "TextArray", + "Jsonb", + "TextArray", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + null, + true + ] + }, + "hash": "fa62a55dd0a635995390ad4189b847b91388b6699f16afb57d8147d7e9712220" +} diff --git a/backend/.sqlx/query-fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd.json b/backend/.sqlx/query-fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd.json new file mode 100644 index 0000000000..4801a9b5da --- /dev/null +++ b/backend/.sqlx/query-fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email,\n job_token_scopes, viewer_data_access\n FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "groups", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "folders", + "type_info": "JsonbArray" + }, + { + "ordinal": 6, + "name": "end_user_email", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "job_token_scopes", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "viewer_data_access", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "fd04f65015069792f23edbfce320af6e69ee32b27a8f0a020e251eb2c79e43cd" +} diff --git a/backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json b/backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json similarity index 86% rename from backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json rename to backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json index 583d2bcc6b..5ee5c7571d 100644 --- a/backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json +++ b/backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3", + "query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -107,6 +107,11 @@ "ordinal": 20, "name": "native_mode", "type_info": "Bool" + }, + { + "ordinal": 21, + "name": "draining", + "type_info": "Bool" } ], "parameters": { @@ -138,8 +143,9 @@ true, true, true, + false, false ] }, - "hash": "68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca" + "hash": "fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c" } diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 750e0594e1..fbacb857ee 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -214,7 +214,7 @@ Whole classes of defect compile and unit-test clean: - **Stack overflow from a large buffer in an async block.** An array declared across an `.await` is baked into the future's state; once that future is boxed a few layers deep by the job poller, two 16 KB arrays abort the worker *process* (`thread - 'tokio-runtime-worker' has overflowed its stack`). Heap-allocate read buffers + 'tokio-rt-worker' has overflowed its stack`). Heap-allocate read buffers (`vec![0u8; N]`, not `[0u8; N]`). - Deadlocks from draining only one of a child's pipes, missed cancellation or timeout propagation, and anything depending on the real engine's output format. diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 61e705a11c..a5af71b50e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -247,7 +238,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ - "object 0.39.1", + "object", ] [[package]] @@ -508,6 +499,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive 0.4.0", + "asn1-rs-impl 0.1.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -515,7 +522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ "asn1-rs-derive 0.5.1", - "asn1-rs-impl", + "asn1-rs-impl 0.2.0", "displaydoc", "nom", "num-traits", @@ -531,7 +538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive 0.6.0", - "asn1-rs-impl", + "asn1-rs-impl 0.2.0", "displaydoc", "nom", "num-traits", @@ -540,6 +547,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "asn1-rs-derive" version = "0.5.1" @@ -564,6 +583,17 @@ dependencies = [ "synstructure 0.13.2", ] +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "asn1-rs-impl" version = "0.2.0" @@ -834,7 +864,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -873,7 +903,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -920,6 +950,35 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attribute-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f9ef7a68684657ce9da341c934c73b63df7a3585a61e366d84c84ed5d3d167" +dependencies = [ + "attribute-derive-macro", + "manyhow 0.14.1", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300d426e5e73549125bb8e4649bdd5508f82c94ddf898fb0517246e71dfb8e98" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow 0.14.1", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 3.0.9", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -970,20 +1029,32 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ - "aws-lc-sys", + "aws-lc-sys 0.46.0", "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -1265,9 +1336,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.12" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -1349,8 +1420,8 @@ dependencies = [ "hyper-util", "pin-project-lite", "rustls 0.21.12", - "rustls 0.23.35", - "rustls-native-certs 0.8.4", + "rustls 0.23.40", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -1492,44 +1563,17 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "rustc_version 0.4.1", + "rustc_version", "tracing", ] -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - [[package]] name = "axum" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core 0.5.6", + "axum-core", "axum-macros", "bytes", "form_urlencoded", @@ -1558,26 +1602,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "axum-core" version = "0.5.6" @@ -1608,12 +1632,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "az" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" - [[package]] name = "backon" version = "1.6.0" @@ -1625,27 +1643,18 @@ dependencies = [ "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide 0.8.9", - "object 0.37.3", - "rustc-demangle", - "windows-link 0.2.1", -] - [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base32" version = "0.5.1" @@ -1723,6 +1732,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease 0.2.37", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -1981,7 +2010,7 @@ dependencies = [ "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2005,7 +2034,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2018,6 +2047,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "brotli" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + [[package]] name = "brotli" version = "7.0.0" @@ -2105,7 +2145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b" dependencies = [ "rust_decimal", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "utf8-width", ] @@ -2149,7 +2189,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2212,6 +2252,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "candle-core" version = "0.9.2" @@ -2316,9 +2366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -2364,6 +2414,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "char_str" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cba78a5abb515610e10af297615434b019755b5d7be14e559ff5340dff50c4" +dependencies = [ + "castaway", + "get-size2", + "itoa", + "ryu", +] + [[package]] name = "chrono" version = "0.4.45" @@ -2459,7 +2521,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2490,11 +2552,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ "const-oid 0.9.6", - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", "x509-cert", ] +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + [[package]] name = "colorchoice" version = "1.0.5" @@ -2534,6 +2602,19 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2562,6 +2643,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-hex" +version = "1.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -2700,6 +2793,27 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2751,6 +2865,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.17.0" @@ -2842,6 +2962,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2859,7 +2995,32 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", ] [[package]] @@ -2899,13 +3060,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", + "subtle", ] [[package]] name = "curl-sys" -version = "0.4.90+curl-8.21.0" +version = "0.4.91+curl-8.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97799a0d220bfb3361e0fe4936966ff8c4b24d65c3f06dfc70d7b680b44e7897" +checksum = "cd39579123e45bf762716bb893d43886ed1cabe6289455be29a4f1d4e37afb06" dependencies = [ "cc", "libc", @@ -2926,8 +3088,8 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto 0.2.9", - "rustc_version 0.4.1", + "fiat-crypto", + "rustc_version", "subtle", "zeroize", ] @@ -3058,7 +3220,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3113,7 +3275,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3801,7 +3963,7 @@ checksum = "c72e0409b3dbd60a5bf296cbc273a8e36bb3a3aab6abac389f1891e187d5ce14" dependencies = [ "base64 0.22.1", "capacity_builder", - "deno_error 0.7.3", + "deno_error", "deno_media_type", "deno_terminal", "dprint-swc-ext", @@ -3835,37 +3997,29 @@ dependencies = [ "url", ] -[[package]] -name = "deno_console" -version = "0.209.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c0b8a65dcb7b38c22e5969c6454b3cb0839b7dcfb7a4f0d904de871a4c4416" -dependencies = [ - "deno_core", -] - [[package]] name = "deno_core" -version = "0.352.1" +version = "0.412.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf78f3f72ac8e09b18a588bc26a1b3c5ab853e7fb489889b2f5000654d34db5d" +checksum = "580cb2e0d797d75ff3dfc4a8f2b14aa7470f0fa31654a09a83d4cbb42b5eb9b4" dependencies = [ "anyhow", - "az", - "bincode", + "base64 0.22.1", "bit-set", "bit-vec 0.8.0", + "boxed_error", "bytes", - "capacity_builder", "cooked-waker", - "deno_core_icudata", - "deno_error 0.6.1", + "deno_error", "deno_ops", "deno_path_util", "deno_unsync", + "deno_v8", "futures", "indexmap 2.14.2", + "inventory", "libc", + "num-bigint", "parking_lot", "percent-encoding", "pin-project", @@ -3875,28 +4029,25 @@ dependencies = [ "smallvec", "sourcemap", "static_assertions", + "sys_traits", "thiserror 2.0.21", "tokio", "url", - "v8", + "uuid", "wasm_dep_analyzer", + "windows-sys 0.59.0", ] -[[package]] -name = "deno_core_icudata" -version = "0.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4dccb6147bb3f3ba0c7a48e993bfeb999d2c2e47a81badee80e2b370c8d695" - [[package]] name = "deno_crypto" -version = "0.223.0" +version = "0.273.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beb2568806b930b96fb6440ff16463befa73a36de3378986644e7134c9d52e8f" +checksum = "d9931d22328961b9a9c8d3b666a27312c1f3ddcc7ea78d8610ddfbc934ac61a0" dependencies = [ "aes 0.8.3", "aes-gcm", "aes-kw", + "argon2", "aws-lc-rs", "base64 0.22.1", "cbc", @@ -3904,12 +4055,15 @@ dependencies = [ "ctr", "curve25519-dalek", "deno_core", - "deno_error 0.6.1", - "deno_web", + "deno_crypto_provider", + "deno_error", "ecdsa", - "ed448-goldilocks", - "elliptic-curve", + "elliptic-curve 0.13.8", + "fips203", + "fips205", + "hmac 0.12.1", "num-traits", + "ocb3", "once_cell", "p256", "p384", @@ -3920,21 +4074,34 @@ dependencies = [ "serde_bytes", "sha1", "sha2 0.10.9", - "signature", - "spki", + "sha3", + "signature 2.2.0", + "spki 0.7.3", + "subtle", "thiserror 2.0.21", + "tiny-keccak", "tokio", "uuid", "x25519-dalek", ] [[package]] -name = "deno_error" -version = "0.6.1" +name = "deno_crypto_provider" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612ec3fc481fea759141b0c57810889b0a4fb6fee8f10748677bfe492fd30486" +checksum = "fafd673e5a4eefd6d65db358b00ff60488cfa1be6515406b4371935c2e2e6baa" dependencies = [ - "deno_error_macro 0.6.1", + "aws-lc-sys 0.40.0", + "ed448-goldilocks", +] + +[[package]] +name = "deno_error" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfafd2219b29886a71aecbb3449e462deed1b2c474dc5b12f855f0e58c478931" +dependencies = [ + "deno_error_macro", "libc", "serde", "serde_json", @@ -3942,32 +4109,11 @@ dependencies = [ "url", ] -[[package]] -name = "deno_error" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3007d3f1ea92ea503324ae15883aac0c2de2b8cf6fead62203ff6a67161007ab" -dependencies = [ - "deno_error_macro 0.7.3", - "libc", -] - [[package]] name = "deno_error_macro" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8380a4224d5d2c3f84da4d764c4326cac62e9a1e3d4960442d29136fc07be863" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "deno_error_macro" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b565e60a9685cdf312c888665b5f8647ac692a7da7e058a5e2268a466da8eaf" +checksum = "1c28ede88783f14cd8aae46ca89f230c226b40e4a81ab06fa52ed72af84beb2f" dependencies = [ "proc-macro2", "quote", @@ -3976,31 +4122,30 @@ dependencies = [ [[package]] name = "deno_features" -version = "0.6.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "487773bb24b92f3b88d1c9ef0d4d15888641a2e2ea4d4cdfd6fda12cae26317c" +checksum = "2fef16a40daeb19acfc5345aa5bb367a36ecffef18b7ad1a594f0f01fe7c20c5" dependencies = [ "deno_core", - "serde", - "serde_json", ] [[package]] name = "deno_fetch" -version = "0.233.0" +version = "0.283.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbe158bec955790105ded69978b1245b97c65092f7bf184add344c0e78efffa" +checksum = "20e792f76d45e20cf6f1ad46157bbe1a0a895a6f09ec4d99b0911112be8e05e3" dependencies = [ + "async-compression", "base64 0.22.1", "bytes", "data-url", "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_fs", + "deno_io", "deno_path_util", "deno_permissions", "deno_tls", - "dyn-clone", "error_reporter", "h2 0.4.20", "hickory-resolver", @@ -4011,8 +4156,7 @@ dependencies = [ "hyper-util", "ipnet", "percent-encoding", - "rustls-webpki 0.102.8", - "serde", + "rustls-webpki 0.103.15", "serde_json", "thiserror 2.0.21", "tokio", @@ -4021,62 +4165,69 @@ dependencies = [ "tokio-util", "tokio-vsock", "tower 0.5.3", - "tower-http", "tower-service", ] [[package]] name = "deno_fs" -version = "0.119.0" +version = "0.169.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca83a95cea7bcdf19dac1888ef6f04aff69d251640a4d085bc6089c06b4b181" +checksum = "a0b0ae3c55f4af9d33113ba4935828a2e9a7bfcbf4d3058533403b9ef332a966" dependencies = [ "async-trait", - "base32", "boxed_error", "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_io", + "deno_maybe_sync", "deno_path_util", "deno_permissions", "filetime", "junction", "libc", - "nix 0.27.1", + "nix 0.30.1", "rand 0.8.5", "rayon", + "same-file", "serde", "thiserror 2.0.21", - "winapi", + "tokio", "windows-sys 0.59.0", ] [[package]] name = "deno_io" -version = "0.119.0" +version = "0.169.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597aac25be261a1bd545d6f47a80439c60e600c076f9847b3718a592883b2a73" +checksum = "ba495b6c141d34e49fb2dbe7ca746d7ac068b1962470776eb341c59059b758f6" dependencies = [ "async-trait", "deno_core", - "deno_error 0.6.1", + "deno_error", + "deno_permissions", "deno_subprocess_windows", "filetime", - "fs3", "libc", "log", - "nix 0.27.1", - "once_cell", + "nix 0.30.1", "os_pipe", "parking_lot", "pin-project", "rand 0.8.5", "tokio", "uuid", - "winapi", "windows-sys 0.59.0", ] +[[package]] +name = "deno_maybe_sync" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5e72b38015b5e478b8d739dfa818d563329e03a72d6ce5cfb2ecbcd7f8d41" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "deno_media_type" version = "0.3.3" @@ -4103,17 +4254,21 @@ dependencies = [ [[package]] name = "deno_net" -version = "0.201.0" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb88f1ea2762065d6cc0316372acc22696ab6004943aa029f80546f9faf1b491" +checksum = "19ba02152d978f30e2eb5f8d074b343363f53cb1364b5c304b3176da916a06c0" dependencies = [ "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_features", "deno_permissions", + "deno_signals", "deno_tls", + "deno_tunnel", "hickory-proto", "hickory-resolver", + "libc", + "log", "pin-project", "quinn", "rustls-tokio-stream", @@ -4125,32 +4280,33 @@ dependencies = [ "tokio-vsock", "url", "web-transport-proto", + "x509-parser 0.15.1", ] [[package]] name = "deno_ops" -version = "0.228.1" +version = "0.288.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf8dbe5abf37d270bb853c5dfe45fbe3b1b6c453877cc11d7fe84e9862a6dbc" +checksum = "977748624aad80164dbe385c8c907bc0041c2e6849f92528e362e03132c64c52" dependencies = [ "indexmap 2.14.2", - "proc-macro-rules", "proc-macro2", "quote", "stringcase", "strum", "strum_macros", "syn 2.0.119", + "syn-match", "thiserror 2.0.21", ] [[package]] name = "deno_path_util" -version = "0.4.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516f813389095889776b81cc9108ff6f336fd9409b4b12fc0138aea23d2708e1" +checksum = "78c7e98943f0d068928906db0c7bde89de684fa32c6a8018caacc4cee2cdd72b" dependencies = [ - "deno_error 0.6.1", + "deno_error", "percent-encoding", "sys_traits", "thiserror 2.0.21", @@ -4159,77 +4315,100 @@ dependencies = [ [[package]] name = "deno_permissions" -version = "0.68.0" +version = "0.118.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa14d9c3fbba59836ffbaac7f736a2f48d4a5fc209a2103d1e1b898915232a5" +checksum = "f8e9d37215f52e29b5ad9d9065c476168ffb888cd2ad93b0ae153f22304851a0" dependencies = [ "capacity_builder", - "deno_error 0.6.1", + "chrono", + "deno_error", "deno_path_util", "deno_terminal", "deno_unsync", + "dunce", "fqdn", - "ipnetwork", + "ipnet", "libc", "log", - "nix 0.27.1", + "nix 0.30.1", "once_cell", "parking_lot", - "percent-encoding", "serde", "serde_json", "sys_traits", - "temp_deno_which", "thiserror 2.0.21", + "unicode-normalization", "url", - "winapi", + "which 8.0.6", + "windows-sys 0.59.0", +] + +[[package]] +name = "deno_signals" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038ed4b09a6d36c81ac436c4d10076e9e01cc67faf93d701c541887f8eeddf36" +dependencies = [ + "libc", + "signal-hook", + "thiserror 2.0.21", + "tokio", "windows-sys 0.59.0", ] [[package]] name = "deno_subprocess_windows" -version = "0.4.0" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc6b10059f0ccb14c6e0319c5275f0407fb2f9ffe405cd555700561999ea4bf" +checksum = "04d4f451b2f94d2847de4895d39ca5aa264b90af34e72fd79ddc1d0da05a5ea0" dependencies = [ - "fastrand 2.5.0", "futures-channel", "libc", + "rand 0.8.5", "windows-sys 0.59.0", ] [[package]] name = "deno_telemetry" -version = "0.31.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "377581966bd34e85ce230f7f4b1dee7e16c6bf7f845b4690648507ce7ba105d9" +checksum = "8831dc236ed1c66a90c8dfddab93ad44fbb28950fc476c78306b9100233ff694" dependencies = [ "async-trait", "deno_core", - "deno_error 0.6.1", + "deno_error", + "deno_net", + "deno_signals", + "deno_terminal", "deno_tls", + "flate2", "http-body-util", "hyper 1.12.0", "hyper-rustls 0.27.10", "hyper-util", "log", "once_cell", - "opentelemetry 0.27.1", - "opentelemetry-http 0.27.0", - "opentelemetry-otlp 0.27.0", - "opentelemetry-semantic-conventions 0.27.0", - "opentelemetry_sdk 0.27.1", + "opentelemetry 0.32.0", + "opentelemetry-http 0.32.0", + "opentelemetry-otlp 0.32.0", + "opentelemetry-proto 0.32.0", + "opentelemetry-semantic-conventions 0.32.1", + "opentelemetry_sdk 0.32.1", "pin-project", + "prost 0.14.4", "serde", + "sys_traits", "thiserror 2.0.21", "tokio", + "tokio-vsock", + "tower-service", ] [[package]] name = "deno_terminal" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f71c27009e0141dedd315f1dfa3ebb0a6ca4acce7c080fac576ea415a465f6" +checksum = "f3ba8041ae7319b3ca6a64c399df4112badcbbe0868b4517637647614bede4be" dependencies = [ "once_cell", "termcolor", @@ -4237,23 +4416,40 @@ dependencies = [ [[package]] name = "deno_tls" -version = "0.196.0" +version = "0.246.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7835eb6a8d114703b0293573fbb8885bc2c81e8c35363b9e89391dd3c541fa6" +checksum = "6229f7a00441b2acbf7fae58dd14bded0e835f212653402083430826e640f99d" dependencies = [ "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_native_certs", - "rustls 0.23.35", + "log", + "rustls 0.23.40", "rustls-pemfile 2.2.0", "rustls-tokio-stream", - "rustls-webpki 0.102.8", + "rustls-webpki 0.103.15", "serde", "thiserror 2.0.21", "tokio", "webpki-roots 0.26.11", ] +[[package]] +name = "deno_tunnel" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e432929e0c167f2a003b963ee406c714fcd947a87d4f9edc7b3255e87be9881d" +dependencies = [ + "pin-project", + "quinn", + "serde", + "serde_json", + "socket2 0.6.5", + "thiserror 2.0.21", + "tokio", + "tracing", +] + [[package]] name = "deno_unsync" version = "0.4.4" @@ -4266,42 +4462,43 @@ dependencies = [ ] [[package]] -name = "deno_url" -version = "0.209.0" +name = "deno_v8" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cccbb10fb59f29b04161055a65f7fe83364f9e8317d4d2ce3bd7979faf63a87" +checksum = "85cdff08dedf79da6a40f97554f47d59d419ba3df8061b8b31789cd2cbe00433" dependencies = [ - "deno_core", - "deno_error 0.6.1", - "urlpattern", + "v8", + "v8x", ] [[package]] name = "deno_web" -version = "0.240.0" +version = "0.290.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6b8a97cc90b6aaea20fe200cfa7e5b6953dd33ecda0af50d6d360387c33df4" +checksum = "7510967ba4ec695959c1a518748c3b564fd9ca7a8f3eb13bd397707a3ca232da" dependencies = [ "async-trait", - "base64-simd", + "brotli 6.0.0", "bytes", + "cssparser", "deno_core", - "deno_error 0.6.1", - "deno_permissions", + "deno_error", "encoding_rs", "flate2", "futures", "serde", "thiserror 2.0.21", "tokio", + "unicode-normalization", + "urlpattern", "uuid", ] [[package]] name = "deno_webidl" -version = "0.209.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20bbfac0cf15918f7cbd0b55c366ac20ad65c825f362fa684c3104984254d3b" +checksum = "40af42313723a8d3114c4e5b007abd1cab4400a0b68929794c556b2e792409c7" dependencies = [ "deno_core", ] @@ -4319,6 +4516,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" +dependencies = [ + "asn1-rs 0.5.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -4360,13 +4581,32 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] +[[package]] +name = "derive-io" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cc7f4740088458993d183a200fe56e62378736f94bf0e2dd45807407e7bb94" +dependencies = [ + "derive-io-macros", + "tokio", +] + +[[package]] +name = "derive-io-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d0a1bd7eeab72097740967d03d53db5fbaf8e3c0dd471ebdefa43ce445a20a6" +dependencies = [ + "proc-macro2", +] + [[package]] name = "derive_builder" version = "0.12.0" @@ -4456,7 +4696,6 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "unicode-xid", ] [[package]] @@ -4467,7 +4706,7 @@ checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "proc-macro2", "quote", - "rustc_version 0.4.1", + "rustc_version", "syn 2.0.119", ] @@ -4524,6 +4763,38 @@ dependencies = [ "ctutils", ] +[[package]] +name = "diplomat" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b37a6cea72eb6df5143d3e091d4c34cb0cf4046dd1ddcc082988886fe1f809" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "diplomat-runtime" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae517853d9f82b2d144393b1c32c34c46c2e361de2b6c44a8fdfce23beb211cd" + +[[package]] +name = "diplomat_core" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b18e806f9ecb92df828d080efe47984ae1193958295d4a84fb8b32c9a1aa1b" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 3.0.9", +] + [[package]] name = "dirs" version = "4.0.0" @@ -4615,7 +4886,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4681,6 +4952,27 @@ dependencies = [ "text_lines", ] +[[package]] +name = "drop_bomb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "duct" version = "0.13.7" @@ -4727,12 +5019,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", - "elliptic-curve", + "elliptic-curve 0.13.8", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -4741,8 +5033,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] @@ -4756,21 +5048,35 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2 0.10.9", - "signature", + "signature 2.2.0", "subtle", "zeroize", ] [[package]] -name = "ed448-goldilocks" -version = "0.8.3" +name = "ed448" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06924531e9e90130842b012e447f85bdaf9161bc8a0f8092be8cb70b01ebe092" +checksum = "ae112a25f86ae3598d4e8533ed1e65149cec6eb21918e7a6f4c06dddec370263" dependencies = [ - "fiat-crypto 0.1.20", - "hex", + "pkcs8 0.11.0", + "signature 3.0.0", +] + +[[package]] +name = "ed448-goldilocks" +version = "0.14.0-pre.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d" +dependencies = [ + "ed448", + "elliptic-curve 0.14.1", + "hash2curve", + "rand_core 0.10.1", + "serdect 0.4.3", + "shake", + "signature 3.0.0", "subtle", - "zeroize", ] [[package]] @@ -4800,20 +5106,39 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "base64ct", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "hkdf", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "serde_json", - "serdect", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -4862,7 +5187,7 @@ checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5012,6 +5337,18 @@ dependencies = [ "regex-syntax 0.8.11", ] +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher 1.0.4", +] + [[package]] name = "fastdivide" version = "0.4.2" @@ -5044,10 +5381,14 @@ dependencies = [ ] [[package]] -name = "fiat-crypto" -version = "0.1.20" +name = "ff" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] [[package]] name = "fiat-crypto" @@ -5071,6 +5412,30 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fips203" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8bdb6454f692ca2a2b45cd554c6828c639d7f9c968cf83a678899ec4443a280" +dependencies = [ + "rand_core 0.6.4", + "sha3", + "subtle", + "zeroize", +] + +[[package]] +name = "fips205" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5626bf5534df4ebdbd2536465d7eaa8a9dc2cdeb7e036e0ecf291dcc80ffb6" +dependencies = [ + "rand_core 0.6.4", + "sha2 0.10.9", + "sha3", + "zeroize", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -5090,7 +5455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ "bitflags 2.13.2", - "rustc_version 0.4.1", + "rustc_version", ] [[package]] @@ -5173,9 +5538,9 @@ dependencies = [ [[package]] name = "fqdn" -version = "0.3.12" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb540cf7bc4fe6df9d8f7f0c974cfd0dce8ed4e9e8884e73433b503ee78b4e7d" +checksum = "886ac788f62d16d6b0f26b2fa762b34ef16ebfb4b624c2c15fbcadc9173c0f72" [[package]] name = "from_variant" @@ -5193,17 +5558,6 @@ version = "1.20260821.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c70238056ad1dbfdf035222a571d2e9baf5b6a4c308cfa87bc8cbe7f2f3840" -[[package]] -name = "fs3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb17cf6ed704f72485332f6ab65257460c4f9f3083934cf402bf9f5b3b600a90" -dependencies = [ - "libc", - "rustc_version 0.2.3", - "winapi", -] - [[package]] name = "fs4" version = "0.13.1" @@ -5331,7 +5685,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5533,6 +5887,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "get-size-derive2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f0daa179069133df60523e0f59d0a6bd2d3db9ccae01b630896d269c4a802e" +dependencies = [ + "attribute-derive", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "get-size2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050a5d07359ebfac7f0b74916d94cfa30575e45fdc7e9bfbb0f76d5d4203325a" +dependencies = [ + "compact_str 0.10.0", + "get-size-derive2", + "hashbrown 0.17.1", + "ordermap", + "smallvec", + "thin-vec", +] + [[package]] name = "gethostname" version = "0.4.3" @@ -5614,12 +5993,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "git-version" version = "0.3.9" @@ -5721,7 +6094,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "886aa8ec755382a1fdf4651f6e6ec01f2f3bf49f2cb0f068b9a74cafd574a715" dependencies = [ - "prost", + "prost 0.13.5", "prost-types", "tonic 0.12.3", ] @@ -5783,11 +6156,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gzip-header" version = "1.1.0" @@ -5850,6 +6234,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash2curve" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eaf40612d7d854743e7189228a6d528f0f6e8502cf6a0cb831d28a218b7f3f6" +dependencies = [ + "digest 0.11.3", + "elliptic-curve 0.14.1", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -5997,11 +6391,10 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d00147af6310f4392a31680db52a3ed45a2e0f68eb18e8c3fe5537ecc96d9e2" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ - "async-recursion", "async-trait", "cfg-if", "data-encoding", @@ -6013,6 +6406,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.9.5", + "ring 0.17.14", "serde", "thiserror 2.0.21", "tinyvec", @@ -6023,9 +6417,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5762f69ebdbd4ddb2e975cd24690bf21fe6b2604039189c26acddbc427f12887" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", @@ -6217,7 +6611,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -6344,8 +6740,8 @@ dependencies = [ "hyper 1.12.0", "hyper-util", "log", - "rustls 0.23.35", - "rustls-native-certs 0.8.4", + "rustls 0.23.40", + "rustls-native-certs 0.8.5", "tokio", "tokio-rustls 0.26.6", "tower-service", @@ -6461,6 +6857,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" + [[package]] name = "icu_collections" version = "2.3.0" @@ -6684,10 +7102,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] -name = "inventory" -version = "0.3.24" +name = "interpolator" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "inventory" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] @@ -6703,17 +7127,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "io-uring" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "libc", -] - [[package]] name = "ipconfig" version = "0.3.4" @@ -6733,15 +7146,6 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" -[[package]] -name = "ipnetwork" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" -dependencies = [ - "serde", -] - [[package]] name = "is-macro" version = "0.3.8" @@ -6802,12 +7206,27 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ixdtf" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3" + [[package]] name = "jiff" version = "0.2.38" @@ -6887,7 +7306,7 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version 0.4.1", + "rustc_version", "simd_cesu8", "syn 2.0.119", ] @@ -6995,7 +7414,7 @@ dependencies = [ "pem", "serde", "serde_json", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -7022,6 +7441,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + [[package]] name = "keyed_priority_queue" version = "0.4.2" @@ -7093,7 +7531,7 @@ dependencies = [ "k8s-openapi", "kube-core", "pem", - "rustls 0.23.35", + "rustls 0.23.40", "secrecy", "serde", "serde_json", @@ -7166,12 +7604,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "lalrpop-util" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" - [[package]] name = "lapin" version = "2.5.5" @@ -7375,9 +7807,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.29" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" dependencies = [ "cc", "libc", @@ -7582,7 +8014,7 @@ dependencies = [ "base64 0.22.1", "gethostname", "mail-builder", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "smtp-proto", "tokio", @@ -7590,76 +8022,30 @@ dependencies = [ "webpki-roots 0.26.11", ] -[[package]] -name = "malachite" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5" -dependencies = [ - "malachite-base", - "malachite-nz", - "malachite-q", -] - -[[package]] -name = "malachite-base" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea0ed76adf7defc1a92240b5c36d5368cfe9251640dcce5bd2d0b7c1fd87aeb" -dependencies = [ - "hashbrown 0.14.5", - "itertools 0.11.0", - "libm", - "ryu", -] - -[[package]] -name = "malachite-bigint" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8" -dependencies = [ - "derive_more 1.0.0", - "malachite", - "num-integer", - "num-traits", - "paste", -] - -[[package]] -name = "malachite-nz" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34a79feebb2bc9aa7762047c8e5495269a367da6b5a90a99882a0aeeac1841f7" -dependencies = [ - "itertools 0.11.0", - "libm", - "malachite-base", -] - -[[package]] -name = "malachite-q" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f235d5747b1256b47620f5640c2a17a88c7569eebdf27cd9cb130e1a619191" -dependencies = [ - "itertools 0.11.0", - "malachite-base", - "malachite-nz", -] - [[package]] name = "manyhow" version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" dependencies = [ - "manyhow-macros", + "manyhow-macros 0.11.4", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "manyhow" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1704b073316272a6785b70599f91f73b49022c2e290b065591ee6b9109ed8c7" +dependencies = [ + "manyhow-macros 0.14.1", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "manyhow-macros" version = "0.11.4" @@ -7671,6 +8057,17 @@ dependencies = [ "quote", ] +[[package]] +name = "manyhow-macros" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31601db71f1f5fb6f831a18aa3052d13a62528ee4f2cba7e8c9a5e258d953725" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "mappable-rc" version = "0.1.1" @@ -7938,7 +8335,7 @@ checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666" dependencies = [ "darling 0.23.0", "heck", - "manyhow", + "manyhow 0.11.4", "num-bigint", "proc-macro-crate", "proc-macro2", @@ -8072,6 +8469,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -8291,7 +8689,6 @@ checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.5", "serde", ] @@ -8435,15 +8832,6 @@ dependencies = [ "url", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "object" version = "0.39.1" @@ -8490,6 +8878,18 @@ dependencies = [ "web-time", ] +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher 0.4.4", + "ctr", + "subtle", +] + [[package]] name = "odpic-sys" version = "0.1.1" @@ -8499,6 +8899,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" +dependencies = [ + "asn1-rs 0.5.2", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -8528,6 +8937,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -8667,23 +9080,23 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.27.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab70038c28ed37b97d8ed414b6429d343a8bbf44c9f79ec854f3a643029ba6d7" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" dependencies = [ "futures-core", "futures-sink", "js-sys", "pin-project-lite", - "thiserror 1.0.69", + "thiserror 2.0.21", "tracing", ] [[package]] name = "opentelemetry" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" dependencies = [ "futures-core", "futures-sink", @@ -8705,18 +9118,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "opentelemetry-http" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" -dependencies = [ - "async-trait", - "bytes", - "http 1.5.0", - "opentelemetry 0.27.1", -] - [[package]] name = "opentelemetry-http" version = "0.30.0" @@ -8731,24 +9132,15 @@ dependencies = [ ] [[package]] -name = "opentelemetry-otlp" -version = "0.27.0" +name = "opentelemetry-http" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", - "futures-core", + "bytes", "http 1.5.0", - "opentelemetry 0.27.1", - "opentelemetry-http 0.27.0", - "opentelemetry-proto 0.27.0", - "opentelemetry_sdk 0.27.1", - "prost", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tonic 0.12.3", - "tracing", + "opentelemetry 0.32.0", ] [[package]] @@ -8762,7 +9154,7 @@ dependencies = [ "opentelemetry-http 0.30.0", "opentelemetry-proto 0.30.0", "opentelemetry_sdk 0.30.0", - "prost", + "prost 0.13.5", "reqwest 0.12.28", "thiserror 2.0.21", "tokio", @@ -8771,17 +9163,19 @@ dependencies = [ ] [[package]] -name = "opentelemetry-proto" -version = "0.27.0" +name = "opentelemetry-otlp" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e05acbfada5ec79023c85368af14abd0b307c015e9064d249b2a950ef459a6" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "hex", - "opentelemetry 0.27.1", - "opentelemetry_sdk 0.27.1", - "prost", - "serde", - "tonic 0.12.3", + "http 1.5.0", + "opentelemetry 0.32.0", + "opentelemetry-http 0.32.0", + "opentelemetry-proto 0.32.0", + "opentelemetry_sdk 0.32.1", + "prost 0.14.4", + "serde_json", + "thiserror 2.0.21", ] [[package]] @@ -8794,16 +9188,24 @@ dependencies = [ "hex", "opentelemetry 0.30.0", "opentelemetry_sdk 0.30.0", - "prost", + "prost 0.13.5", "serde", "tonic 0.13.1", ] [[package]] -name = "opentelemetry-semantic-conventions" -version = "0.27.0" +name = "opentelemetry-proto" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "base64 0.22.1", + "const-hex", + "opentelemetry 0.32.0", + "opentelemetry_sdk 0.32.1", + "prost 0.14.4", + "serde", +] [[package]] name = "opentelemetry-semantic-conventions" @@ -8812,25 +9214,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2" [[package]] -name = "opentelemetry_sdk" -version = "0.27.1" +name = "opentelemetry-semantic-conventions" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "231e9d6ceef9b0b2546ddf52335785ce41252bc7474ee8ba05bfad277be13ab8" -dependencies = [ - "async-trait", - "futures-channel", - "futures-executor", - "futures-util", - "glob", - "opentelemetry 0.27.1", - "percent-encoding", - "rand 0.8.5", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tokio-stream", - "tracing", -] +checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47" [[package]] name = "opentelemetry_sdk" @@ -8850,6 +9237,24 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry 0.32.0", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.21", + "tokio", + "tokio-stream", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -8901,6 +9306,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordermap" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a756ddcdeb6c0efc8927bdf0ed112885815a35ba350472a467be5104f01ca1" +dependencies = [ + "indexmap 2.14.2", +] + [[package]] name = "os_pipe" version = "1.2.1" @@ -8939,7 +9353,7 @@ checksum = "3cae83056e7cb770211494a0ecf66d9fa7eba7d00977e5bb91f0e925b40b937f" dependencies = [ "cbc", "cms", - "der", + "der 0.7.10", "des 0.8.1", "hex", "hmac 0.12.1", @@ -8960,7 +9374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "sha2 0.10.9", ] @@ -8972,7 +9386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "sha2 0.10.9", ] @@ -8983,9 +9397,9 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", + "base16ct 0.2.0", "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "rand_core 0.6.4", "sha2 0.10.9", @@ -9224,7 +9638,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", + "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -9243,6 +9657,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ + "phf_macros 0.13.1", "phf_shared 0.13.1", "serde", ] @@ -9253,7 +9668,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", ] @@ -9267,19 +9682,42 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.5.0", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -9380,9 +9818,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -9393,9 +9831,9 @@ checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" dependencies = [ "cms", "const-oid 0.9.6", - "der", + "der 0.7.10", "digest 0.10.7", - "spki", + "spki 0.7.3", "x509-cert", "zeroize", ] @@ -9408,11 +9846,11 @@ checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ "aes 0.8.3", "cbc", - "der", + "der 0.7.10", "pbkdf2", "scrypt", "sha2 0.10.9", - "spki", + "spki 0.7.3", ] [[package]] @@ -9421,8 +9859,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.1", ] [[package]] @@ -9530,7 +9978,7 @@ dependencies = [ "md-5 0.11.0", "memchr", "rand 0.10.3", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", ] @@ -9599,7 +10047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -9608,7 +10056,7 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", ] [[package]] @@ -9617,7 +10065,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.15+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -9643,29 +10091,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-rules" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f" -dependencies = [ - "proc-macro-rules-macros", - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "proc-macro-rules-macros" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "207fffb0fe655d1d47f6af98cc2793405e85929bdbc420d685554ff07be27ac7" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "proc-macro-utils" version = "0.10.0" @@ -9739,6 +10164,22 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand 0.10.3", + "rand_xorshift", + "regex-syntax 0.8.11", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -9746,7 +10187,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -9762,13 +10213,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "prost-types" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost", + "prost 0.13.5", ] [[package]] @@ -9886,7 +10350,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.3", - "rustls 0.23.35", + "rustls 0.23.40", "socket2 0.6.5", "thiserror 2.0.21", "tokio", @@ -9902,13 +10366,14 @@ checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", + "fastbloom", "getrandom 0.4.3", "lru-slab", "rand 0.10.3", "rand_pcg", "ring 0.17.14", "rustc-hash 2.1.3", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror 2.0.21", @@ -9940,6 +10405,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quote-use" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48356e493823dc3c1d7e9fc7b3ff204ecec48afbdd718fc4134091b88eb63f3d" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf1c5de6a7cc231c71e03c77aa4c7019097859ea7029156b54d2894a8430704" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -10094,6 +10581,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -10284,7 +10780,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10373,8 +10869,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.35", - "rustls-native-certs 0.8.4", + "rustls 0.23.40", + "rustls-native-certs 0.8.5", "rustls-pki-types", "serde", "serde_json", @@ -10420,7 +10916,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -10476,6 +10972,16 @@ dependencies = [ "wasmtimer", ] +[[package]] +name = "resb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d392791f3c6802a1905a509e9d1a6039cbbcb5e9e00e5a6d3661f7c874f390" +dependencies = [ + "potential_utf", + "serde_core", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -10579,7 +11085,7 @@ dependencies = [ "rand 0.10.3", "reqwest 0.13.5", "rmcp-macros", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "serde_json", "sse-stream", @@ -10603,7 +11109,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10666,14 +11172,114 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] +[[package]] +name = "ruff_python_ast" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15434b06df7d930fad2818639a4e0fec4af9c770121c45a96b72da7feaae9ead" +dependencies = [ + "aho-corasick", + "arrayvec", + "bitflags 2.13.2", + "char_str", + "compact_str 0.10.0", + "get-size2", + "is-macro", + "memchr", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash 2.1.3", + "thin-vec", + "thiserror 2.0.21", +] + +[[package]] +name = "ruff_python_parser" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a22811d752aa9e255d7dd72524d14352cf9bc654ef79b399bf1eeeb6640fbb" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "drop_bomb", + "get-size2", + "hashbrown 0.17.1", + "memchr", + "ruff_python_ast", + "ruff_python_trivia", + "ruff_text_size", + "ruff_unicode_names2", + "rustc-hash 2.1.3", + "stacker", + "static_assertions", + "thin-vec", + "unicode-ident", + "unicode-normalization", +] + +[[package]] +name = "ruff_python_trivia" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0322a5059fe0feb2e010ca6f31c24ac289f6b7e9e38359539491b10c57f1c5bc" +dependencies = [ + "itertools 0.15.0", + "memchr", + "ruff_source_file", + "ruff_text_size", + "rustc-hash 2.1.3", + "unicode-ident", + "unicode-normalization", +] + +[[package]] +name = "ruff_source_file" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ae809a289b5c791f82aa45df33f2a92323e7a1c1d265c10e9ac884027a2e43" +dependencies = [ + "memchr", + "ruff_text_size", +] + +[[package]] +name = "ruff_text_size" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf6344d6915ced9316312c1ee3f24c4300c54295bc4c3b8f4e95b633ae5443b" +dependencies = [ + "get-size2", +] + +[[package]] +name = "ruff_unicode_names2" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13418b8fd029d597e35dedc36e91f47122d26075e4c86557574d9441753fb375" +dependencies = [ + "phf 0.11.3", + "ruff_unicode_names2_generator", +] + +[[package]] +name = "ruff_unicode_names2_generator" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37fd82af49d5685d9054e68185987acaaf62542f671b6df8935155664876bc39" +dependencies = [ + "phf_codegen", + "rand 0.8.5", +] + [[package]] name = "rumqttc" version = "0.24.0" @@ -10747,12 +11353,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -10765,22 +11365,13 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] - [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.28", + "semver", ] [[package]] @@ -10860,9 +11451,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -10881,7 +11472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" dependencies = [ "log", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.7.3", "rustls-pki-types", "rustls-webpki 0.103.15", @@ -10914,9 +11505,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -10963,8 +11554,8 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.35", - "rustls-native-certs 0.8.4", + "rustls 0.23.40", + "rustls-native-certs 0.8.5", "rustls-platform-verifier-android", "rustls-webpki 0.103.15", "security-framework 3.7.0", @@ -10981,12 +11572,13 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-tokio-stream" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa7dc7c991d9164e55bbf1558029eb5b84d32cc4d61a7df5b8641b2deedc4b3" +checksum = "0560d12c0d8c672f849197de91b9ee61f5bf9aa024c97aaeeb112ec2f6c347fd" dependencies = [ + "derive-io", "futures", - "rustls 0.23.35", + "rustls 0.23.40", "socket2 0.5.10", "tokio", ] @@ -11024,63 +11616,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "rustpython-ast" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cdaf8ee5c1473b993b398c174641d3aa9da847af36e8d5eb8291930b72f31a5" -dependencies = [ - "is-macro", - "malachite-bigint", - "rustpython-parser-core", - "static_assertions", -] - -[[package]] -name = "rustpython-parser" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "868f724daac0caf9bd36d38caf45819905193a901e8f1c983345a68e18fb2abb" -dependencies = [ - "anyhow", - "is-macro", - "itertools 0.11.0", - "lalrpop-util", - "log", - "malachite-bigint", - "num-traits", - "phf 0.11.3", - "phf_codegen", - "rustc-hash 1.1.0", - "rustpython-ast", - "rustpython-parser-core", - "tiny-keccak", - "unic-emoji-char", - "unic-ucd-ident", - "unicode_names2", -] - -[[package]] -name = "rustpython-parser-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b6c12fa273825edc7bccd9a734f0ad5ba4b8a2f4da5ff7efe946f066d0f4ad" -dependencies = [ - "is-macro", - "memchr", - "rustpython-parser-vendored", -] - -[[package]] -name = "rustpython-parser-vendored" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04fcea49a4630a3a5d940f4d514dc4f575ed63c14c3e3ed07146634aed7f67a6" -dependencies = [ - "memchr", - "once_cell", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -11207,14 +11742,14 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive 1.2.2", + "schemars_derive 1.2.3", "serde", "serde_json", ] @@ -11233,14 +11768,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.30.0", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11288,11 +11823,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", - "serdect", + "pkcs8 0.10.2", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array", "subtle", "zeroize", ] @@ -11342,27 +11891,12 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "seq-macro" version = "0.3.6" @@ -11439,7 +11973,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11461,15 +11995,16 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", "indexmap 2.14.2", "itoa", "memchr", @@ -11515,7 +12050,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11541,16 +12076,16 @@ dependencies = [ [[package]] name = "serde_v8" -version = "0.261.1" +version = "0.321.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3495190857461e87a2716141043218aad5281f219f54a03b7ebbe605b3b931df" +checksum = "fd3b6f6140192f569b88997e2ea7afeef6aa38b64374e84784ea1d1ea35679d3" dependencies = [ - "deno_error 0.6.1", + "deno_error", + "deno_v8", "num-bigint", "serde", "smallvec", "thiserror 2.0.21", - "v8", ] [[package]] @@ -11567,7 +12102,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -11583,7 +12118,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11620,7 +12155,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -11686,15 +12231,36 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -11735,6 +12301,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -11751,9 +12327,9 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "zeroize", ] @@ -11767,6 +12343,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -11779,7 +12365,7 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ - "rustc_version 0.4.1", + "rustc_version", "simdutf8", ] @@ -11836,9 +12422,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -11947,7 +12533,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.2", ] [[package]] @@ -11962,6 +12558,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sql-builder" version = "3.1.1" @@ -12041,7 +12643,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.35", + "rustls 0.23.40", "serde", "serde_json", "sha2 0.10.9", @@ -12238,6 +12840,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "string_enum" version = "1.0.2" @@ -12438,7 +13049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcf55c2d7555c93f4945e29f93b7529562be97ba16e60dd94c25724d746174ac" dependencies = [ "ascii", - "compact_str", + "compact_str 0.7.1", "memchr", "num-bigint", "once_cell", @@ -12742,15 +13353,26 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn-match" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b8f0a9004d6aafa6a588602a1119e6cdaacec9921aa1605383e6e7d6258fd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -12760,6 +13382,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -12779,7 +13413,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -12793,11 +13427,14 @@ dependencies = [ [[package]] name = "sys_traits" -version = "0.1.16" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4707edf3196e8037ee45018d1bb1bfb233b0e4fc440fa3d3f25bc69bfdaf26" +checksum = "c7a35f11f9773f11930a7d1e6276766f1577400ee2530b7f0cf3844c2dcbf12b" dependencies = [ + "junction", + "libc", "sys_traits_macros", + "windows-sys 0.59.0", ] [[package]] @@ -13052,15 +13689,6 @@ dependencies = [ "rustls-pemfile 2.2.0", ] -[[package]] -name = "temp_deno_which" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366c5ccd670145885feb6efd6bbf2478ed236c4c3839046fcc8e2a1a84c51091" -dependencies = [ - "either", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -13074,6 +13702,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "temporal_capi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48eb9ca4ca22caaa48d1e7a534ad3fd598a23d1a71945e8ad5ac0be501fa5f2" +dependencies = [ + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale_core", + "num-traits", + "temporal_rs", + "timezone_provider", + "writeable", + "zoneinfo64", +] + +[[package]] +name = "temporal_rs" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a978fef907a27736827d336c0bfaab14fb8a7509dd6dc2b43c44e51d59aca5b" +dependencies = [ + "calendrical_calculations", + "core_maths", + "icu_calendar", + "icu_locale_core", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -13112,6 +13774,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "thin-vec" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" + [[package]] name = "thiserror" version = "1.0.69" @@ -13149,7 +13817,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -13274,6 +13942,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "timezone_provider" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ec4f4eebb4817fde02a411aedc7ac5f66c89c68c49e5d4b17a8139f077af52" +dependencies = [ + "tinystr", + "zerotrie", + "zerovec", + "zoneinfo64", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -13350,23 +14030,20 @@ dependencies = [ [[package]] name = "tokio" -version = "1.46.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.5.10", + "socket2 0.6.5", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -13395,13 +14072,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -13490,7 +14167,7 @@ version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ - "rustls 0.23.35", + "rustls 0.23.40", "tokio", ] @@ -13592,7 +14269,7 @@ dependencies = [ "httparse", "rand 0.8.5", "ring 0.17.14", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -13622,9 +14299,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -13644,21 +14321,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap 2.14.2", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] @@ -13669,13 +14346,10 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ - "async-stream", "async-trait", - "axum 0.7.9", "base64 0.22.1", "bytes", "flate2", - "h2 0.4.20", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -13684,9 +14358,8 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", + "prost 0.13.5", "rustls-pemfile 2.2.0", - "socket2 0.5.10", "tokio", "tokio-rustls 0.26.6", "tokio-stream", @@ -13704,7 +14377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "flate2", @@ -13717,8 +14390,8 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", - "rustls-native-certs 0.8.4", + "prost 0.13.5", + "rustls-native-certs 0.8.5", "socket2 0.5.10", "tokio", "tokio-rustls 0.26.6", @@ -13775,7 +14448,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" dependencies = [ - "axum-core 0.5.6", + "axum-core", "cookie", "futures-util", "http 1.5.0", @@ -13973,9 +14646,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" [[package]] name = "tree-sitter-r" @@ -13999,9 +14672,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc" dependencies = [ "serde", "stable_deref_trait", @@ -14109,7 +14782,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14129,6 +14802,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -14150,17 +14829,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" -[[package]] -name = "unic-emoji-char" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - [[package]] name = "unic-ucd-category" version = "0.9.0" @@ -14173,17 +14841,6 @@ dependencies = [ "unic-ucd-version", ] -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - [[package]] name = "unic-ucd-version" version = "0.9.0" @@ -14213,9 +14870,9 @@ checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -14271,28 +14928,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" -[[package]] -name = "unicode_names2" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" -dependencies = [ - "phf 0.11.3", - "unicode_names2_generator", -] - -[[package]] -name = "unicode_names2_generator" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" -dependencies = [ - "getopts", - "log", - "phf_codegen", - "rand 0.8.5", -] - [[package]] name = "universal-hash" version = "0.5.1" @@ -14344,7 +14979,7 @@ dependencies = [ "log", "native-tls", "once_cell", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", @@ -14374,13 +15009,13 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "0f805818f843b548bacc19609eb3619dd2850e54746f5cada37927393c2ef4ec" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -14416,9 +15051,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -14428,20 +15063,36 @@ dependencies = [ [[package]] name = "v8" -version = "137.3.0" +version = "150.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595" +checksum = "42a978ff11f15b24e5c05a7123cf2b68f41e763546699781a924ef4e2cf43a49" dependencies = [ - "bindgen 0.71.1", + "bindgen 0.72.1", "bitflags 2.13.2", "fslock", "gzip-header", "home", "miniz_oxide 0.8.9", "paste", + "temporal_capi", "which 6.0.3", ] +[[package]] +name = "v8x" +version = "149.4.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3701ffb3692a24808503d17f758b0a3f688d2c451779247982566b5941033e63" +dependencies = [ + "bindgen 0.70.1", + "bitflags 2.13.2", + "cc", + "paste", + "temporal_capi", + "temporal_rs", + "timezone_provider", +] + [[package]] name = "valuable" version = "0.1.1" @@ -14581,7 +15232,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -14624,7 +15275,7 @@ checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14661,11 +15312,11 @@ dependencies = [ [[package]] name = "wasm_dep_analyzer" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51cf5f08b357e64cd7642ab4bbeb11aecab9e15520692129624fb9908b8df2c" +checksum = "a10e6b67c951a84de7029487e0e0a496860dae49f6699edd279d5ff35b8fbf54" dependencies = [ - "deno_error 0.6.1", + "deno_error", "thiserror 2.0.21", ] @@ -14766,6 +15417,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" + [[package]] name = "whoami" version = "1.6.1" @@ -14829,14 +15486,14 @@ dependencies = [ [[package]] name = "windmill" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-nats", "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -14856,7 +15513,7 @@ dependencies = [ "rdkafka", "reqwest 0.13.5", "rumqttc", - "rustls 0.23.35", + "rustls 0.23.40", "serde", "serde_derive", "serde_json", @@ -14887,6 +15544,7 @@ dependencies = [ "windmill-api-scripts", "windmill-api-settings", "windmill-api-users", + "windmill-audit", "windmill-autoscaling", "windmill-common", "windmill-dep-map", @@ -14918,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.826.0" +version = "1.833.0" dependencies = [ "async-stream", "async-trait", @@ -14935,6 +15593,7 @@ dependencies = [ "indexmap 2.14.2", "lazy_static", "mime_guess", + "rand 0.9.5", "reqwest 0.13.5", "serde", "serde_json", @@ -14952,9 +15611,9 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -14965,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "argon2", @@ -14976,7 +15635,7 @@ dependencies = [ "async_zip", "aws-sdk-config", "aws-sigv4", - "axum 0.8.9", + "axum", "base32", "base64 0.22.1", "bytes", @@ -15008,17 +15667,19 @@ dependencies = [ "native-tls", "openidconnect", "openssl", + "p256", "pin-project", "postgres-native-tls 0.5.3", "prometheus", "quick-xml", "quick_cache", "rand 0.9.5", + "rcgen", "regex", "reqwest 0.13.5", "rsa", "rust-embed", - "rustls 0.23.35", + "rustls 0.23.40", "samael", "serde", "serde_json", @@ -15034,6 +15695,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-postgres", + "tokio-rustls 0.26.6", "tokio-stream", "tokio-util", "tower 0.5.3", @@ -15105,9 +15767,9 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "hyper 1.12.0", @@ -15128,9 +15790,9 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -15145,10 +15807,10 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "itertools 0.14.0", @@ -15171,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.826.0" +version = "1.833.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,9 +15843,9 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "itertools 0.14.0", "serde", @@ -15198,9 +15860,9 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "ed25519-dalek", @@ -15220,10 +15882,10 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "candle-core", "candle-nn", "candle-transformers", @@ -15243,9 +15905,9 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "sql-builder", @@ -15259,9 +15921,9 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "hyper 1.12.0", "serde", @@ -15281,9 +15943,9 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "globset", "lazy_static", @@ -15303,9 +15965,9 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -15317,14 +15979,14 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-nats", "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.9", + "axum", "base64 0.22.1", "futures", "hex", @@ -15353,10 +16015,10 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "http 1.5.0", @@ -15379,9 +16041,9 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "flate2", "futures", @@ -15389,7 +16051,7 @@ dependencies = [ "lazy_static", "quick_cache", "reqwest 0.13.5", - "semver 1.0.28", + "semver", "serde", "serde_json", "sha2 0.10.9", @@ -15407,10 +16069,10 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "http 1.5.0", "indexmap 2.14.2", "itertools 0.14.0", @@ -15429,9 +16091,9 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "chrono-tz", "serde", @@ -15449,10 +16111,10 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "chrono", "futures", "http 1.5.0", @@ -15491,10 +16153,10 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "chrono", @@ -15520,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.826.0" +version = "1.833.0" dependencies = [ "lazy_static", "serde", @@ -15532,10 +16194,10 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.826.0" +version = "1.833.0" dependencies = [ "argon2", - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "hyper 1.12.0", @@ -15556,9 +16218,9 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -15570,9 +16232,9 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.826.0" +version = "1.833.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "constant_time_eq 0.3.1", "futures", @@ -15609,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.826.0" +version = "1.833.0" dependencies = [ "chrono", "lazy_static", @@ -15624,10 +16286,10 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "k8s-openapi", "kube", "serde", @@ -15643,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.826.0" +version = "1.833.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15657,7 +16319,7 @@ dependencies = [ "aws-sdk-secretsmanager", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.9", + "axum", "backon", "base64 0.22.1", "bitflags 2.13.2", @@ -15679,6 +16341,7 @@ dependencies = [ "git-version", "globset", "hex", + "hkdf", "hmac 0.12.1", "hyper 1.12.0", "indexmap 2.14.2", @@ -15696,6 +16359,7 @@ dependencies = [ "opentelemetry-otlp 0.30.0", "opentelemetry-semantic-conventions 0.30.0", "opentelemetry_sdk 0.30.0", + "p256", "pep440_rs", "phf 0.11.3", "pin-project-lite", @@ -15711,18 +16375,18 @@ dependencies = [ "reqwest-middleware", "reqwest-retry", "rsa", - "rustls 0.23.35", - "rustls-native-certs 0.8.4", + "rustls 0.23.40", + "rustls-native-certs 0.8.5", "ryu-js", "schemars 0.8.22", - "semver 1.0.28", + "semver", "serde", "serde_json", "serde_yml", "sha1", "sha2 0.10.9", "size", - "spki", + "spki 0.7.3", "sqlx", "strum", "strum_macros", @@ -15757,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.826.0" +version = "1.833.0" dependencies = [ "chrono", "futures", @@ -15777,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.826.0" +version = "1.833.0" dependencies = [ "regex", "serde", @@ -15794,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15821,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "futures", @@ -15838,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.826.0" +version = "1.833.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15854,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -15867,6 +16531,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "sse-stream", "tokio", "tokio-util", "tracing", @@ -15875,11 +16540,11 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "backon", "base64 0.22.1", "chrono", @@ -15906,12 +16571,12 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "arc-swap", "async-oauth2", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "hex", @@ -15931,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-stream", @@ -15940,7 +16605,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.9", + "axum", "bytes", "chrono", "datafusion", @@ -15967,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "futures", @@ -15980,12 +16645,13 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "windmill-audit", "windmill-common", ] [[package]] name = "windmill-parser" -version = "1.826.0" +version = "1.833.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15994,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16006,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -16018,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "gosyn", @@ -16030,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16042,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -16054,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "nu-parser", @@ -16065,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16076,30 +16742,30 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", - "rustpython-ast", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", "serde_json", "windmill-parser", ] [[package]] name = "windmill-parser-py-asset" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "rustpython-ast", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", "windmill-parser", "windmill-parser-sql-asset", ] [[package]] name = "windmill-parser-py-imports" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", @@ -16109,7 +16775,8 @@ dependencies = [ "phf 0.11.3", "regex", "regex-lite", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", "serde", "serde_json", "sqlx", @@ -16121,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -16133,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16147,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16164,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16177,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde", @@ -16189,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16207,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16223,11 +16890,12 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "rustpython-ast", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", + "ruff_text_size", "serde", "serde_json", "sha2 0.10.9", @@ -16239,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -16253,11 +16921,11 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.9", + "axum", "backon", "chrono", "chrono-tz", @@ -16292,23 +16960,19 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "const_format", "deno_ast", - "deno_console", "deno_core", "deno_crypto", - "deno_error 0.6.1", + "deno_error", "deno_fetch", - "deno_fs", - "deno_io", "deno_net", "deno_permissions", "deno_telemetry", "deno_tls", - "deno_url", "deno_web", "deno_webidl", "futures", @@ -16317,13 +16981,15 @@ dependencies = [ "rcgen", "regex", "reqwest 0.13.5", - "rustls 0.23.35", + "rustls 0.23.40", "serde", "serde_json", "sqlx", + "sys_traits", "tokio", "tracing", "uuid", + "v8", "winapi", "windmill-common", "windmill-parser-ts", @@ -16332,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.826.0" +version = "1.833.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16343,11 +17009,11 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "dashmap", @@ -16378,11 +17044,11 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "chrono", "futures", "serde", @@ -16402,11 +17068,11 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "hyper 1.12.0", @@ -16435,11 +17101,11 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "futures", "itertools 0.14.0", @@ -16462,11 +17128,11 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "chrono", @@ -16495,11 +17161,11 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "lazy_static", "regex", @@ -16515,11 +17181,11 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "chrono", @@ -16549,11 +17215,11 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -16586,11 +17252,11 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "itertools 0.14.0", "rdkafka", @@ -16609,11 +17275,11 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "itertools 0.14.0", @@ -16633,12 +17299,12 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-nats", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "itertools 0.14.0", "nkeys", @@ -16657,11 +17323,11 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "byteorder", "bytes", "chrono", @@ -16692,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", @@ -16701,7 +17367,7 @@ dependencies = [ "aws-sdk-sqs", "aws-sdk-sts", "aws-smithy-types", - "axum 0.8.9", + "axum", "backon", "chrono", "itertools 0.14.0", @@ -16720,11 +17386,11 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "futures", "http 1.5.0", @@ -16745,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16764,14 +17430,14 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-once-cell", "async-recursion", "async-stream", "async-trait", - "axum 0.8.9", + "axum", "backon", "base64 0.22.1", "bit-vec 0.6.3", @@ -16816,7 +17482,7 @@ dependencies = [ "postgres-protocol", "process-wrap", "prometheus", - "prost", + "prost 0.13.5", "quick_cache", "rand 0.9.5", "rcgen", @@ -16825,8 +17491,8 @@ dependencies = [ "reqwest-middleware", "rsa", "rust_decimal", - "rustls 0.23.35", - "rustls-native-certs 0.8.4", + "rustls 0.23.40", + "rustls-native-certs 0.8.5", "rustls-pemfile 2.2.0", "serde", "serde_json", @@ -16883,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.826.0" +version = "1.833.0" dependencies = [ "bytes", "futures", @@ -17426,9 +18092,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "version_check", ] @@ -17479,8 +18145,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "x509-parser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7069fba5b66b9193bd2c5d3d4ff12b839118f6bcbef5328efafafb5395cf63da" +dependencies = [ + "asn1-rs 0.5.2", + "data-encoding", + "der-parser 8.2.0", + "lazy_static", + "nom", + "oid-registry 0.6.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", ] [[package]] @@ -17586,24 +18269,24 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -17627,7 +18310,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -17683,7 +18366,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -17710,6 +18393,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zoneinfo64" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed6eb2607e906160c457fd573e9297e65029669906b9ac8fb1b5cd5e055f0705" +dependencies = [ + "calendrical_calculations", + "icu_locale_core", + "potential_utf", + "resb", + "serde", +] + [[package]] name = "zstd" version = "0.13.3" @@ -17730,9 +18426,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.1+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" dependencies = [ "cc", "pkg-config", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7808c0d621..5cfd7372cc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.826.0" +version = "1.833.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.826.0" +version = "1.833.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -156,7 +156,7 @@ bedrock = ["windmill-ai/bedrock", "windmill-api/bedrock", "windmill-worker/bedro mqtt_trigger = ["windmill-api/mqtt_trigger"] amqp_trigger = ["windmill-api/amqp_trigger"] native_trigger = ["windmill-api/native_trigger"] -sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"] +sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "openidconnect"] gcp_trigger = ["windmill-api/gcp_trigger"] azure_trigger = ["windmill-api/azure_trigger"] smtp = ["windmill-api/smtp", "windmill-common/smtp", "windmill-queue/smtp"] @@ -281,6 +281,7 @@ windmill-api = { workspace = true, default-features = false } windmill-api-agent-workers = { workspace = true, optional = true } windmill-api-scripts.workspace = true windmill-api-settings.workspace = true +windmill-audit.workspace = true windmill-worker.workspace = true windmill-indexer = { workspace = true, optional = true } windmill-autoscaling = { workspace = true, optional = true } @@ -451,7 +452,7 @@ headers = "^0" hyper = { version = "^1", features = ["full"] } hyper-tls = "^0.6" hyper-util = { version = "^0.1", features = ["client-legacy", "http1", "tokio"] } -tokio = { version = "=1.46.1", features = ["full", "tracing", "time"] } +tokio = { version = "=1.53.2", features = ["full", "tracing", "time"] } tokio-stream = { version = "0.1.17" } tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } @@ -488,9 +489,11 @@ arc-swap = "1" ed25519-dalek = { version = "2", features = ["rand_core"] } magic-crypt = "^3" git-version = "^0" -malachite = "=0.4.18" -malachite-bigint = "=0.2.0" -rustpython-parser = "^0" +# Ruff's parser crates are internal to ruff and carry no semver guarantee: pin them exactly, +# and to the same release. +ruff_python_parser = "=0.0.17" +ruff_python_ast = "=0.0.17" +ruff_text_size = "=0.0.17" pep440_rs = "0.7.3" php-parser-rs = { git = "https://github.com/php-rust-tools/parser", rev = "ec4cb411dec09450946ef57920b7ffced7f6495d" } cron = "^0" @@ -519,33 +522,29 @@ aws-sdk-rds = "^1" async-trait = "0.1.88" -v8 = "=137.1.0" # Exact version NOTE: Do not forget to update version and hash in flake.nix -# deno_* pin set: deno v2.4.0 base, with deno_ast force-overridden to =0.51.0. -# Rationale: deno_ast 0.51.0 is the first version pulling swc_common =14.0.4, -# the first swc_common patch that dropped `pub use serde::__private as serde;` -# (the line that capped our workspace serde pin at =1.0.220). v2.4.0's other -# pins keep deno_tls at 0.196.0 which uses permissive `rustls ^0.23.11`, -# compatible with aws-sdk-bedrockruntime's `^0.23.31` requirement. deno_tls -# 0.198+ tightened that to exact `=0.23.28`, which would have made any -# meaningful deno bump resolver-impossible against aws-sdk. -deno_fetch = "0.233.0" -deno_tls = "0.196.0" -deno_console = "0.209.0" -deno_url = "0.209.0" -deno_webidl = "0.209.0" -deno_web = "0.240.0" -# Sibling release of the pinned deno_core 0.352 / deno_web 0.240 stack -# (its deps are deno_core ^0.352, deno_web ^0.240, deno_error =0.6.1). A newer -# deno_crypto would force bumping the whole deno stack. -deno_crypto = "0.223.0" -deno_io = "0.119.0" -deno_fs = "0.119.0" -deno_net = "0.201.0" -deno_core = "0.352.0" +# deno_* pin set: the crates deno published together on 2026-09-16 (deno_core +# 0.412). +# V8 is pinned exactly because flake.nix fetches the prebuilt archive of that +# version: update its version and hash there with any bump. No default features, +# so the pin leaves the feature set deno_core picks untouched. +v8 = { version = "=150.4.0", default-features = false } +# These release as a matched set: each pins its siblings, deno_error exactly, +# and deno_tls pins rustls exactly (=0.23.40, the workspace `rustls` pin below +# must equal it). A bump moves all of them at once. +# deno_ast is independent of that set and stays at 0.51.0: a newer one pulls +# swc_common 17, which would force bumping every swc_* pin the parsers use. +deno_fetch = "0.283.0" +deno_tls = "0.246.0" +deno_webidl = "0.259.0" +deno_web = "0.290.0" +deno_crypto = "0.273.0" +deno_net = "0.251.0" +deno_core = "0.412.0" deno_ast = { version = "=0.51.0", features = ["transpiling"] } -deno_permissions = "0.68.0" -deno_telemetry = "0.31.0" -deno_error = "=0.6.1" +deno_permissions = "0.118.0" +deno_telemetry = "0.81.0" +deno_error = "=0.7.1" +sys_traits = { version = "0.1.28", features = ["real", "libc", "winapi"] } rustls-pemfile = "2.2.0" # only used with special deno_core_mac feature to prevent ffi issue on macos, requires libffi to be installed @@ -596,9 +595,11 @@ const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] } const-str = "0.5" constant_time_eq = "0.3.1" rsa = "^0" +p256 = "0.13" spki = { version = "0.7", features = ["pem"] } pkcs1 = "0.7" aes-gcm = "0.10.3" +hkdf = "0.12.4" async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] } once_cell = "1.17.1" dashmap = "6.1.0" @@ -682,7 +683,7 @@ aws-sdk-sqs = "^1.89.0" aws-sdk-sts = "^1.91.0" aws-sdk-sso = "=1.77.0" aws-sdk-ssooidc = "=1.78.0" -rustls = "=0.23.35" +rustls = "=0.23.40" async-once-cell = "0.5.4" size = "0.5.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e1cd3791f0..a29f944749 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -bd19fbc0a6329fc5503637be3fe3c4b6302cc86c +440f922eaffd5abca8f3223e34fe5a0ca61e6106 diff --git a/backend/migrations/20261008070335_worker_ping_draining.down.sql b/backend/migrations/20261008070335_worker_ping_draining.down.sql new file mode 100644 index 0000000000..64a5f2949d --- /dev/null +++ b/backend/migrations/20261008070335_worker_ping_draining.down.sql @@ -0,0 +1 @@ +ALTER TABLE worker_ping DROP COLUMN IF EXISTS draining; diff --git a/backend/migrations/20261008070335_worker_ping_draining.up.sql b/backend/migrations/20261008070335_worker_ping_draining.up.sql new file mode 100644 index 0000000000..33b5ad5e3e --- /dev/null +++ b/backend/migrations/20261008070335_worker_ping_draining.up.sql @@ -0,0 +1 @@ +ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS draining BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/migrations/20261008165042_runnable_job_stats.down.sql b/backend/migrations/20261008165042_runnable_job_stats.down.sql new file mode 100644 index 0000000000..21208736b4 --- /dev/null +++ b/backend/migrations/20261008165042_runnable_job_stats.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS runnable_job_stats; diff --git a/backend/migrations/20261008165042_runnable_job_stats.up.sql b/backend/migrations/20261008165042_runnable_job_stats.up.sql new file mode 100644 index 0000000000..cf676a037e --- /dev/null +++ b/backend/migrations/20261008165042_runnable_job_stats.up.sql @@ -0,0 +1,17 @@ +-- Hourly resource rollup per runnable and worker group, fed by the workers. +-- Memory figures are in kB, like v2_job_completed.memory_peak. +-- No foreign key to workspace: every worker upserts here, and the key would cost each row +-- a lookup and a lock on its workspace. Workspace deletion removes the rows itself. +CREATE TABLE IF NOT EXISTS runnable_job_stats ( + hour BIGINT NOT NULL, + workspace_id VARCHAR(50) NOT NULL, + runnable_path VARCHAR(255) NOT NULL, + worker_group TEXT NOT NULL, + job_count INTEGER NOT NULL DEFAULT 0, + total_duration_ms BIGINT NOT NULL DEFAULT 0, + max_memory_peak INTEGER NOT NULL DEFAULT 0, + sum_memory_peak BIGINT NOT NULL DEFAULT 0, + memory_sample_count INTEGER NOT NULL DEFAULT 0, + total_cpu_ms BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (hour, workspace_id, runnable_path, worker_group) +); diff --git a/backend/migrations/20261012180101_datatable_role_set.down.sql b/backend/migrations/20261012180101_datatable_role_set.down.sql new file mode 100644 index 0000000000..15384edae8 --- /dev/null +++ b/backend/migrations/20261012180101_datatable_role_set.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS datatable_role_set; diff --git a/backend/migrations/20261012180101_datatable_role_set.up.sql b/backend/migrations/20261012180101_datatable_role_set.up.sql new file mode 100644 index 0000000000..0093837d3b --- /dev/null +++ b/backend/migrations/20261012180101_datatable_role_set.up.sql @@ -0,0 +1,21 @@ +-- The logins behind a data table connection made as several roles at once: one Postgres login per +-- distinct set of data table roles, a member of each. Which roles a login holds is its +-- memberships on the cluster, and its name is derived from their catalog ids, so a row only has to +-- carry the credential. +-- +-- A row is written after its login exists and removed before the login is dropped, so a row always +-- names a login that can authenticate with `pwd`. +CREATE TABLE datatable_role_set ( + cluster VARCHAR(20) NOT NULL CHECK (cluster IN ('instance', 'external_instance')), + name VARCHAR(63) NOT NULL, + -- Generated by Windmill, never entered by anyone, and never leaves the server. + pwd TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Recorded at most once a day. A login unused for long enough is dropped, and made again if + -- its set of roles is asked for later. + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (cluster, name) +); + +GRANT ALL ON datatable_role_set TO windmill_user; +GRANT ALL ON datatable_role_set TO windmill_admin; diff --git a/backend/migrations/20261012213357_widen_variable_value.down.sql b/backend/migrations/20261012213357_widen_variable_value.down.sql new file mode 100644 index 0000000000..48b61c3f65 --- /dev/null +++ b/backend/migrations/20261012213357_widen_variable_value.down.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + too_long BIGINT; +BEGIN + SELECT count(*) INTO too_long FROM variable WHERE length(value) > 15000; + IF too_long > 0 THEN + RAISE EXCEPTION 'cannot narrow variable.value to VARCHAR(15000): % row(s) are longer than 15000 characters', too_long; + END IF; +END $$; + +ALTER TABLE variable DROP CONSTRAINT IF EXISTS variable_value_length; +ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15000); diff --git a/backend/migrations/20261012213357_widen_variable_value.up.sql b/backend/migrations/20261012213357_widen_variable_value.up.sql new file mode 100644 index 0000000000..06dc8a191a --- /dev/null +++ b/backend/migrations/20261012213357_widen_variable_value.up.sql @@ -0,0 +1,8 @@ +-- AES-256-GCM ciphertext is 32 bytes (magic, nonce, tag) longer than legacy CBC before base64, +-- so the longest CBC value that fits 15000 chars is exactly 15040 chars once re-encrypted. +ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15040); + +-- Only GCM secrets (base64 of the `wmA2` magic starts `d21BM`) may use the extra room: a longer +-- CBC value could never be re-encrypted to fit, and would fail every rotation of its workspace key. +ALTER TABLE variable ADD CONSTRAINT variable_value_length + CHECK (length(value) <= 15000 OR (is_secret AND value LIKE 'd21BM%')); diff --git a/backend/migrations/20261013165527_job_perms_viewer_data_access.down.sql b/backend/migrations/20261013165527_job_perms_viewer_data_access.down.sql new file mode 100644 index 0000000000..6741203ade --- /dev/null +++ b/backend/migrations/20261013165527_job_perms_viewer_data_access.down.sql @@ -0,0 +1 @@ +ALTER TABLE job_perms DROP COLUMN IF EXISTS viewer_data_access; diff --git a/backend/migrations/20261013165527_job_perms_viewer_data_access.up.sql b/backend/migrations/20261013165527_job_perms_viewer_data_access.up.sql new file mode 100644 index 0000000000..5d6a41ece4 --- /dev/null +++ b/backend/migrations/20261013165527_job_perms_viewer_data_access.up.sql @@ -0,0 +1,4 @@ +-- Set by the server on a job pushed for an app runnable whose policy says it reads data as its +-- viewer: data table connections that name no role and object storage permission rules then go by +-- end_user_email instead of the job's own identity. +ALTER TABLE job_perms ADD COLUMN IF NOT EXISTS viewer_data_access BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 3c4de3b082..a29aecb92d 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -445,5 +445,90 @@ "mailboxes.read" ], "req_body_auth": true + }, + "ms_outlook": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Mail.Send", "label": "Send mail" }, + { "value": "Mail.Read", "label": "Read and search mail" }, + { "value": "Mail.ReadWrite", "label": "Read, draft, move and modify mail" }, + { "value": "MailboxSettings.Read", "label": "Read mailbox settings and categories" }, + { "value": "Contacts.Read", "label": "Read contacts" }, + { "value": "Contacts.ReadWrite", "label": "Read, create and edit contacts" }, + { "value": "People.Read", "label": "Search relevant people" }, + { "value": "Calendars.Read", "label": "Read calendars and events" }, + { "value": "Calendars.ReadWrite", "label": "Read, create, edit and delete events" }, + { "value": "Calendars.Read.Shared", "label": "Read shared calendars and find meeting times" }, + { "value": "Mail.Send.Shared", "label": "Send mail from shared mailboxes" }, + { "value": "Mail.Read.Shared", "label": "Read mail in shared mailboxes" }, + { "value": "Mail.ReadWrite.Shared", "label": "Read and modify mail in shared mailboxes" } + ], + "connect_config_template": { + "display_name": "Outlook", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"] + } + }, + "hubspot": { + "auth_url": "https://app.hubspot.com/oauth/authorize", + "token_url": "https://api.hubapi.com/oauth/v1/token", + "scopes": [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.contacts.write", + "crm.objects.companies.read", + "crm.objects.companies.write", + "crm.objects.deals.read", + "crm.objects.deals.write", + "crm.objects.owners.read" + ], + "scope_options": [ + { "value": "oauth", "label": "Basic OAuth access (always required by HubSpot)" }, + { "value": "crm.objects.contacts.read", "label": "Read contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.contacts.write", "label": "Create and edit contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.companies.read", "label": "Read companies" }, + { "value": "crm.objects.companies.write", "label": "Create and edit companies" }, + { "value": "crm.objects.deals.read", "label": "Read deals and deal pipelines" }, + { "value": "crm.objects.deals.write", "label": "Create and edit deals" }, + { "value": "crm.objects.owners.read", "label": "Read owners (users assignable to records)" }, + { "value": "tickets", "label": "Read and edit tickets and ticket pipelines" }, + { "value": "crm.objects.line_items.read", "label": "Read line items" }, + { "value": "crm.objects.line_items.write", "label": "Create and edit line items" }, + { "value": "e-commerce", "label": "Read and edit products and line items" }, + { "value": "crm.objects.quotes.read", "label": "Read quotes" }, + { "value": "crm.objects.quotes.write", "label": "Create and edit quotes" }, + { "value": "crm.objects.leads.read", "label": "Read leads" }, + { "value": "crm.objects.leads.write", "label": "Create and edit leads" }, + { "value": "crm.objects.custom.read", "label": "Read custom object records" }, + { "value": "crm.objects.custom.write", "label": "Create and edit custom object records" }, + { "value": "crm.schemas.custom.read", "label": "Read custom object schemas" }, + { "value": "crm.lists.read", "label": "Read lists (segments)" }, + { "value": "crm.lists.write", "label": "Edit lists and their members" }, + { "value": "sales-email-read", "label": "Read the content of logged one-to-one emails" }, + { "value": "forms", "label": "Read and edit forms and their submissions" }, + { "value": "files", "label": "Read and upload files in the file manager" }, + { "value": "content", "label": "Read and edit marketing emails, blog posts, pages and templates" }, + { "value": "crm.objects.marketing_events.read", "label": "Read marketing events" }, + { "value": "marketing.campaigns.read", "label": "Read marketing campaigns (Marketing Hub Professional)" }, + { "value": "communication_preferences.read_write", "label": "Read and change email subscription preferences" }, + { "value": "automation", "label": "Read, create and enroll into workflows (Professional)" }, + { "value": "automation.sequences.read", "label": "Read sequences and sequence enrollments (Sales Hub Professional)" }, + { "value": "automation.sequences.enrollments.write", "label": "Enroll contacts in sequences (Sales Hub Professional)" }, + { "value": "conversations.read", "label": "Read inboxes, threads and messages" }, + { "value": "conversations.write", "label": "Send messages and update threads" }, + { "value": "scheduler.meetings.meeting-link.read", "label": "Read meeting links and their availability" } + ], + "extra_params": { + "optional_scope": "tickets crm.objects.line_items.read crm.objects.line_items.write e-commerce crm.objects.quotes.read crm.objects.quotes.write crm.objects.leads.read crm.objects.leads.write crm.objects.custom.read crm.objects.custom.write crm.schemas.custom.read crm.lists.read crm.lists.write sales-email-read forms files content crm.objects.marketing_events.read marketing.campaigns.read communication_preferences.read_write automation automation.sequences.read automation.sequences.enrollments.write conversations.read conversations.write scheduler.meetings.meeting-link.read" + }, + "req_body_auth": true } } diff --git a/backend/parsers/windmill-parser-py-asset/Cargo.toml b/backend/parsers/windmill-parser-py-asset/Cargo.toml index ddd6446079..84562dcf0e 100644 --- a/backend/parsers/windmill-parser-py-asset/Cargo.toml +++ b/backend/parsers/windmill-parser-py-asset/Cargo.toml @@ -11,6 +11,6 @@ path = "./src/lib.rs" [dependencies] windmill-parser.workspace = true windmill-parser-sql-asset.workspace = true -rustpython-parser.workspace = true -rustpython-ast = { version = "0.4.0", features = ["visitor"] } +ruff_python_parser.workspace = true +ruff_python_ast.workspace = true anyhow.workspace = true diff --git a/backend/parsers/windmill-parser-py-asset/src/lib.rs b/backend/parsers/windmill-parser-py-asset/src/lib.rs index 9c70b93d64..2919b74607 100644 --- a/backend/parsers/windmill-parser-py-asset/src/lib.rs +++ b/backend/parsers/windmill-parser-py-asset/src/lib.rs @@ -1,5 +1,8 @@ -use rustpython_ast::{Constant, Expr, ExprConstant, Visitor}; -use rustpython_parser::{ast::Suite, Parse}; +use ruff_python_ast::{ + visitor::{walk_expr, walk_stmt, Visitor}, + Expr, ExprCall, ExprDict, FStringPartRef, InterpolatedStringElement, Keyword, Stmt, StmtAssign, +}; +use ruff_python_parser::parse_module; use std::collections::HashMap; use windmill_parser::asset_parser::{ asset_was_used, merge_assets, parse_asset_syntax, parse_pipeline_annotations, AssetKind, @@ -8,12 +11,12 @@ use windmill_parser::asset_parser::{ use AssetUsageAccessType::*; pub fn parse_assets(input: &str) -> anyhow::Result { - let ast = Suite::parse(input, "main.py") - .map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))?; + let ast = parse_module(input) + .map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))? + .into_suite(); let mut assets_finder = AssetsFinder { assets: vec![], var_identifiers: HashMap::new() }; - ast.into_iter() - .for_each(|stmt| assets_finder.visit_stmt(stmt)); + assets_finder.visit_body(&ast); for (kind, path, _) in assets_finder.var_identifiers.into_values() { // if a db = wmill.datatable() was never used (e.g db.query(...)), @@ -43,13 +46,72 @@ struct AssetsFinder { var_identifiers: HashMap, } -impl Visitor for AssetsFinder { +impl<'a> Visitor<'a> for AssetsFinder { + fn visit_stmt(&mut self, stmt: &'a Stmt) { + if let Stmt::Assign(node) = stmt { + self.track_assignment(node); + } + // Continue with generic visit to catch any other assets in the statement + walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'a Expr) { + match expr { + // A detected asset call does not recurse, + // so this is only reached when no further context was found + Expr::StringLiteral(s) => self.visit_str(s.value.to_str()), + Expr::FString(f) => { + // Literal text between two interpolations reads as one string, across the + // pieces of an implicitly concatenated f-string too. + let mut literal = String::new(); + for part in &f.value { + match part { + FStringPartRef::Literal(s) => literal.push_str(&s.value), + FStringPartRef::FString(f) => { + for element in &f.elements { + match element { + InterpolatedStringElement::Literal(l) => { + literal.push_str(&l.value) + } + InterpolatedStringElement::Interpolation(_) => { + self.visit_str(&std::mem::take(&mut literal)) + } + } + } + } + } + } + self.visit_str(&literal); + walk_expr(self, expr); + } + Expr::Call(node) => { + if self.visit_expr_call_inner(node).is_err() { + walk_expr(self, expr); + } + } + _ => walk_expr(self, expr), + } + } +} + +impl AssetsFinder { + fn visit_str(&mut self, s: &str) { + if let Some((kind, path)) = parse_asset_syntax(s, false) { + self.assets.push(ParseAssetsResult { + kind, + path: path.to_string(), + access_type: None, + columns: None, + }); + } + } + // Handle assignment statements like: x = wmill.datatable('name') - fn visit_stmt_assign(&mut self, node: rustpython_ast::StmtAssign) { + fn track_assignment(&mut self, node: &StmtAssign) { // Check if the value is a call to a tracked function if let Some(Expr::Name(expr_name)) = node.targets.first() { // Remove any tracked variables with that name in case of reassignment - let Ok(var_name) = expr_name.id.parse::(); + let var_name = expr_name.id.to_string(); let removed = self.var_identifiers.remove(&var_name); // if a db = wmill.datatable() or similar was removed, but never used (e.g db.query(...)), // we still want to register the asset as unknown access type @@ -69,59 +131,12 @@ impl Visitor for AssetsFinder { if let Some((kind, name, schema)) = self.extract_asset_from_call(&node.value) { // Track target variable - let Ok(var_name) = expr_name.id.parse::(); self.var_identifiers .insert(var_name, (kind.clone(), name.clone(), schema.clone())); } } - // Continue with generic visit to catch any other assets in the expression - self.generic_visit_stmt_assign(node); } - // visit_call_expr will not recurse if it detects an asset, - // so this will only be called when no further context was found - fn visit_expr_constant(&mut self, node: ExprConstant) { - match node.value { - Constant::Str(s) => { - if let Some((kind, path)) = parse_asset_syntax(&s, false) { - self.assets.push(ParseAssetsResult { - kind, - path: path.to_string(), - access_type: None, - columns: None, - }); - } - } - _ => self.generic_visit_expr_constant(node), - } - } - - fn visit_expr_call(&mut self, node: rustpython_ast::ExprCall) { - match self.visit_expr_call_inner(&node) { - Ok(_) => {} - Err(_) => { - // Check keyword arguments for assets before falling back to generic visit - for keyword in &node.keywords { - if let Expr::Constant(ExprConstant { value: Constant::Str(s), .. }) = - &keyword.value - { - if let Some((kind, path)) = parse_asset_syntax(s, false) { - self.assets.push(ParseAssetsResult { - kind, - path: path.to_string(), - access_type: None, - columns: None, - }); - } - } - } - self.generic_visit_expr_call(node); - } - } - } -} - -impl AssetsFinder { /// Extract asset info from calls like wmill.datatable('name'), wmill.ducklake('name'), etc. fn extract_asset_from_call( &self, @@ -147,18 +162,20 @@ impl AssetsFinder { // Get the first argument (the asset name) let name = call + .arguments .args .first() .or_else(|| { // Try keyword argument 'name' or 'path' - call.keywords + call.arguments + .keywords .iter() .find(|kw| matches!(kw.arg.as_deref(), Some("name") | Some("path"))) .map(|kw| &kw.value) }) .and_then(|first_arg| { - if let Expr::Constant(ExprConstant { value: Constant::Str(name), .. }) = first_arg { - Some(name.clone()) + if let Expr::StringLiteral(name) = first_arg { + Some(name.value.to_str().to_string()) } else { None } @@ -179,44 +196,39 @@ impl AssetsFinder { Some((kind, name, schema)) } - fn visit_expr_call_inner(&mut self, node: &rustpython_ast::ExprCall) -> Result<(), ()> { + fn visit_expr_call_inner(&mut self, node: &ExprCall) -> Result<(), ()> { let ident: String = node .func .as_name_expr() - .and_then(|o| o.id.parse().ok()) + .map(|o| o.id.to_string()) .or_else(|| { node.func .as_attribute_expr() - .and_then(|attr| attr.attr.parse().ok()) + .map(|attr| attr.attr.to_string()) }) .ok_or(())?; // `obj_name` is the object (receiver) - i.e., `obj` in `obj.method()` - let obj_name: String = if let Expr::Attribute(rustpython_ast::ExprAttribute { - value, .. - }) = node.func.as_ref() - { - if let Expr::Name(expr_name) = value.as_ref() { - expr_name.id.parse().map_err(|_| ())? - } else { - return Err(()); - } - } else { - return Err(()); - }; + let obj_name: String = node + .func + .as_attribute_expr() + .and_then(|attr| attr.value.as_name_expr()) + .map(|name| name.id.to_string()) + .ok_or(())?; if obj_name == "wmill" { // Continue } else if let Some((kind, ref path, ref schema)) = self.var_identifiers.get(&obj_name) { if ident == "query" { - let expr_name = node.args.get(0).or_else(|| { - node.keywords + let expr_name = node.arguments.args.get(0).or_else(|| { + node.arguments + .keywords .iter() .find(|kw| kw.arg.as_deref() == Some("name")) .map(|kw| &kw.value) }); let sql = match expr_name { - Some(Expr::Constant(ExprConstant { value: Constant::Str(sql), .. })) => sql, + Some(Expr::StringLiteral(sql)) => sql.value.to_str(), _ => return Err(()), }; // We use the SQL parser to detect RW, specific tables, etc. @@ -224,7 +236,7 @@ impl AssetsFinder { *kind, path, schema.as_deref(), - &sql, + sql, ); match sql_assets { Ok(Some(sql_assets)) => self.assets.extend(sql_assets), @@ -252,9 +264,10 @@ impl AssetsFinder { }; let arg_val = match arg { - Arg(i, name) => node.args.get(i).or_else(|| { + Arg(i, name) => node.arguments.args.get(i).or_else(|| { // Get arg by name - node.keywords + node.arguments + .keywords .iter() .find(|kw| kw.arg.as_deref() == Some(name)) .map(|kw| &kw.value) @@ -270,8 +283,9 @@ impl AssetsFinder { self.assets .push(ParseAssetsResult { kind, path, access_type, columns: None }); } - Some(Expr::Constant(ExprConstant { value: Constant::Str(value), .. })) => { - let path = parse_asset_syntax(&value, false) + Some(Expr::StringLiteral(value)) => { + let value = value.value.to_str(); + let path = parse_asset_syntax(value, false) .map(|(_, p)| p) .unwrap_or(std::borrow::Cow::Borrowed(value)); self.assets.push(ParseAssetsResult { @@ -291,26 +305,24 @@ impl AssetsFinder { struct Arg(usize, &'static str); /// Extract a string-literal keyword argument, e.g. `s3="value"` in a call. -fn keyword_str_value(keywords: &[rustpython_ast::Keyword], name: &str) -> Option { +fn keyword_str_value(keywords: &[Keyword], name: &str) -> Option { keywords .iter() .find(|kw| kw.arg.as_deref() == Some(name)) .and_then(|kw| match &kw.value { - Expr::Constant(ExprConstant { value: Constant::Str(s), .. }) => Some(s.clone()), + Expr::StringLiteral(s) => Some(s.value.to_str().to_string()), _ => None, }) } /// Extract a string-literal value from a dict literal, e.g. `{"s3": "value"}`. -fn dict_str_value(dict: &rustpython_ast::ExprDict, name: &str) -> Option { - dict.keys +fn dict_str_value(dict: &ExprDict, name: &str) -> Option { + dict.items .iter() - .zip(dict.values.iter()) - .find_map(|(key, value)| match (key.as_ref()?, value) { - ( - Expr::Constant(ExprConstant { value: Constant::Str(k), .. }), - Expr::Constant(ExprConstant { value: Constant::Str(v), .. }), - ) if k.as_str() == name => Some(v.clone()), + .find_map(|item| match (item.key.as_ref()?, &item.value) { + (Expr::StringLiteral(k), Expr::StringLiteral(v)) if k.value.to_str() == name => { + Some(v.value.to_str().to_string()) + } _ => None, }) } @@ -328,12 +340,13 @@ fn dict_str_value(dict: &rustpython_ast::ExprDict, name: &str) -> Option /// matches the TS object form and the `# on s3:///…` trigger form exactly. fn s3_object_arg_path(expr: &Expr) -> Option { let uri = match expr { - Expr::Constant(ExprConstant { value: Constant::Str(s), .. }) => { + Expr::StringLiteral(s) => { + let s = s.value.to_str(); match s .strip_prefix("s3://") .and_then(|rest| rest.split_once('/')) { - Some((_, key)) if !key.is_empty() => s.clone(), + Some((_, key)) if !key.is_empty() => s.to_string(), _ => return None, } } @@ -347,8 +360,9 @@ fn s3_object_arg_path(expr: &Expr) -> Option { if func_name != "S3Object" { return None; } - let key = keyword_str_value(&call.keywords, "s3")?; - let storage = keyword_str_value(&call.keywords, "storage").unwrap_or_default(); + let key = keyword_str_value(&call.arguments.keywords, "s3")?; + let storage = + keyword_str_value(&call.arguments.keywords, "storage").unwrap_or_default(); format!("s3://{storage}/{key}") } Expr::Dict(dict) => { @@ -822,4 +836,62 @@ def main(): },]) ); } + + #[test] + fn test_py_asset_parser_recent_syntax() { + let input = r#" +import wmill +lazy import polars + +type Rows = list[dict] + +def main[T](name: str): + try: + with wmill.load_s3_file_reader('s3:///in.csv') as f: + f.read() + except ValueError, TypeError: + pass + greeting = t"hello {name}" + return f"{wmill.get_resource("u/user/pg")["host"]}" +"#; + let s = parse_assets(input).map(|o| o.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ + ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/in.csv".to_string(), + access_type: Some(R), + columns: None, + }, + ParseAssetsResult { + kind: AssetKind::Resource, + path: "u/user/pg".to_string(), + access_type: None, + columns: None, + }, + ]) + ); + } + + #[test] + fn test_py_asset_parser_schema_qualified_db_without_table() { + let input = r#" +import wmill +def main(): + db = wmill.datatable('dt:sch') + db.query("ATTACH 'ducklake://other' AS o") + db.query("SELECT * FROM t") +"#; + let s = parse_assets(input).map(|o| o.assets); + assert_eq!( + s.map_err(|e| e.to_string()) + .map(|a| a.into_iter().map(|a| (a.kind, a.path)).collect::>()), + Ok(vec![ + (AssetKind::DataTable, "dt".to_string()), + (AssetKind::DataTable, "dt/sch.t".to_string()), + (AssetKind::Ducklake, "other".to_string()), + ]) + ); + } } diff --git a/backend/parsers/windmill-parser-py-imports/Cargo.toml b/backend/parsers/windmill-parser-py-imports/Cargo.toml index 565e1422bb..2ef83f5ac9 100644 --- a/backend/parsers/windmill-parser-py-imports/Cargo.toml +++ b/backend/parsers/windmill-parser-py-imports/Cargo.toml @@ -23,7 +23,8 @@ tracing.workspace = true [dependencies] windmill-parser.workspace = true -rustpython-parser.workspace = true +ruff_python_parser.workspace = true +ruff_python_ast.workspace = true phf.workspace = true itertools.workspace = true serde_json.workspace = true diff --git a/backend/parsers/windmill-parser-py-imports/src/lib.rs b/backend/parsers/windmill-parser-py-imports/src/lib.rs index de7f51bc79..5c8de9790f 100644 --- a/backend/parsers/windmill-parser-py-imports/src/lib.rs +++ b/backend/parsers/windmill-parser-py-imports/src/lib.rs @@ -13,9 +13,9 @@ use async_recursion::async_recursion; use itertools::Itertools; use lazy_static::lazy_static; #[cfg(not(target_arch = "wasm32"))] -use std::str::FromStr; -#[cfg(not(target_arch = "wasm32"))] use std::collections::HashMap; +#[cfg(not(target_arch = "wasm32"))] +use std::str::FromStr; use mapping::{FULL_IMPORTS_MAP, SHORT_IMPORTS_MAP}; #[cfg(not(target_arch = "wasm32"))] @@ -23,11 +23,8 @@ use regex::Regex; #[cfg(target_arch = "wasm32")] use regex_lite::Regex; -use rustpython_parser::{ - ast::{Stmt, StmtImport, StmtImportFrom, Suite}, - text_size::TextRange, - Parse, -}; +use ruff_python_ast::{Stmt, StmtImport, StmtImportFrom}; +use ruff_python_parser::parse_module; #[cfg(not(target_arch = "wasm32"))] use sqlx::{Pool, Postgres}; #[cfg(not(target_arch = "wasm32"))] @@ -160,6 +157,7 @@ pub struct ImportPin { } pub fn parse_code_for_imports(code: &str, path: &str) -> anyhow::Result> { + let full_code = code; // Use regex to safely find the main function definition let mut code = DEF_MAIN_RE .split(code) @@ -186,16 +184,22 @@ pub fn parse_code_for_imports(code: &str, path: &str) -> anyhow::Result (code, parsed.into_suite()), + // The split lands on any line reading like the definition of `main`, one inside a + // string included: what precedes it is then not a module, while the whole file is. + Err(e) => match parse_module(full_code) { + Ok(parsed) => (full_code.to_string(), parsed.into_suite()), + Err(_) => anyhow::bail!("Error parsing code for imports: {}", e.to_string()), + }, + }; // Note: We're still using the original code for finding pins, // as the TextRange values from the parsed AST would be based on code_with_fake_main // but we want to match against the original code - let find_pin = |range: TextRange, key: String| { + let find_pin = |stmt_end: usize, key: String| { let hs = code .chars() - .skip(range.end().to_usize()) + .skip(stmt_end) .take_while(|e| *e != '\n') .collect::(); @@ -226,9 +230,9 @@ pub fn parse_code_for_imports(code: &str, path: &str) -> anyhow::Result = ast .into_iter() .filter_map(|x| match x { - Stmt::Import(StmtImport { names, range }) => names + Stmt::Import(StmtImport { names, range, .. }) => names .get(0) - .and_then(|al| find_pin(range, al.name.to_string())) + .and_then(|al| find_pin(range.end().to_usize(), al.name.to_string())) .or(Some( names .into_iter() @@ -239,15 +243,11 @@ pub fn parse_code_for_imports(code: &str, path: &str) -> anyhow::Result>(), )), - Stmt::ImportFrom(StmtImportFrom { level: Some(i), module, .. }) if i.to_u32() > 0 => { - Some(process_import( - module.map(|x| x.to_string()), - path, - i.to_usize(), - )) - } + Stmt::ImportFrom(StmtImportFrom { level, module, .. }) if level > 0 => Some( + process_import(module.map(|x| x.to_string()), path, level as usize), + ), Stmt::ImportFrom(StmtImportFrom { level: _, module, range, .. }) => find_pin( - range, + range.end().to_usize(), module.clone().map(|x| x.to_string()).unwrap_or_default(), ) .or(Some(process_import(module.map(|x| x.to_string()), path, 0))), @@ -351,13 +351,16 @@ async fn parse_python_imports_inner( temp_script_refs: &Option>, ) -> error::Result> { tracing::debug!("Parsing python imports for path: {}", path); - let PythonAnnotations { py310, py311, py312, py313, .. } = PythonAnnotations::parse(&code); + let PythonAnnotations { py310, py311, py312, py313, py314, py315, .. } = + PythonAnnotations::parse(&code); tracing::debug!( - "Found python annotations - py310: {}, py311: {}, py312: {}, py313: {}", + "Found python annotations - py310: {}, py311: {}, py312: {}, py313: {}, py314: {}, py315: {}", py310, py311, py312, - py313 + py313, + py314, + py315 ); let mut push_version_specifiers = |perform, unparsed: String| -> error::Result<()> { @@ -373,6 +376,8 @@ async fn parse_python_imports_inner( push_version_specifiers(py311, "==3.11.*".to_owned())?; push_version_specifiers(py312, "==3.12.*".to_owned())?; push_version_specifiers(py313, "==3.13.*".to_owned())?; + push_version_specifiers(py314, "==3.14.*".to_owned())?; + push_version_specifiers(py315, "==3.15.*".to_owned())?; for x in code.lines() { if x.starts_with("# py:") || x.starts_with("#py:") { @@ -513,8 +518,14 @@ async fn parse_python_imports_inner( let mut nested = match n { NImport::Relative(rpath) => { // First try to get content from temp_script_refs cache if available - let code_from_cache = if let Some(hash) = temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath)) { - tracing::debug!("Found relative import '{}' in temp_script_refs with hash '{}'", rpath, hash); + let code_from_cache = if let Some(hash) = + temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath)) + { + tracing::debug!( + "Found relative import '{}' in temp_script_refs with hash '{}'", + rpath, + hash + ); match windmill_common::cache::raw_script_temp::load(hash.clone(), db).await { Ok(content) => Some(content), Err(e) => { @@ -529,19 +540,17 @@ async fn parse_python_imports_inner( // Use cached content if available, otherwise fall back to deployed script let code = match code_from_cache { Some(content) => content, - None => { - sqlx::query_scalar!( - r#" + None => sqlx::query_scalar!( + r#" SELECT content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1 "#, - &rpath, - w_id - ) - .fetch_optional(db) - .await? - .unwrap_or_else(|| "".to_string()) - } + &rpath, + w_id + ) + .fetch_optional(db) + .await? + .unwrap_or_else(|| "".to_string()), }; if already_visited.contains(&rpath) { diff --git a/backend/parsers/windmill-parser-py-imports/src/mapping.rs b/backend/parsers/windmill-parser-py-imports/src/mapping.rs index 6246a1f156..90f3fab99c 100644 --- a/backend/parsers/windmill-parser-py-imports/src/mapping.rs +++ b/backend/parsers/windmill-parser-py-imports/src/mapping.rs @@ -383,5 +383,10 @@ pub static SHORT_IMPORTS_MAP: PyMap = phf_map! { "vt" => "vt-py", "grpc" => "grpcio", "serpapi" => "google-search-results", + "pptx" => "python-pptx", + "OpenSSL" => "pyOpenSSL", + "win32api" => "pywin32", + "ruamel" => "ruamel.yaml", + "speech-recognition" => "SpeechRecognition", // Add new entry here ^ }; diff --git a/backend/parsers/windmill-parser-py-imports/tests/tests.rs b/backend/parsers/windmill-parser-py-imports/tests/tests.rs index c853c8e5b4..9086b29d47 100644 --- a/backend/parsers/windmill-parser-py-imports/tests/tests.rs +++ b/backend/parsers/windmill-parser-py-imports/tests/tests.rs @@ -1,7 +1,9 @@ mod tests { use sqlx::{Pool, Postgres}; - use windmill_parser_py_imports::parse_python_imports; + use windmill_parser_py_imports::{ + parse_code_for_imports, parse_python_imports, ImportPin, NImport, + }; #[sqlx::test(fixtures("base"))] async fn test_parse_python_imports(db: Pool) -> anyhow::Result<()> { @@ -117,4 +119,87 @@ def main(): Ok(()) } + + fn auto(pkg: &str) -> NImport { + NImport::Auto { pkg: pkg.to_string(), key: None } + } + + #[test] + fn test_parse_lazy_imports() -> anyhow::Result<()> { + let code = " +lazy import requests +lazy import numpy # pin: numpy==2.1.0 +lazy from pandas import DataFrame +lazy from f.shared import helper +lazy = 1 + +def main(): + pass +"; + assert_eq!( + parse_code_for_imports(code, "f/foo/bar")?, + vec![ + NImport::Pin { + pins: vec![ImportPin { + pkg: "numpy==2.1.0".to_string(), + path: "f/foo/bar".to_string() + }], + key: "numpy".to_string() + }, + auto("pandas"), + auto("requests"), + NImport::Relative("f/shared".to_string()), + ] + ); + Ok(()) + } + + #[test] + fn test_parse_imports_recent_syntax_before_main() -> anyhow::Result<()> { + let code = r#" +import requests + +type Alias = list[int] +NAME = "x" +D = {"a": 1} +L = [[1], [2]] +TEMPLATE = t"hello {NAME}" +PEP701 = f"{D["a"]} {'\n'.join(["x"])}" +PEP798 = [*a for a in L] + +try: + import ujson +except ImportError, ValueError: + pass + +def main(): + pass +"#; + assert_eq!( + parse_code_for_imports(code, "f/foo/bar")?, + vec![auto("requests")] + ); + Ok(()) + } + + #[test] + fn test_parse_imports_main_lookalike_in_docstring() -> anyhow::Result<()> { + for example in ["def main[T](x: T):", "def main(x):"] { + let code = format!( + "\"\"\"Usage example:\n{example}\n return x\n\"\"\"\nimport requests # pin: requests==2.32.0\n\ndef main(value: str):\n return value\n" + ); + assert_eq!( + parse_code_for_imports(&code, "f/foo/bar")?, + vec![NImport::Pin { + pins: vec![ImportPin { + pkg: "requests==2.32.0".to_string(), + path: "f/foo/bar".to_string() + }], + key: "requests".to_string() + }], + "{code}" + ); + } + Ok(()) + } } diff --git a/backend/parsers/windmill-parser-py/Cargo.toml b/backend/parsers/windmill-parser-py/Cargo.toml index 0c50e0f8d8..6971fe8fcd 100644 --- a/backend/parsers/windmill-parser-py/Cargo.toml +++ b/backend/parsers/windmill-parser-py/Cargo.toml @@ -10,8 +10,8 @@ path = "./src/lib.rs" [dependencies] windmill-parser.workspace = true -rustpython-parser.workspace = true +ruff_python_parser.workspace = true +ruff_python_ast.workspace = true itertools.workspace = true serde_json.workspace = true anyhow.workspace = true -rustpython-ast = { version = "0.4.0", features = ["visitor"] } diff --git a/backend/parsers/windmill-parser-py/src/lib.rs b/backend/parsers/windmill-parser-py/src/lib.rs index 086a354d02..5a9eabc33e 100644 --- a/backend/parsers/windmill-parser-py/src/lib.rs +++ b/backend/parsers/windmill-parser-py/src/lib.rs @@ -13,13 +13,11 @@ use itertools::Itertools; use serde_json::json; use windmill_parser::{json_to_typ, Arg, MainArgSignature, ObjectType, Typ}; -use rustpython_parser::{ - ast::{ - Constant, Expr, ExprAttribute, ExprConstant, ExprDict, ExprList, ExprName, Stmt, - StmtAssign, StmtAsyncFunctionDef, StmtClassDef, StmtFunctionDef, Suite, - }, - Parse, +use ruff_python_ast::{ + Expr, ExprAttribute, ExprDict, ExprList, ExprName, Number, Parameters, Stmt, StmtAssign, + StmtClassDef, StmtFunctionDef, }; +use ruff_python_parser::parse_module; pub mod pydantic_parser; @@ -81,22 +79,24 @@ fn should_parse_for_models(code: &str) -> bool { || code.contains("import dataclasses") } +/// What follows `def ` or `async def ` on a line opening that function's +/// parameters or type parameters. +fn strip_def<'a>(line: &'a str, name: &str) -> Option<&'a str> { + let rest = line + .strip_prefix("async def ") + .or_else(|| line.strip_prefix("def "))? + .strip_prefix(name)?; + (rest.starts_with('(') || rest.starts_with('[')).then_some(rest) +} + fn filter_non_main(code: &str, main_name: &str) -> String { - let def_main = format!("def {}(", main_name); - let async_def_main = format!("async def {}(", main_name); let mut filtered_code = String::new(); let mut code_iter = code.split("\n"); let mut remaining: String = String::new(); while let Some(line) = code_iter.next() { - if line.starts_with(&async_def_main) { - filtered_code += &async_def_main; - remaining += line.strip_prefix(&async_def_main).unwrap(); - remaining += "\n"; - remaining += &code_iter.join("\n"); - break; - } else if line.starts_with(&def_main) { - filtered_code += &def_main; - remaining += line.strip_prefix(&def_main).unwrap(); + if let Some(rest) = strip_def(line, main_name) { + filtered_code += &line[..line.len() - rest.len()]; + remaining += rest; remaining += "\n"; remaining += &code_iter.join("\n"); break; @@ -106,6 +106,26 @@ fn filter_non_main(code: &str, main_name: &str) -> String { return String::new(); } let mut chars = remaining.chars(); + + // PEP 695 type parameters, `def main[T](x: T)`, sit between the name and the parameters. + if remaining.starts_with('[') { + let mut open_brackets = 0; + for c in chars.by_ref() { + if c == '[' { + open_brackets += 1; + } else if c == ']' { + open_brackets -= 1; + } + filtered_code.push(c); + if open_brackets == 0 { + break; + } + } + } + if chars.next() != Some('(') { + return String::new(); + } + filtered_code.push('('); let mut open_parens = 1; while let Some(c) = chars.next() { @@ -183,15 +203,15 @@ fn extract_code_metadata(code: &str, main_name: &str) -> CodeMetadata { let filtered_code = filter_relevant_statements(code); - let ast = match Suite::parse(&filtered_code, "main.py") { - Ok(ast) => ast, + let ast = match parse_module(&filtered_code) { + Ok(parsed) => parsed.into_suite(), Err(_) => return CodeMetadata { enums, descriptions }, }; for stmt in ast { match stmt { - Stmt::ClassDef(StmtClassDef { name, body, bases, .. }) if has_enum => { - let is_enum = bases.iter().any(|base| { + Stmt::ClassDef(class_def) if has_enum => { + let is_enum = class_def.bases().iter().any(|base| { matches!(base, Expr::Name(ExprName { id, .. }) if id == "Enum" || id == "IntEnum" || id == "StrEnum" || id == "Flag" || id == "IntFlag") @@ -201,17 +221,15 @@ fn extract_code_metadata(code: &str, main_name: &str) -> CodeMetadata { let mut values = Vec::new(); let mut members = HashMap::new(); + let StmtClassDef { name, body, .. } = class_def; for item in body { if let Stmt::Assign(StmtAssign { targets, value, .. }) = item { if let Some(Expr::Name(ExprName { id: target_name, .. })) = targets.first() { if !target_name.starts_with('_') { - if let Expr::Constant(ExprConstant { - value: Constant::Str(val), - .. - }) = value.as_ref() - { + if let Expr::StringLiteral(val) = value.as_ref() { + let val = val.value.to_str(); values.push(val.to_string()); members.insert(target_name.to_string(), val.to_string()); } @@ -225,15 +243,13 @@ fn extract_code_metadata(code: &str, main_name: &str) -> CodeMetadata { } } } - Stmt::FunctionDef(StmtFunctionDef { name: func_name, body, .. }) if has_docstring => { - if &func_name == main_name { + Stmt::FunctionDef(StmtFunctionDef { + name: func_name, body, is_async: false, .. + }) if has_docstring => { + if func_name.as_str() == main_name { if let Some(Stmt::Expr(expr_stmt)) = body.first() { - if let Expr::Constant(ExprConstant { - value: Constant::Str(docstring), - .. - }) = expr_stmt.value.as_ref() - { - descriptions = parse_docstring_args(docstring); + if let Expr::StringLiteral(docstring) = expr_stmt.value.as_ref() { + descriptions = parse_docstring_args(docstring.value.to_str()); } } } @@ -294,6 +310,27 @@ fn parse_docstring_args(docstring: &str) -> HashMap { descriptions } +/// A line that reads as the definition of `main_name` can also sit inside a string, a docstring +/// showing an example for instance. When several lines do, only the parsed module tells which +/// one is the function. +fn main_params_among_lookalikes(code: &str, main_name: &str) -> Option { + code.split('\n') + .filter(|line| strip_def(line, main_name).is_some()) + .nth(1)?; + parse_module(code) + .ok()? + .into_suite() + .into_iter() + .find_map(|x| match x { + Stmt::FunctionDef(StmtFunctionDef { name, parameters, .. }) + if name.as_str() == main_name => + { + Some(*parameters) + } + _ => None, + }) +} + /// skip_params is a micro optimization for when we just want to find the main /// function without parsing all the params. pub fn parse_python_signature( @@ -310,41 +347,39 @@ pub fn parse_python_signature( // - If no models: parse only the filtered main function let (params, module) = if should_parse_for_models(code) { // Parse full code once for both Pydantic detection and signature extraction - let ast = Suite::parse(code, "main.py") - .map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))?; + let ast = parse_module(code) + .map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))? + .into_suite(); // Extract main function from full AST let params = ast.iter().find_map(|x| match x { - Stmt::FunctionDef(StmtFunctionDef { name, args, .. }) if name == &main_name => { - Some(args.as_ref().clone()) - } - Stmt::AsyncFunctionDef(StmtAsyncFunctionDef { name, args, .. }) - if name == &main_name => + Stmt::FunctionDef(StmtFunctionDef { name, parameters, .. }) + if name.as_str() == main_name => { - Some(args.as_ref().clone()) + Some(parameters.as_ref().clone()) } _ => None, }); // Keep AST for Pydantic/dataclass detection (params, Some(ast)) + } else if let Some(params) = main_params_among_lookalikes(code, &main_name) { + (Some(params), None) } else { // No models detected - parse only the filtered main function let filtered_code = filter_non_main(code, &main_name); if filtered_code.is_empty() { (None, None) } else { - let ast = Suite::parse(&filtered_code, "main.py") - .map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))?; + let ast = parse_module(&filtered_code) + .map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))? + .into_suite(); let params = ast.into_iter().find_map(|x| match x { - Stmt::FunctionDef(StmtFunctionDef { name, args, .. }) if &name == &main_name => { - Some(*args) - } - Stmt::AsyncFunctionDef(StmtAsyncFunctionDef { name, args, .. }) - if &name == &main_name => + Stmt::FunctionDef(StmtFunctionDef { name, parameters, .. }) + if name.as_str() == main_name => { - Some(*args) + Some(*parameters) } _ => None, }); @@ -378,8 +413,6 @@ pub fn parse_python_signature( if !skip_params && params.is_some() { let params = params.unwrap(); - let def_arg_start = params.args.len() - params.defaults().count(); - // Two-pass approach for lazy metadata extraction: // Pass 1: Parse types without enum info to determine if metadata is needed // Pass 2: Re-parse unknown types with metadata only if necessary @@ -392,11 +425,11 @@ pub fn parse_python_signature( .iter() .enumerate() .map(|(i, x)| { - let arg_name = x.as_arg().arg.to_string(); + let arg_name = x.parameter.name.to_string(); let (typ, has_default) = x - .as_arg() + .parameter .annotation - .as_ref() + .as_deref() .map_or((Typ::Unknown, false), |e| { parse_expr(e, &empty_enums, module_ref) }); @@ -423,21 +456,16 @@ pub fn parse_python_signature( .into_iter() .map(|(i, arg_name, mut typ, mut has_default)| { if matches!(typ, Typ::Resource(_)) && !metadata.enums.is_empty() { - if let Some(annotation) = params.args[i].as_arg().annotation.as_ref() { + if let Some(annotation) = params.args[i].parameter.annotation.as_deref() { (typ, has_default) = parse_expr(annotation, &metadata.enums, module_ref); } } - let default = if i >= def_arg_start { - params - .defaults() - .nth(i - def_arg_start) - .map(|expr| to_value(expr, &metadata.enums)) - .flatten() - } else { - None - }; + let default = params.args[i] + .default + .as_deref() + .and_then(|expr| to_value(expr, &metadata.enums)); let should_get_type_from_default = match &typ { Typ::Unknown => true, @@ -465,10 +493,10 @@ pub fn parse_python_signature( // debounce accumulation support. Falls back to docstring // description if no union array pattern is found. let union_otyp = params.args[i] - .as_arg() + .parameter .annotation - .as_ref() - .and_then(|ann| detect_py_union_array_otyp(ann.as_ref())); + .as_deref() + .and_then(detect_py_union_array_otyp); Arg { otyp: union_otyp.or_else(|| { @@ -509,12 +537,8 @@ pub fn parse_python_signature( } } -fn parse_expr( - e: &Box, - enums: &HashMap, - module: Option<&[Stmt]>, -) -> (Typ, bool) { - match e.as_ref() { +fn parse_expr(e: &Expr, enums: &HashMap, module: Option<&[Stmt]>) -> (Typ, bool) { + match e { Expr::Name(ExprName { id, .. }) => (parse_typ(id.as_ref(), enums, module), false), Expr::Attribute(x) => { if let Some(name) = x.value.as_name_expr() { @@ -531,12 +555,9 @@ fn parse_expr( } } Expr::BinOp(x) => { - if matches!( - x.right.as_ref(), - Expr::Constant(ExprConstant { value: Constant::None, .. }) - ) { + if x.right.is_none_literal_expr() { (parse_expr(&x.left, enums, module).0, true) - } else if detect_py_union_array_otyp(e.as_ref()).is_some() { + } else if detect_py_union_array_otyp(e).is_some() { // T | list[T] — parsed type is Unknown; otyp is set separately (Typ::Unknown, false) } else { @@ -552,7 +573,7 @@ fn parse_expr( .elts .iter() .map(|x| match x { - Expr::Constant(c) => c.value.as_str().map(|x| x.to_string()), + Expr::StringLiteral(s) => Some(s.value.to_str().to_string()), _ => None, }) .filter_map(|x| x) @@ -625,15 +646,26 @@ fn map_resource_name(x: &str) -> String { } } -fn to_value(et: &Expr, enums: &HashMap) -> Option { +fn to_value(et: &Expr, enums: &HashMap) -> Option { match et { - Expr::Constant(ExprConstant { value, .. }) => Some(constant_to_value(value)), - Expr::Dict(ExprDict { keys, values, .. }) => { - let v = keys - .into_iter() - .zip(values) - .map(|(k, v)| { - let key = k + Expr::NoneLiteral(_) => Some(json!(null)), + Expr::BooleanLiteral(b) => Some(json!(b.value)), + Expr::StringLiteral(s) => Some(json!(s.value.to_str())), + Expr::BytesLiteral(b) => Some(json!(b.value.bytes().collect::>())), + Expr::NumberLiteral(n) => Some(match &n.value { + Number::Int(i) => { + serde_json::from_str(&i.to_string()).unwrap_or(json!("invalid number")) + } + Number::Float(f) => json!(f), + Number::Complex { real, imag } => json!([real, imag]), + }), + Expr::EllipsisLiteral(_) => Some(json!("...")), + Expr::Dict(ExprDict { items, .. }) => { + let v = items + .iter() + .map(|item| { + let key = item + .key .as_ref() .map(|e| to_value(e, enums)) .flatten() @@ -642,7 +674,7 @@ fn to_value(et: &Expr, enums: &HashMap) -> Option None, }) .unwrap_or_else(|| "no_key".to_string()); - (key, to_value(&v, enums)) + (key, to_value(&item.value, enums)) }) .collect::>(); Some(json!(v)) @@ -672,20 +704,6 @@ fn to_value(et: &Expr, enums: &HashMap) -> Option serde_json::Value { - match c { - Constant::None => json!(null), - Constant::Bool(b) => json!(b), - Constant::Str(s) => json!(s), - Constant::Bytes(b) => json!(b), - Constant::Int(i) => serde_json::from_str(&i.to_string()).unwrap_or(json!("invalid number")), - Constant::Tuple(t) => json!(t.iter().map(constant_to_value).collect::>()), - Constant::Float(f) => json!(f), - Constant::Complex { real, imag } => json!([real, imag]), - Constant::Ellipsis => json!("..."), - } -} - #[cfg(test)] mod tests { @@ -1323,4 +1341,89 @@ def main(color: Color = Color.RED): ); Ok(()) } + + #[test] + fn test_parse_python_sig_type_params() -> anyhow::Result<()> { + for code in [ + "def main[T](x: str, y: int = 3):\n return x\n", + "async def main[T: (int, str), *Ts](x: str, y: int = 3):\n return x\n", + // parsed whole rather than pre-filtered down to `main` + "from dataclasses import dataclass\n\ndef main[T](x: str, y: int = 3):\n return x\n", + ] { + let result = parse_python_signature(code, None, false)?; + assert_eq!(result.auto_kind, None, "{code}"); + assert_eq!( + result + .args + .iter() + .map(|a| (a.name.as_str(), &a.typ, &a.default)) + .collect::>(), + vec![ + ("x", &Typ::Str(None), &None), + ("y", &Typ::Int, &Some(json!(3))) + ], + "{code}" + ); + } + Ok(()) + } + + // A script with a model class is parsed whole, so syntax anywhere in it reaches the parser. + #[test] + fn test_parse_python_sig_recent_syntax() -> anyhow::Result<()> { + let code = r#" +from dataclasses import dataclass +lazy import requests +lazy from pandas import DataFrame + +type Alias = list[int] + +@dataclass +class Point: + x: int + +try: + import a +except ImportError, ValueError: + pass + +D = {"a": 1} +L = [[1], [2]] +PEP701 = f"{D["a"]} {'\n'.join(["x"])}" +PEP798 = [*a for a in L] + +def main(p: Point, name: str = "world"): + return t"hello {name}" +"#; + let result = parse_python_signature(code, None, false)?; + assert_eq!(result.args.len(), 2); + assert_eq!( + result.args[0].typ, + Typ::Object(ObjectType::new( + Some("Point".to_string()), + Some(vec![windmill_parser::ObjectProperty { + key: "x".to_string(), + typ: Box::new(Typ::Int) + }]) + )) + ); + assert_eq!(result.args[1].default, Some(json!("world"))); + Ok(()) + } + + #[test] + fn test_parse_python_sig_main_lookalike_in_docstring() -> anyhow::Result<()> { + for example in ["def main[T](x: T):", "def main(x: int, y: int):"] { + let code = format!( + "\"\"\"Usage example:\n{example}\n return x\n\"\"\"\n\ndef main(value: str):\n return value\n" + ); + let result = parse_python_signature(&code, None, false)?; + assert_eq!( + result.args.iter().map(|a| &a.name).collect::>(), + vec!["value"], + "{code}" + ); + } + Ok(()) + } } diff --git a/backend/parsers/windmill-parser-py/src/pydantic_parser.rs b/backend/parsers/windmill-parser-py/src/pydantic_parser.rs index e229d97a8a..78a0a3c354 100644 --- a/backend/parsers/windmill-parser-py/src/pydantic_parser.rs +++ b/backend/parsers/windmill-parser-py/src/pydantic_parser.rs @@ -11,8 +11,8 @@ //! This module provides functionality to detect and parse Pydantic models and Python //! dataclasses from Python AST, enabling automatic UI generation for complex data structures. -use rustpython_parser::ast::{ - Constant, Expr, ExprAttribute, ExprCall, ExprConstant, ExprName, ExprTuple, Stmt, StmtAnnAssign, +use ruff_python_ast::{ + Decorator, Expr, ExprAttribute, ExprCall, ExprName, ExprTuple, Stmt, StmtAnnAssign, }; use std::collections::HashSet; use windmill_parser::{ObjectProperty, ObjectType, Typ}; @@ -64,7 +64,7 @@ fn detect_model_type_impl( // Mark as being visited visited.insert(class_name.to_string()); - let result = if is_pydantic_base(&class_def.bases) { + let result = if is_pydantic_base(class_def.bases()) { // Pydantic BaseModel parse_model_fields(&class_def.body, class_def.name.as_str(), module, visited) } else if has_dataclass_decorator(&class_def.decorator_list) @@ -115,9 +115,9 @@ fn is_pydantic_base(bases: &[Expr]) -> bool { } /// Checks if a class has @dataclass decorator (standard library) -fn has_dataclass_decorator(decorators: &[Expr]) -> bool { +fn has_dataclass_decorator(decorators: &[Decorator]) -> bool { for decorator in decorators { - match decorator { + match &decorator.expression { // Match: @dataclass Expr::Name(ExprName { id, .. }) if id.as_str() == "dataclass" => { return true; @@ -160,9 +160,9 @@ fn has_dataclass_decorator(decorators: &[Expr]) -> bool { } /// Checks if a class has @pydantic.dataclasses.dataclass decorator (Pydantic v2) -fn has_pydantic_dataclass_decorator(decorators: &[Expr]) -> bool { +fn has_pydantic_dataclass_decorator(decorators: &[Decorator]) -> bool { for decorator in decorators { - match decorator { + match &decorator.expression { // Match: @pydantic.dataclasses.dataclass Expr::Attribute(ExprAttribute { attr, value, .. }) if attr.as_str() == "dataclass" => { if let Expr::Attribute(ExprAttribute { @@ -350,10 +350,10 @@ fn extract_field_type( // String annotations: "ForwardRef" (forward references) // Not fully supported - return Unknown with warning - Expr::Constant(ExprConstant { value: Constant::Str(s), .. }) => { + Expr::StringLiteral(s) => { eprintln!( "Forward references like \"{forward_ref}\" are not yet supported, treating as Unknown", - forward_ref = s, + forward_ref = s.value.to_str(), ); Typ::Unknown } diff --git a/backend/parsers/windmill-parser-sql-asset/src/asset_parser_utils.rs b/backend/parsers/windmill-parser-sql-asset/src/asset_parser_utils.rs index b29c6c99ac..4d77da15bb 100644 --- a/backend/parsers/windmill-parser-sql-asset/src/asset_parser_utils.rs +++ b/backend/parsers/windmill-parser-sql-asset/src/asset_parser_utils.rs @@ -20,13 +20,16 @@ pub fn parse_wmill_sdk_sql_assets( Ok(mut sql_assets) => { if let Some(schema) = schema { for asset in &mut sql_assets.assets { - if asset.kind == kind && asset.path.starts_with(asset_name) { - asset.path = format!( - "{}/{}.{}", - asset_name, - schema, - &asset.path[asset_name.len() + 1..] - ); + if asset.kind != kind { + continue; + } + // A statement can name the database alone, with no table to qualify. + let table = asset + .path + .strip_prefix(asset_name) + .and_then(|rest| rest.strip_prefix('/')); + if let Some(table) = table { + asset.path = format!("{asset_name}/{schema}.{table}"); } } } diff --git a/backend/parsers/windmill-parser-sql/src/lib.rs b/backend/parsers/windmill-parser-sql/src/lib.rs index 10929e409a..341906a3e3 100644 --- a/backend/parsers/windmill-parser-sql/src/lib.rs +++ b/backend/parsers/windmill-parser-sql/src/lib.rs @@ -235,7 +235,7 @@ lazy_static::lazy_static! { // -- $name (type) = default static ref RE_ARG_DUCKDB: Regex = Regex::new(r#"(?m)^-- \$(\w+) \(([A-Za-z0-9_\[\]]+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); - static ref RE_ARG_SNOWFLAKE: Regex = Regex::new(r#"(?m)^-- \? (\w+) \((\w+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); + static ref RE_ARG_SNOWFLAKE: Regex = Regex::new(r#"(?m)^-- \? (\w+) \((\w+(?:\[\])?)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); static ref RE_ARG_MSSQL: Regex = Regex::new(r#"(?m)^-- @(?:P|p)\d+ (\w+) \((\w+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); @@ -254,6 +254,12 @@ fn parsed_default(parsed_typ: &Typ, default: String) -> Option= 2 && default.starts_with("'") && default.ends_with("'") => { Some(json!(&default[1..default.len() - 1])) } + Typ::List(_) => Some( + serde_json::from_str::(&default) + .ok() + .filter(|v| v.is_array()) + .unwrap_or_else(|| json!(default)), + ), _ => Some(json!(default)), } } @@ -1039,7 +1045,11 @@ pub fn parse_duckdb_typ(typ: &str) -> Typ { } pub fn parse_snowflake_typ(typ: &str) -> Typ { + if let Some(base_typ) = typ.strip_suffix("[]") { + return Typ::List(Box::new(parse_snowflake_typ(base_typ))); + } match typ { + "array" => Typ::List(Box::new(Typ::Unknown)), "varchar" | "text" | "string" | "char" | "character" => Typ::Str(None), "binary" | "varbinary" => Typ::Bytes, "date" | "time" | "datetime" | "timestamp" | "timestamp_ntz" | "timestamp_ltz" @@ -1591,6 +1601,39 @@ SELECT ?; Ok(()) } + #[test] + fn test_parse_snowflake_array_types() -> anyhow::Result<()> { + let code = r#" +-- ? groups (varchar[]) +-- ? ids (int[]) = [1, 2] +-- ? anything (ARRAY) +SELECT 1; +"#; + let args = parse_snowflake_sig(code)?.args; + assert_eq!(args[1].default, Some(json!([1, 2]))); + let got = args + .iter() + .map(|a| (a.name.as_str(), a.otyp.as_deref(), &a.typ)) + .collect::>(); + assert_eq!( + got, + vec![ + ( + "groups", + Some("varchar[]"), + &Typ::List(Box::new(Typ::Str(None))) + ), + ("ids", Some("int[]"), &Typ::List(Box::new(Typ::Int))), + ( + "anything", + Some("array"), + &Typ::List(Box::new(Typ::Unknown)) + ), + ] + ); + Ok(()) + } + #[test] fn test_parse_mssql_sig() -> anyhow::Result<()> { let code = r#" diff --git a/backend/parsers/windmill-parser-wac/Cargo.toml b/backend/parsers/windmill-parser-wac/Cargo.toml index d354b1c653..21ee286db9 100644 --- a/backend/parsers/windmill-parser-wac/Cargo.toml +++ b/backend/parsers/windmill-parser-wac/Cargo.toml @@ -9,8 +9,9 @@ name = "windmill_parser_wac" path = "./src/lib.rs" [dependencies] -rustpython-parser.workspace = true -rustpython-ast = { version = "0.4.0", features = ["visitor"] } +ruff_python_parser.workspace = true +ruff_python_ast.workspace = true +ruff_text_size.workspace = true swc_common.workspace = true swc_ecma_parser.workspace = true swc_ecma_ast.workspace = true diff --git a/backend/parsers/windmill-parser-wac/src/python.rs b/backend/parsers/windmill-parser-wac/src/python.rs index f91067b711..e8fb72ff2f 100644 --- a/backend/parsers/windmill-parser-wac/src/python.rs +++ b/backend/parsers/windmill-parser-wac/src/python.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; -use rustpython_parser::{ - ast::{ - Expr, ExprAwait, ExprCall, ExprName, Stmt, StmtExpr, StmtFor, StmtIf, StmtReturn, StmtTry, - StmtTryStar, StmtWhile, - }, - Parse, +use ruff_python_ast::{ + CmpOp, ElifElseClause, ExceptHandler, Expr, ExprAttribute, ExprAwait, ExprCall, ExprName, + Number, Parameters, Stmt, StmtExpr, StmtFor, StmtFunctionDef, StmtIf, StmtReturn, StmtTry, + StmtWhile, }; +use ruff_python_parser::parse_module; +use ruff_text_size::{Ranged, TextSize}; use crate::dag::{DagEdge, DagNode, DagNodeType, Param, WorkflowDag}; use crate::validation::{self, CompileError}; @@ -42,9 +42,9 @@ type TaskFunctions = HashMap>; fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions { let mut tasks = HashMap::new(); for stmt in stmts { - if let Stmt::AsyncFunctionDef(func) = stmt { + if let Stmt::FunctionDef(func @ StmtFunctionDef { is_async: true, .. }) = stmt { for dec in &func.decorator_list { - match dec { + match &dec.expression { // @task (bare decorator) Expr::Name(ExprName { id, .. }) if id.as_str() == "task" => { tasks.insert(func.name.to_string(), None); @@ -68,13 +68,9 @@ fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions { if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() { if id.as_str() == "task_script" || id.as_str() == "task_flow" { // Extract the path from the first positional argument - let path = call.args.first().and_then(|arg| { - if let Expr::Constant(c) = arg { - if let rustpython_parser::ast::Constant::Str(s) = &c.value { - return Some(s.to_string()); - } - } - None + let path = call.arguments.args.first().and_then(|arg| match arg { + Expr::StringLiteral(s) => Some(s.value.to_str().to_string()), + _ => None, }); // Extract variable name from target if let Some(Expr::Name(ExprName { id: var_name, .. })) = @@ -92,13 +88,11 @@ fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions { /// Extract the `path=` keyword argument from a `@task(path="...")` call. fn extract_task_path_kwarg(call: &ExprCall) -> Option { - for kw in &call.keywords { + for kw in &call.arguments.keywords { if let Some(ref arg) = kw.arg { if arg.as_str() == "path" { - if let Expr::Constant(c) = &kw.value { - if let rustpython_parser::ast::Constant::Str(s) = &c.value { - return Some(s.to_string()); - } + if let Expr::StringLiteral(s) = &kw.value { + return Some(s.value.to_str().to_string()); } } } @@ -150,10 +144,9 @@ impl WacWalker { fn line_of_expr(&self, expr: &Expr) -> usize { let offset = match expr { - Expr::Call(c) => c.range.start().to_usize(), - Expr::Await(a) => a.range.start().to_usize(), - Expr::Attribute(a) => a.range.start().to_usize(), - Expr::Name(n) => n.range.start().to_usize(), + Expr::Call(_) | Expr::Await(_) | Expr::Attribute(_) | Expr::Name(_) => { + expr.start().to_usize() + } _ => 0, }; self.line_index.line_of(offset) @@ -161,17 +154,15 @@ impl WacWalker { fn line_of_stmt(&self, stmt: &Stmt) -> usize { let offset = match stmt { - Stmt::If(s) => s.range.start().to_usize(), - Stmt::For(s) => s.range.start().to_usize(), - Stmt::While(s) => s.range.start().to_usize(), - Stmt::Return(s) => s.range.start().to_usize(), - Stmt::Expr(s) => s.range.start().to_usize(), - Stmt::Try(s) => s.range.start().to_usize(), - Stmt::TryStar(s) => s.range.start().to_usize(), - Stmt::Assign(s) => s.range.start().to_usize(), - Stmt::AnnAssign(s) => s.range.start().to_usize(), - Stmt::FunctionDef(s) => s.range.start().to_usize(), - Stmt::AsyncFunctionDef(s) => s.range.start().to_usize(), + Stmt::If(_) + | Stmt::For(_) + | Stmt::While(_) + | Stmt::Return(_) + | Stmt::Expr(_) + | Stmt::Try(_) + | Stmt::Assign(_) + | Stmt::AnnAssign(_) + | Stmt::FunctionDef(_) => stmt.start().to_usize(), _ => 0, }; self.line_index.line_of(offset) @@ -190,9 +181,7 @@ impl WacWalker { /// Check if an expression is `asyncio.gather(...)` call fn is_asyncio_gather_call(expr: &Expr) -> bool { if let Expr::Call(call) = expr { - if let Expr::Attribute(rustpython_parser::ast::ExprAttribute { value, attr, .. }) = - call.func.as_ref() - { + if let Expr::Attribute(ExprAttribute { value, attr, .. }) = call.func.as_ref() { if attr.as_str() == "gather" { if let Expr::Name(ExprName { id, .. }) = value.as_ref() { return id.as_str() == "asyncio"; @@ -222,20 +211,20 @@ impl WacWalker { fn expr_to_source(expr: &Expr) -> String { match expr { Expr::Compare(c) => { - let left = Self::expr_to_source(&c.left); - if let Some(comparator) = c.comparators.first() { + let left = Self::expr_to_source(&c.operands[0]); + if let Some(comparator) = c.comparators().first() { let right = Self::expr_to_source(comparator); let op = match c.ops.first() { - Some(rustpython_parser::ast::CmpOp::Gt) => ">", - Some(rustpython_parser::ast::CmpOp::Lt) => "<", - Some(rustpython_parser::ast::CmpOp::GtE) => ">=", - Some(rustpython_parser::ast::CmpOp::LtE) => "<=", - Some(rustpython_parser::ast::CmpOp::Eq) => "==", - Some(rustpython_parser::ast::CmpOp::NotEq) => "!=", - Some(rustpython_parser::ast::CmpOp::In) => "in", - Some(rustpython_parser::ast::CmpOp::NotIn) => "not in", - Some(rustpython_parser::ast::CmpOp::Is) => "is", - Some(rustpython_parser::ast::CmpOp::IsNot) => "is not", + Some(CmpOp::Gt) => ">", + Some(CmpOp::Lt) => "<", + Some(CmpOp::GtE) => ">=", + Some(CmpOp::LtE) => "<=", + Some(CmpOp::Eq) => "==", + Some(CmpOp::NotEq) => "!=", + Some(CmpOp::In) => "in", + Some(CmpOp::NotIn) => "not in", + Some(CmpOp::Is) => "is", + Some(CmpOp::IsNot) => "is not", None => "?", }; format!("{left} {op} {right}") @@ -253,14 +242,14 @@ impl WacWalker { format!("{value}.{}", a.attr) } Expr::Name(n) => n.id.to_string(), - Expr::Constant(c) => match &c.value { - rustpython_parser::ast::Constant::Str(s) => format!("\"{s}\""), - rustpython_parser::ast::Constant::Int(i) => i.to_string(), - rustpython_parser::ast::Constant::Float(f) => f.to_string(), - rustpython_parser::ast::Constant::Bool(b) => b.to_string(), - rustpython_parser::ast::Constant::None => "None".to_string(), - _ => "...".to_string(), + Expr::StringLiteral(s) => format!("\"{}\"", s.value.to_str()), + Expr::NumberLiteral(n) => match &n.value { + Number::Int(i) => i.to_string(), + Number::Float(f) => f.to_string(), + Number::Complex { .. } => "...".to_string(), }, + Expr::BooleanLiteral(b) => b.value.to_string(), + Expr::NoneLiteral(_) => "None".to_string(), _ => "...".to_string(), } } @@ -279,8 +268,16 @@ impl WacWalker { match stmt { Stmt::Expr(StmtExpr { value, .. }) => self.expr_contains_step(value), Stmt::Assign(a) => self.expr_contains_step(&a.value), - Stmt::If(s) => self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse), - Stmt::For(s) => self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse), + Stmt::If(s) => { + self.body_contains_step(&s.body) + || s.elif_else_clauses + .iter() + .any(|clause| self.body_contains_step(&clause.body)) + } + // `async for` is not modelled: its body is skipped like any unsupported statement + Stmt::For(s) if !s.is_async => { + self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse) + } Stmt::While(s) => { self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse) } @@ -289,19 +286,7 @@ impl WacWalker { || self.body_contains_step(&s.orelse) || self.body_contains_step(&s.finalbody) || s.handlers.iter().any(|h| match h { - rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => { - self.body_contains_step(&eh.body) - } - }) - } - Stmt::TryStar(s) => { - self.body_contains_step(&s.body) - || self.body_contains_step(&s.orelse) - || self.body_contains_step(&s.finalbody) - || s.handlers.iter().any(|h| match h { - rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => { - self.body_contains_step(&eh.body) - } + ExceptHandler::ExceptHandler(eh) => self.body_contains_step(&eh.body), }) } Stmt::Return(_) => false, @@ -319,11 +304,12 @@ impl WacWalker { match expr { Expr::Await(ExprAwait { value, .. }) => self.expr_contains_step(value), Expr::Call(call) => { - if self.is_task_fn_call(&Expr::Call(call.clone())) { - return true; - } - if Self::is_asyncio_gather_call(&Expr::Call(call.clone())) { - return call.args.iter().any(|a| self.expr_contains_step(a)); + if Self::is_asyncio_gather_call(expr) { + return call + .arguments + .args + .iter() + .any(|a| self.expr_contains_step(a)); } false } @@ -370,12 +356,11 @@ impl WacWalker { Stmt::Expr(StmtExpr { value, .. }) => self.walk_expr_stmt(value), Stmt::Assign(a) => self.walk_expr_stmt(&a.value), Stmt::If(if_stmt) => self.walk_if(if_stmt), - Stmt::For(for_stmt) => self.walk_for(for_stmt), + Stmt::For(for_stmt) if !for_stmt.is_async => self.walk_for(for_stmt), Stmt::While(while_stmt) => self.walk_while(while_stmt), Stmt::Try(try_stmt) => self.walk_try(try_stmt), - Stmt::TryStar(try_stmt) => self.walk_try_star(try_stmt), Stmt::Return(ret) => self.walk_return(ret), - Stmt::FunctionDef(_) | Stmt::AsyncFunctionDef(_) => { + Stmt::FunctionDef(_) => { if self.stmt_contains_step(stmt) { self.errors.push(validation::error_step_in_nested_function( self.line_of_stmt(stmt), @@ -392,7 +377,7 @@ impl WacWalker { if let Expr::Await(ExprAwait { value, .. }) = expr { // await task_fn(...) if let Expr::Call(call) = value.as_ref() { - if self.is_task_fn_call(&Expr::Call(call.clone())) { + if self.is_task_fn_call(value) { return self.emit_step(call, expr); } // Check for SDK-level calls: step(), sleep(), wait_for_approval() @@ -430,15 +415,12 @@ impl WacWalker { "step" => { // step("name", fn) — extract the name from the first string argument let name = call + .arguments .args .first() - .and_then(|arg| { - if let Expr::Constant(c) = arg { - if let rustpython_parser::ast::Constant::Str(s) = &c.value { - return Some(s.to_string()); - } - } - None + .and_then(|arg| match arg { + Expr::StringLiteral(s) => Some(s.value.to_str().to_string()), + _ => None, }) .unwrap_or_else(|| "step".to_string()); let id = self.next_id(); @@ -452,6 +434,7 @@ impl WacWalker { } "sleep" => { let seconds = call + .arguments .args .first() .map(|arg| Self::expr_to_source(arg)) @@ -517,10 +500,10 @@ impl WacWalker { }); let mut step_ids = Vec::new(); - for arg in &gather_call.args { + for arg in &gather_call.arguments.args { // Each arg should be task_fn(...) if let Expr::Call(call) = arg { - if self.is_task_fn_call(&Expr::Call(call.clone())) { + if self.is_task_fn_call(arg) { let (name, script) = self .extract_step_info_from_task_call(call) .unwrap_or(("unknown".into(), "unknown".into())); @@ -553,15 +536,33 @@ impl WacWalker { } fn walk_if(&mut self, if_stmt: &StmtIf) -> Option<(String, String)> { - let has_steps_in_body = self.body_contains_step(&if_stmt.body); - let has_steps_in_else = self.body_contains_step(&if_stmt.orelse); + self.walk_if_chain( + if_stmt.start(), + &if_stmt.test, + &if_stmt.body, + &if_stmt.elif_else_clauses, + ) + } + + /// An `elif` is laid out as an `if` nested alone in the `else` of the clause before it. + fn walk_if_chain( + &mut self, + start: TextSize, + test: &Expr, + body: &[Stmt], + clauses: &[ElifElseClause], + ) -> Option<(String, String)> { + let has_steps_in_body = self.body_contains_step(body); + let has_steps_in_else = clauses + .iter() + .any(|clause| self.body_contains_step(&clause.body)); if !has_steps_in_body && !has_steps_in_else { return None; } - let line = self.line_index.line_of(if_stmt.range.start().to_usize()); - let condition_source = Self::expr_to_source(&if_stmt.test); + let line = self.line_index.line_of(start.to_usize()); + let condition_source = Self::expr_to_source(test); let branch_id = self.next_id(); let branch_node_id = self.add_node(DagNode { @@ -573,15 +574,19 @@ impl WacWalker { let mut last_ids = Vec::new(); - if let Some((true_first, true_last)) = self.walk_body(&if_stmt.body) { + if let Some((true_first, true_last)) = self.walk_body(body) { self.add_edge(&branch_node_id, &true_first, Some("true".to_string())); last_ids.push(true_last); } else { last_ids.push(branch_node_id.clone()); } - if !if_stmt.orelse.is_empty() { - if let Some((else_first, else_last)) = self.walk_body(&if_stmt.orelse) { + if let Some((clause, clauses)) = clauses.split_first() { + let orelse = match &clause.test { + Some(test) => self.walk_if_chain(clause.start(), test, &clause.body, clauses), + None => self.walk_body(&clause.body), + }; + if let Some((else_first, else_last)) = orelse { self.add_edge(&branch_node_id, &else_first, Some("false".to_string())); last_ids.push(else_last); } else { @@ -611,7 +616,7 @@ impl WacWalker { return None; } - let line = self.line_index.line_of(for_stmt.range.start().to_usize()); + let line = self.line_index.line_of(for_stmt.start().to_usize()); let iter_source = Self::expr_to_source(&for_stmt.iter); let start_id = self.next_id(); @@ -644,7 +649,7 @@ impl WacWalker { return None; } - let line = self.line_index.line_of(while_stmt.range.start().to_usize()); + let line = self.line_index.line_of(while_stmt.start().to_usize()); let condition = Self::expr_to_source(&while_stmt.test); let start_id = self.next_id(); @@ -677,39 +682,14 @@ impl WacWalker { || self.body_contains_step(&try_stmt.orelse) || self.body_contains_step(&try_stmt.finalbody) || try_stmt.handlers.iter().any(|h| match h { - rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => { - self.body_contains_step(&eh.body) - } + ExceptHandler::ExceptHandler(eh) => self.body_contains_step(&eh.body), }); if !has_steps { return None; } - let line = self.line_index.line_of(try_stmt.range.start().to_usize()); - self.emit_try_catch_branch( - &try_stmt.body, - &try_stmt.handlers, - &try_stmt.finalbody, - line, - ) - } - - fn walk_try_star(&mut self, try_stmt: &StmtTryStar) -> Option<(String, String)> { - let has_steps = self.body_contains_step(&try_stmt.body) - || self.body_contains_step(&try_stmt.orelse) - || self.body_contains_step(&try_stmt.finalbody) - || try_stmt.handlers.iter().any(|h| match h { - rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => { - self.body_contains_step(&eh.body) - } - }); - - if !has_steps { - return None; - } - - let line = self.line_index.line_of(try_stmt.range.start().to_usize()); + let line = self.line_index.line_of(try_stmt.start().to_usize()); self.emit_try_catch_branch( &try_stmt.body, &try_stmt.handlers, @@ -721,7 +701,7 @@ impl WacWalker { fn emit_try_catch_branch( &mut self, try_body: &[Stmt], - handlers: &[rustpython_parser::ast::ExceptHandler], + handlers: &[ExceptHandler], finally_body: &[Stmt], line: usize, ) -> Option<(String, String)> { @@ -746,7 +726,7 @@ impl WacWalker { // Except handlers for handler in handlers { match handler { - rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => { + ExceptHandler::ExceptHandler(eh) => { if let Some((catch_first, catch_last)) = self.walk_body(&eh.body) { self.add_edge(&branch_node_id, &catch_first, Some("except".to_string())); last_ids.push(catch_last); @@ -783,7 +763,7 @@ impl WacWalker { } fn walk_return(&mut self, ret: &StmtReturn) -> Option<(String, String)> { - let line = self.line_index.line_of(ret.range.start().to_usize()); + let line = self.line_index.line_of(ret.start().to_usize()); let id = self.next_id(); let node_id = self.add_node(DagNode { id: id.clone(), @@ -796,12 +776,12 @@ impl WacWalker { } /// Extract workflow function parameters (no longer skips ctx) -fn extract_params(args: &rustpython_parser::ast::Arguments) -> Vec { +fn extract_params(args: &Parameters) -> Vec { let mut params = Vec::new(); for arg_with_default in args.args.iter().chain(args.posonlyargs.iter()) { - let name = arg_with_default.def.arg.to_string(); + let name = arg_with_default.parameter.name.to_string(); let typ = arg_with_default - .def + .parameter .annotation .as_ref() .map(|ann| WacWalker::expr_to_source(ann)); @@ -811,59 +791,34 @@ fn extract_params(args: &rustpython_parser::ast::Arguments) -> Vec { } pub fn parse_python_workflow(code: &str) -> Result> { - let ast = rustpython_parser::ast::Suite::parse(code, "") - .map_err(|e| vec![CompileError { message: format!("Parse error: {e}"), line: 0 }])?; + let ast = parse_module(code) + .map_err(|e| vec![CompileError { message: format!("Parse error: {e}"), line: 0 }])? + .into_suite(); // First pass: collect @task functions let task_functions = collect_task_functions(&ast); - // Find the @workflow async def - let workflow_fn = ast.iter().find_map(|stmt| { - if let Stmt::AsyncFunctionDef(func) = stmt { - let has_workflow_decorator = func.decorator_list.iter().any(|dec| { - if let Expr::Name(ExprName { id, .. }) = dec { - id.as_str() == "workflow" - } else { - false - } - }); - if has_workflow_decorator { - return Some(func); + let workflow_fns = || { + ast.iter().filter_map(|stmt| match stmt { + Stmt::FunctionDef(func) + if func.decorator_list.iter().any(|dec| { + matches!(&dec.expression, Expr::Name(ExprName { id, .. }) if id.as_str() == "workflow") + }) => + { + Some(func) } - } - // Also check non-async for error reporting - if let Stmt::FunctionDef(func) = stmt { - let has_workflow_decorator = func.decorator_list.iter().any(|dec| { - if let Expr::Name(ExprName { id, .. }) = dec { - id.as_str() == "workflow" - } else { - false - } - }); - if has_workflow_decorator { - return None; // Will be reported as not-async below - } - } - None - }); + _ => None, + }) + }; - // Check for non-async workflow function - let non_async_workflow = ast.iter().find_map(|stmt| { - if let Stmt::FunctionDef(func) = stmt { - let has_workflow_decorator = func.decorator_list.iter().any(|dec| { - if let Expr::Name(ExprName { id, .. }) = dec { - id.as_str() == "workflow" - } else { - false - } - }); - if has_workflow_decorator { - let line_index = LineIndex::new(code); - return Some(line_index.line_of(func.range.start().to_usize())); - } - } - None - }); + // Find the @workflow async def + let workflow_fn = workflow_fns().find(|func| func.is_async); + + // Check for non-async workflow function. The statement starts at its first decorator, + // the name sits on the `def` line the error points at. + let non_async_workflow = workflow_fns() + .find(|func| !func.is_async) + .map(|func| LineIndex::new(code).line_of(func.name.start().to_usize())); if let Some(line) = non_async_workflow { if workflow_fn.is_none() { @@ -875,7 +830,7 @@ pub fn parse_python_workflow(code: &str) -> Result String { hasher.update(code.as_bytes()); format!("{:x}", hasher.finalize()) } - -trait ToUsize { - fn to_usize(self) -> usize; -} - -impl ToUsize for rustpython_parser::text_size::TextSize { - fn to_usize(self) -> usize { - u32::from(self) as usize - } -} diff --git a/backend/parsers/windmill-parser-wac/tests/python_tests.rs b/backend/parsers/windmill-parser-wac/tests/python_tests.rs index cf117da9e7..c0de489153 100644 --- a/backend/parsers/windmill-parser-wac/tests/python_tests.rs +++ b/backend/parsers/windmill-parser-wac/tests/python_tests.rs @@ -363,3 +363,77 @@ async def main(x: str): )); assert!(matches!(dag.nodes[5].node_type, DagNodeType::Return)); } + +#[test] +fn test_elif_chain_nests_branches() { + let code = r#" +from wmill import workflow, task + +@task +async def a(): ... +@task +async def b(): ... +@task +async def c(): ... + +@workflow +async def main(x: int): + if x > 2: + await a() + elif x == 2: + await b() + else: + await c() +"#; + + let dag = parse_python_workflow(code).expect("should parse"); + let branches: Vec<(&str, usize)> = dag + .nodes + .iter() + .filter_map(|n| match &n.node_type { + DagNodeType::Branch { condition_source } => Some((condition_source.as_str(), n.line)), + _ => None, + }) + .collect(); + assert_eq!(branches, vec![("x > 2", 13), ("x == 2", 15)]); + + // The `elif` branch hangs off the `false` edge of the `if` before it + let (outer, inner) = (&dag.nodes[0].id, &dag.nodes[2].id); + assert!(dag + .edges + .iter() + .any(|e| &e.from == outer && &e.to == inner && e.label.as_deref() == Some("false"))); + assert_eq!( + dag.nodes + .iter() + .filter(|n| matches!(n.node_type, DagNodeType::Merge)) + .count(), + 2 + ); +} + +#[test] +fn test_workflow_with_recent_syntax() { + let code = r#" +from wmill import workflow, task +lazy import polars + +type Rows = list[dict] + +@task +async def load[T](rows: T): ... + +@workflow +async def main(name: str): + try: + await load(t"hello {name}") + except ValueError, TypeError: + pass +"#; + + let dag = parse_python_workflow(code).expect("should parse"); + assert!(dag + .nodes + .iter() + .any(|n| matches!(&n.node_type, DagNodeType::Step { name, .. } if name == "load"))); +} diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index d7e33313c6..c0dcab1c78 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -2,21 +2,22 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.7.5" @@ -24,11 +25,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" dependencies = [ "cfg-if", - "cipher", + "cipher 0.3.0", "cpufeatures 0.2.17", "opaque-debug", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -254,6 +280,35 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attribute-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f9ef7a68684657ce9da341c934c73b63df7a3585a61e366d84c84ed5d3d167" +dependencies = [ + "attribute-derive-macro", + "manyhow", + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300d426e5e73549125bb8e4649bdd5508f82c94ddf898fb0517246e71dfb8e98" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 3.0.7", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -360,21 +415,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.21.7" @@ -428,7 +468,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash 2.1.1", + "rustc-hash", "shlex", "syn 2.0.117", ] @@ -510,7 +550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e" dependencies = [ "block-padding", - "cipher", + "cipher 0.3.0", ] [[package]] @@ -620,6 +660,15 @@ version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.56" @@ -670,6 +719,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "char_str" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cba78a5abb515610e10af297615434b019755b5d7be14e559ff5340dff50c4" +dependencies = [ + "castaway", + "get-size2", + "itoa", + "ryu", +] + [[package]] name = "chrono" version = "0.4.44" @@ -713,6 +774,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -779,6 +850,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + [[package]] name = "colorchoice" version = "1.0.4" @@ -795,6 +872,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -1019,12 +1109,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -1032,6 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1044,6 +1129,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1093,27 +1187,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "unicode-xid", -] - [[package]] name = "des" version = "0.7.0" @@ -1121,7 +1194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac41dd49fb554432020d52c875fc290e110113f864c6b1b525cd62c7e7747a5d" dependencies = [ "byteorder", - "cipher", + "cipher 0.3.0", "opaque-debug", ] @@ -1196,6 +1269,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "drop_bomb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" + [[package]] name = "dunce" version = "1.0.5" @@ -1502,6 +1581,31 @@ dependencies = [ "version_check", ] +[[package]] +name = "get-size-derive2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f0daa179069133df60523e0f59d0a6bd2d3db9ccae01b630896d269c4a802e" +dependencies = [ + "attribute-derive", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "get-size2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050a5d07359ebfac7f0b74916d94cfa30575e45fdc7e9bfbb0f76d5d4203325a" +dependencies = [ + "compact_str", + "get-size-derive2", + "hashbrown 0.17.1", + "ordermap", + "smallvec", + "thin-vec", +] + [[package]] name = "gethostname" version = "0.4.3" @@ -1563,10 +1667,14 @@ dependencies = [ ] [[package]] -name = "gimli" -version = "0.32.3" +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] [[package]] name = "git-version" @@ -1686,6 +1794,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" @@ -1758,7 +1872,7 @@ dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", "once_cell", - "rustc-hash 2.1.1", + "rustc-hash", "triomphe", ] @@ -1870,7 +1984,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2012,16 +2126,31 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + [[package]] name = "inventory" version = "0.3.22" @@ -2031,17 +2160,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io-uring" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -2082,15 +2200,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2109,6 +2218,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2212,12 +2330,6 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" -[[package]] -name = "lalrpop-util" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" - [[package]] name = "lazy_static" version = "1.5.0" @@ -2376,7 +2488,7 @@ version = "3.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c42f95f9d296f2dcb50665f507ed5a68a171453142663ce44d77a4eb217b053" dependencies = [ - "aes", + "aes 0.7.5", "base64 0.21.7", "block-modes", "crc-any", @@ -2388,61 +2500,26 @@ dependencies = [ ] [[package]] -name = "malachite" -version = "0.4.22" +name = "manyhow" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5" +checksum = "8c69ea6e469ca785c846b25dd69eac9e9c916b51d8ada1f212866787ee1da12d" dependencies = [ - "malachite-base", - "malachite-nz", - "malachite-q", + "manyhow-macros", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] -name = "malachite-base" -version = "0.4.22" +name = "manyhow-macros" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea0ed76adf7defc1a92240b5c36d5368cfe9251640dcce5bd2d0b7c1fd87aeb" +checksum = "22fc5c1b2c4000056dae3619fea36e79ce79dabfee4907d2dcad6612b189e370" dependencies = [ - "hashbrown 0.14.5", - "itertools 0.11.0", - "libm", - "ryu", -] - -[[package]] -name = "malachite-bigint" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8" -dependencies = [ - "derive_more", - "malachite", - "num-integer", - "num-traits", - "paste", -] - -[[package]] -name = "malachite-nz" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34a79feebb2bc9aa7762047c8e5495269a367da6b5a90a99882a0aeeac1841f7" -dependencies = [ - "itertools 0.11.0", - "libm", - "malachite-base", -] - -[[package]] -name = "malachite-q" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f235d5747b1256b47620f5640c2a17a88c7569eebdf27cd9cb130e1a619191" -dependencies = [ - "itertools 0.11.0", - "malachite-base", - "malachite-nz", + "proc-macro-utils", + "proc-macro2", + "quote", ] [[package]] @@ -2575,9 +2652,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2949,6 +3026,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordermap" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a756ddcdeb6c0efc8927bdf0ed112885815a35ba350472a467be5104f01ca1" +dependencies = [ + "indexmap", +] + [[package]] name = "owo-colors" version = "4.3.0" @@ -2984,12 +3070,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pem" version = "3.0.6" @@ -3173,6 +3253,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "postgres-native-tls" version = "0.5.1" @@ -3286,6 +3378,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3401,9 +3504,9 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -3422,7 +3525,7 @@ dependencies = [ "lru-slab", "rand 0.9.5", "ring", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -3441,7 +3544,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -3455,6 +3558,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quote-use" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48356e493823dc3c1d7e9fc7b3ff204ecec48afbdd718fc4134091b88eb63f3d" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf1c5de6a7cc231c71e03c77aa4c7019097859ea7029156b54d2894a8430704" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 3.0.7", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -3835,6 +3960,106 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ruff_python_ast" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15434b06df7d930fad2818639a4e0fec4af9c770121c45a96b72da7feaae9ead" +dependencies = [ + "aho-corasick", + "arrayvec", + "bitflags", + "char_str", + "compact_str", + "get-size2", + "is-macro", + "memchr", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "thin-vec", + "thiserror 2.0.18", +] + +[[package]] +name = "ruff_python_parser" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a22811d752aa9e255d7dd72524d14352cf9bc654ef79b399bf1eeeb6640fbb" +dependencies = [ + "bitflags", + "bstr", + "drop_bomb", + "get-size2", + "hashbrown 0.17.1", + "memchr", + "ruff_python_ast", + "ruff_python_trivia", + "ruff_text_size", + "ruff_unicode_names2", + "rustc-hash", + "stacker", + "static_assertions", + "thin-vec", + "unicode-ident", + "unicode-normalization", +] + +[[package]] +name = "ruff_python_trivia" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0322a5059fe0feb2e010ca6f31c24ac289f6b7e9e38359539491b10c57f1c5bc" +dependencies = [ + "itertools 0.15.0", + "memchr", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "unicode-ident", + "unicode-normalization", +] + +[[package]] +name = "ruff_source_file" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ae809a289b5c791f82aa45df33f2a92323e7a1c1d265c10e9ac884027a2e43" +dependencies = [ + "memchr", + "ruff_text_size", +] + +[[package]] +name = "ruff_text_size" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf6344d6915ced9316312c1ee3f24c4300c54295bc4c3b8f4e95b633ae5443b" +dependencies = [ + "get-size2", +] + +[[package]] +name = "ruff_unicode_names2" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13418b8fd029d597e35dedc36e91f47122d26075e4c86557574d9441753fb375" +dependencies = [ + "phf 0.11.3", + "ruff_unicode_names2_generator", +] + +[[package]] +name = "ruff_unicode_names2_generator" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37fd82af49d5685d9054e68185987acaaf62542f671b6df8935155664876bc39" +dependencies = [ + "phf_codegen", + "rand 0.8.6", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -3851,18 +4076,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -3971,63 +4184,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "rustpython-ast" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cdaf8ee5c1473b993b398c174641d3aa9da847af36e8d5eb8291930b72f31a5" -dependencies = [ - "is-macro", - "malachite-bigint", - "rustpython-parser-core", - "static_assertions", -] - -[[package]] -name = "rustpython-parser" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "868f724daac0caf9bd36d38caf45819905193a901e8f1c983345a68e18fb2abb" -dependencies = [ - "anyhow", - "is-macro", - "itertools 0.11.0", - "lalrpop-util", - "log", - "malachite-bigint", - "num-traits", - "phf 0.11.3", - "phf_codegen", - "rustc-hash 1.1.0", - "rustpython-ast", - "rustpython-parser-core", - "tiny-keccak", - "unic-emoji-char", - "unic-ucd-ident", - "unicode_names2", -] - -[[package]] -name = "rustpython-parser-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b6c12fa273825edc7bccd9a734f0ad5ba4b8a2f4da5ff7efe946f066d0f4ad" -dependencies = [ - "is-macro", - "memchr", - "rustpython-parser-vendored", -] - -[[package]] -name = "rustpython-parser-vendored" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04fcea49a4630a3a5d940f4d514dc4f575ed63c14c3e3ed07146634aed7f67a6" -dependencies = [ - "memchr", - "once_cell", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -4040,6 +4196,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -4409,16 +4571,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -4680,15 +4832,15 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.23" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" dependencies = [ "cc", "cfg-if", "libc", "psm", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4830,7 +4982,7 @@ dependencies = [ "new_debug_unreachable", "num-bigint", "once_cell", - "rustc-hash 2.1.1", + "rustc-hash", "serde", "siphasher 0.3.11", "swc_atoms", @@ -4852,7 +5004,7 @@ dependencies = [ "num-bigint", "once_cell", "phf 0.11.3", - "rustc-hash 2.1.1", + "rustc-hash", "string_enum", "swc_atoms", "swc_common", @@ -4871,7 +5023,7 @@ dependencies = [ "either", "num-bigint", "phf 0.11.3", - "rustc-hash 2.1.1", + "rustc-hash", "seq-macro", "serde", "smallvec", @@ -4974,6 +5126,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5102,6 +5265,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "thin-vec" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" + [[package]] name = "thiserror" version = "1.0.69" @@ -5193,15 +5362,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -5229,34 +5389,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.5.10", + "socket2", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.7", ] [[package]] @@ -5289,7 +5446,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.10.1", - "socket2 0.6.3", + "socket2", "tokio", "tokio-util", "whoami 2.1.0", @@ -5325,6 +5482,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -5663,17 +5821,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" -[[package]] -name = "unic-emoji-char" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - [[package]] name = "unic-ucd-category" version = "0.9.0" @@ -5686,17 +5833,6 @@ dependencies = [ "unic-ucd-version", ] -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - [[package]] name = "unic-ucd-version" version = "0.9.0" @@ -5776,25 +5912,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "unicode_names2" -version = "1.3.0" +name = "universal-hash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "phf 0.11.3", - "unicode_names2_generator", -] - -[[package]] -name = "unicode_names2_generator" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" -dependencies = [ - "getopts", - "log", - "phf_codegen", - "rand 0.8.6", + "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -6216,8 +6340,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.826.0" +version = "1.833.0" dependencies = [ + "aes-gcm", "aho-corasick", "anyhow", "arc-swap", @@ -6245,6 +6370,7 @@ dependencies = [ "git-version", "globset", "hex", + "hkdf", "hmac 0.12.1", "hyper", "indexmap", @@ -6260,12 +6386,14 @@ dependencies = [ "pin-project-lite", "pkcs1", "postgres-native-tls", + "postgres-protocol", "quick_cache", "rand 0.9.5", "regex", "reqwest", "reqwest-middleware", "reqwest-retry", + "ryu-js", "semver", "serde", "serde_json", @@ -6302,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.826.0" +version = "1.833.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.826.0" +version = "1.833.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,30 +6533,30 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "itertools 0.14.0", - "rustpython-ast", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", "serde_json", "windmill-parser", ] [[package]] name = "windmill-parser-py-asset" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "rustpython-ast", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", "windmill-parser", "windmill-parser-sql-asset", ] [[package]] name = "windmill-parser-py-imports" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "async-recursion", @@ -6438,7 +6566,8 @@ dependencies = [ "phf 0.11.3", "regex", "regex-lite", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", "serde", "serde_json", "sqlx", @@ -6450,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,11 +6681,12 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", - "rustpython-ast", - "rustpython-parser", + "ruff_python_ast", + "ruff_python_parser", + "ruff_text_size", "serde", "serde_json", "sha2 0.10.9", @@ -6568,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.826.0" +version = "1.833.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index ba69f6e380..ca10c14ff6 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.826.0" +version = "1.833.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index d4dee19f82..ab12ae6f6e 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -153,6 +153,9 @@ pub async fn connect( pool_options = pool_options.idle_timeout(Duration::from_secs(60)); } pool_options + .before_acquire(|_, meta| { + Box::pin(async move { Ok(!windmill_common::db::connection_is_retired(meta.age)) }) + }) .after_connect(move |conn, _| { if worker_mode { Box::pin(async move { diff --git a/backend/src/main.rs b/backend/src/main.rs index 81104d6764..fd953213a4 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -63,12 +63,14 @@ use windmill_common::{ SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SERVICE_LOG_RETENTION_SECS_SETTING, - SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, - UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, SYNC_HUB_RESOURCE_TYPES_DAILY_SETTING, + TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, + UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_MAX_QUEUED_JOBS_SETTING, WORKSPACE_REGISTRIES_SETTING, }, + hub_resource_types, scripts::ScriptLang, stats_oss::schedule_stats, triggers::TriggerKind, @@ -121,7 +123,8 @@ use windmill_worker::{ BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, CSHARP_CACHE_DIR, DENO_CACHE_DIR, DENO_CACHE_DIR_DEPS, DENO_CACHE_DIR_NPM, GO_BIN_CACHE_DIR, GO_CACHE_DIR, JAVA_CACHE_DIR, NU_CACHE_DIR, POWERSHELL_CACHE_DIR, PY310_CACHE_DIR, PY311_CACHE_DIR, PY312_CACHE_DIR, PY313_CACHE_DIR, - RUBY_CACHE_DIR, RUST_CACHE_DIR, R_CACHE_DIR, TAR_JAVA_CACHE_DIR, UV_CACHE_DIR, + PY314_CACHE_DIR, PY315_CACHE_DIR, RUBY_CACHE_DIR, RUST_CACHE_DIR, R_CACHE_DIR, + TAR_JAVA_CACHE_DIR, UV_CACHE_DIR, }; use crate::monitor::{ @@ -401,111 +404,16 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { Ok(()) } -/// Raw resource type from hub API (schema is a JSON string) -#[derive(serde::Deserialize)] -struct HubResourceTypeRaw { - pub id: i64, - pub name: String, - pub schema: Option, - pub app: String, - pub description: Option, - /// Absent from hubs predating the column, and from caches written before it. - #[serde(default)] - pub format_extension: Option, - /// Doubly optional, so a hub predating the field (no key) is told apart from a type the - /// hub leaves unnamed (null). - #[serde( - default, - deserialize_with = "windmill_common::more_serde::double_option" - )] - pub display_name: Option>, -} - -/// Processed resource type with parsed schema -#[derive(serde::Deserialize, serde::Serialize, Clone)] -pub struct HubResourceType { - pub id: i64, - pub name: String, - pub schema: Option, - pub app: String, - pub description: Option, - /// Doubly optional on purpose. A cache written before this column has no key at - /// all (`None`) and must leave the stored extension alone; one written since - /// always writes the key, so an explicit null (`Some(None)`) is the hub genuinely - /// dropping it and must clear. A single `Option` conflates the two, and picking - /// either meaning breaks the other — as does plain serde, which folds `null` - /// into the outer `None`, hence the wrapping deserializer. - #[serde( - default, - deserialize_with = "windmill_common::more_serde::double_option", - skip_serializing_if = "Option::is_none" - )] - pub format_extension: Option>, - /// Doubly optional like `format_extension`: a cache written before the field leaves the - /// stored name alone, while a null from the hub clears it. - #[serde( - default, - deserialize_with = "windmill_common::more_serde::double_option", - skip_serializing_if = "Option::is_none" - )] - pub display_name: Option>, -} - -const HUB_RT_CACHE_FILE: &str = "resource_types.json"; - async fn cache_hub_resource_types() -> anyhow::Result<()> { println!("Caching resource types from hub..."); - let response = HTTP_CLIENT - .get(format!("{}/resource_types/list", DEFAULT_HUB_BASE_URL)) - .header("Accept", "application/json") - .send() - .await - .with_context(|| "Failed to fetch resource types from hub")?; - - if !response.status().is_success() { - anyhow::bail!( - "Failed to fetch resource types from hub: {}", - response.status() - ); - } - - let raw_types: Vec = response - .json::>() - .await - .with_context(|| "Failed to parse resource types from hub")?; - - // Parse schema strings into JSON values - let resource_types: Vec = raw_types - .into_iter() - .filter_map(|rt| { - let schema = match rt.schema { - Some(s) => match serde_json::from_str(&s) { - Ok(v) => Some(v), - Err(e) => { - println!("Warning: failed to parse schema for {}: {}", rt.name, e); - return None; - } - }, - None => None, - }; - Some(HubResourceType { - id: rt.id, - name: rt.name, - schema, - app: rt.app, - description: rt.description, - format_extension: Some(rt.format_extension), - display_name: rt.display_name, - }) - }) - .collect(); + let resource_types = hub_resource_types::fetch(DEFAULT_HUB_BASE_URL, None, None).await?; println!("Fetched {} resource types from hub", resource_types.len()); create_dir_all(&*HUB_RT_CACHE_DIR)?; - let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE); + let cache_path = hub_resource_types::cache_path(); let content = serde_json::to_string_pretty(&resource_types) .with_context(|| "Failed to serialize resource types")?; @@ -516,147 +424,6 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> { Ok(()) } -pub async fn sync_cached_resource_types(db: &sqlx::Pool) -> anyhow::Result<()> { - let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE); - - if tokio::fs::metadata(&cache_path).await.is_err() { - tracing::info!( - "No cached resource types found at {}, skipping sync", - cache_path - ); - return Ok(()); - } - - tracing::info!("Syncing cached resource types to admins workspace..."); - - let content = tokio::fs::read_to_string(&cache_path) - .await - .with_context(|| format!("Failed to read cache file from {}", cache_path))?; - - let cached_types: Vec = - serde_json::from_str(&content).with_context(|| "Failed to parse cached resource types")?; - - tracing::info!("Found {} cached resource types", cached_types.len()); - - // Get existing resource types in admins workspace. `format_extension` is part of - // the comparison below, so a type whose only change is gaining or losing it is - // not mistaken for unchanged; `is_fileset` decides whether it may take one. - let existing_types: Vec<( - String, - Option, - Option, - Option, - bool, - Option, - )> = sqlx::query_as( - "SELECT name, schema, description, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = 'admins'", - ) - .fetch_all(db) - .await - .with_context(|| "Failed to fetch existing resource types")?; - - let existing_map: std::collections::HashMap< - String, - ( - Option, - Option, - Option, - bool, - Option, - ), - > = existing_types - .into_iter() - .map( - |(name, schema, desc, format_extension, is_fileset, display_name)| { - ( - name, - (schema, desc, format_extension, is_fileset, display_name), - ) - }, - ) - .collect(); - - let mut synced_count = 0; - let mut skipped_count = 0; - - for rt in cached_types { - let existing = existing_map.get(&rt.name); - let is_fileset = existing.map(|(_, _, _, f, _)| *f).unwrap_or(false); - let stored_extension = existing.and_then(|(_, _, e, _, _)| e.clone()); - let stored_display_name = existing.and_then(|(_, _, _, _, n)| n.clone()); - // A fileset is a set of files, so it cannot also be one file. Create, update - // and the manual sync all reject the pair; this writer would otherwise - // persist it onto a same-named local fileset. - // - // A cache with no key at all leaves the stored value alone, so the target is - // what is already there — which is also what makes the comparison below - // agree with the write instead of re-upserting the row on every boot. - let format_extension = if is_fileset { - None - } else { - match &rt.format_extension { - Some(from_cache) => from_cache.clone(), - None => stored_extension.clone(), - } - }; - // No key in the cache leaves the stored name alone, as for the extension. So does a name - // too long for the column: one bad entry must not fail the upsert and end the sync. - let display_name = match &rt.display_name { - Some(Some(name)) if name.chars().count() > 100 => { - tracing::warn!( - "Ignoring the display_name of resource type {}: longer than 100 characters", - rt.name - ); - stored_display_name.clone() - } - Some(from_cache) => from_cache.clone(), - None => stored_display_name.clone(), - }; - - if let Some((existing_schema, existing_desc, _, _, _)) = existing { - if existing_schema == &rt.schema - && existing_desc == &rt.description - && stored_extension == format_extension - && stored_display_name == display_name - { - skipped_count += 1; - continue; - } - } - - // Insert or update resource type - sqlx::query( - // `format_extension` is resolved above rather than coalesced here: a - // COALESCE could never clear one, so a hub that dropped an extension - // would leave the stale value behind forever. - "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at) - VALUES ('admins', $1, $2, $3, $4, $5, now()) - ON CONFLICT (workspace_id, name) DO UPDATE - SET schema = EXCLUDED.schema, description = EXCLUDED.description, - format_extension = EXCLUDED.format_extension, - display_name = EXCLUDED.display_name, edited_at = now()", - ) - .bind(&rt.name) - .bind(&rt.schema) - .bind(&rt.description) - .bind(&format_extension) - .bind(&display_name) - .execute(db) - .await - .with_context(|| format!("Failed to upsert resource type {}", rt.name))?; - - synced_count += 1; - } - - tracing::info!( - "Synced {} resource types to admins workspace ({} skipped as unchanged)", - synced_count, - skipped_count - ); - - Ok(()) -} - fn print_help() { println!("Windmill - a fast, open-source workflow engine and job runner."); println!(); @@ -670,6 +437,8 @@ fn print_help() { println!(" cache-rt Pre-cache hub resource types"); println!(" sync-config Sync instance config from a YAML file to the database"); println!(" operator Run the Kubernetes operator (watches a ConfigMap)"); + #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] + println!(" oidc-signer (ee) Check an external OIDC signer, or run a file-backed one for tests"); println!(); println!("Environment variables (name = default):"); println!(" DATABASE_URL = The Postgres database url."); @@ -696,7 +465,7 @@ fn print_help() { println!(" LICENSE_KEY = None (EE only) Enterprise license key (workers require valid key)"); println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup"); println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool"); - println!(" SYNC_CACHED_RT = false Sync cached resource types to admins workspace on server start"); + println!(" SYNC_CACHED_RT = false Sync the image's cached resource types to the admins workspace on server start"); println!(" HUB_BASE_URL = https://hub.windmill.dev Hub to fetch scripts from in `cache` mode (server/worker use the DB setting instead)"); println!(); println!("Notes:"); @@ -704,6 +473,38 @@ fn print_help() { println!("- At startup, Windmill logs currently set configuration keys for visibility."); } +async fn otel_environment(conn: &Connection) -> String { + if let Ok(environment) = std::env::var("OTEL_ENVIRONMENT") { + environment + } else { + load_base_url(conn) + .await + .unwrap_or_else(|_| "local".to_string()) + .trim_start_matches("https://") + .trim_start_matches("http://") + .split(".") + .next() + .unwrap_or_else(|| "local") + .to_string() + } +} + +/// Tracing for a subcommand that writes instance config. It never runs the server startup +/// that loads the OTel settings, and its audit events are exported under them. +/// +/// The subscriber it installs depends on those settings, so `early_logs` is what prints +/// the events of connecting and loading them; it gives way once the real one is in. +async fn init_declarative_writer_tracing( + db: &windmill_common::DB, + service: &str, + early_logs: tracing::subscriber::DefaultGuard, +) { + load_otel(db).await; + let environment = otel_environment(&Connection::Sql(db.clone())).await; + drop(early_logs); + windmill_common::tracing_init::initialize_subcommand_tracing(service, &HOSTNAME, &environment); +} + async fn windmill_main() -> anyhow::Result<()> { let (killpill_tx, mut killpill_rx) = KillpillSender::new(2); let mut monitor_killpill_rx = killpill_tx.subscribe(); @@ -807,7 +608,6 @@ async fn windmill_main() -> anyhow::Result<()> { return Ok(()); } "sync-config" => { - tracing_subscriber::fmt::init(); let path = std::env::args().nth(2).unwrap_or_else(|| { eprintln!("Usage: windmill sync-config "); std::process::exit(1); @@ -821,15 +621,40 @@ async fn windmill_main() -> anyhow::Result<()> { windmill_common::instance_config::resolve_env_refs(&mut config.global_settings) .map_err(|var| anyhow::anyhow!("environment variable '{var}' not found"))?; + let early_logs = tracing::subscriber::set_default(tracing_subscriber::fmt().finish()); tracing::info!("Connecting to database..."); let db = crate::db_connect::initial_connection().await?; - config.sync_to_db(&db).await?; + init_declarative_writer_tracing(&db, "windmill-sync-config", early_logs).await; + + let author = windmill_audit::instance_config::declarative_sync_author(); + let synced = config + .sync_to_db( + &db, + &windmill_audit::instance_config::InstanceConfigAuditor { + db: &db, + author: &author, + source: Some( + windmill_audit::instance_config::DeclarativeSource::SyncConfig, + ), + }, + ) + .await; + // Also on failure: the settings written before it are already audited, and the + // process exits before the exporter's next scheduled batch. + windmill_common::otel_oss::flush_otel_logs().await; + synced?; tracing::info!("Synced instance config from {path}"); return Ok(()); } + #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] + "oidc-signer" => { + tracing_subscriber::fmt::init(); + windmill_api::oidc_signer_tools_ee::run_cli(std::env::args().skip(2).collect()).await?; + return Ok(()); + } #[cfg(feature = "operator")] "operator" => { - tracing_subscriber::fmt::init(); + let early_logs = tracing::subscriber::set_default(tracing_subscriber::fmt().finish()); tracing::info!("Starting Windmill Kubernetes operator..."); tracing::info!("Connecting to database..."); @@ -842,6 +667,7 @@ async fn windmill_main() -> anyhow::Result<()> { operator_killpill_rx, ) .await?; + init_declarative_writer_tracing(&db, "windmill-operator", early_logs).await; #[cfg(all(feature = "enterprise", feature = "private"))] tokio::spawn(async move { @@ -968,19 +794,7 @@ async fn windmill_main() -> anyhow::Result<()> { (Connection::Sql(db), None, None) }; - let environment = if let Ok(environment) = std::env::var("OTEL_ENVIRONMENT") { - environment - } else { - load_base_url(&conn) - .await - .unwrap_or_else(|_| "local".to_string()) - .trim_start_matches("https://") - .trim_start_matches("http://") - .split(".") - .next() - .unwrap_or_else(|| "local") - .to_string() - }; + let environment = otel_environment(&conn).await; let _guard = windmill_common::tracing_init::initialize_tracing(&hostname, &mode, &environment); @@ -1015,8 +829,10 @@ async fn windmill_main() -> anyhow::Result<()> { .map(|v| v.to_lowercase() == "true" || v == "1") .unwrap_or(false) { - if let Err(e) = sync_cached_resource_types(db).await { - tracing::warn!("Failed to sync cached resource types: {:#}", e); + match hub_resource_types::sync_from_image_cache(db).await { + Ok(Some(outcome)) => tracing::info!("{}", outcome.summary()), + Ok(None) => {} + Err(e) => tracing::warn!("Failed to sync cached resource types: {:#}", e), } } } @@ -1234,6 +1050,15 @@ Windmill Community Edition {GIT_VERSION} // empty script cache, refilled from the database. windmill_common::cache::script::clear(); + // Before the first load: it can sign a token for instance object storage. + #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] + windmill_common::oidc_ee::init_oidc_signing( + &conn, + server_mode, + killpill_rx.resubscribe(), + ) + .await?; + initial_load( &conn, killpill_tx.clone(), @@ -1748,6 +1573,7 @@ Windmill Community Edition {GIT_VERSION} if server_mode { if let Some(db) = conn.as_sql() { schedule_stats(&db, &HTTP_CLIENT).await; + hub_resource_types::spawn_daily_sync(db.clone(), killpill_tx.subscribe()); } } @@ -2322,6 +2148,12 @@ async fn process_notify_event( RESTART_COORDINATION_SETTING => { // Internal coordination key for staggered restarts, no action needed } + SYNC_HUB_RESOURCE_TYPES_DAILY_SETTING => { + // Read by the daily resource type sync each time it runs + } + "oidc_external_signer" => { + // Written by servers that sign with an external signer, read when signing + } "plain_emails_telemetry" => { let enabled = sqlx::query_scalar!( "SELECT value FROM global_settings WHERE name = 'plain_emails_telemetry'" @@ -2407,6 +2239,8 @@ pub async fn run_workers( &*PY311_CACHE_DIR, &*PY312_CACHE_DIR, &*PY313_CACHE_DIR, + &*PY314_CACHE_DIR, + &*PY315_CACHE_DIR, &*BUN_BUNDLE_CACHE_DIR, &*GO_CACHE_DIR, &*GO_BIN_CACHE_DIR, diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 677ecd11a2..4fae6f34ab 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -427,6 +427,8 @@ pub async fn initial_load( native_mode, // an agent worker never reads its group's config, only its token object_store_cache_config: None, + // nor whether it is paused: the server refuses it jobs instead + paused: false, })); } } @@ -4134,6 +4136,20 @@ pub async fn monitor_db( } _ => {} } + match windmill_common::runnable_job_stats::cleanup_old_runnable_job_stats( + db, + windmill_common::runnable_job_stats::RETENTION_DAYS, + ) + .await + { + Ok(count) if count > 0 => { + tracing::info!("Deleted {} old runnable job stats rows", count); + } + Err(e) => { + tracing::error!("Error cleaning up runnable job stats: {:?}", e); + } + _ => {} + } } } }; @@ -4433,6 +4449,44 @@ pub async fn monitor_db( } }; + // Drop the data table role set logins nobody has connected with for a month. Every ~40 min, + // spawned for the same reason as the credential maintenance above: it opens a connection per + // managed database for each login it drops. + let datatable_role_set_sweep_f = async { + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(240) { + if let Some(db) = conn.as_sql() { + let db = db.clone(); + tokio::spawn(async move { + if let Err(e) = + windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await + { + tracing::error!("Error sweeping unused data table role set logins: {e}"); + } + }); + } + } + }; + + // Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM. + // Every ~5 min until one pass has gone through every workspace; spawned so a large instance + // does not hold up the join below. The column widening it starts with is tried every + // iteration: a v2 value too long for the narrow column fails to save until it is done. + let legacy_secret_upgrade_f = async { + if server_mode + && iteration.is_some() + && (windmill_common::variables::variable_value_widen_pending() + || (iteration.as_ref().unwrap().should_run(30) + && windmill_common::variables::legacy_secret_upgrade_pending())) + { + if let Some(db) = conn.as_sql() { + let db = db.clone(); + tokio::spawn(async move { + windmill_common::variables::upgrade_legacy_secret_variables(&db).await + }); + } + } + }; + // Delete the AI session backups older than their workspace's retention. Every ~40 min // (240 iterations at the default 10 s, the most a u8 `should_run` counts): the retention // counts in days. Spawned for the same reason as the credential maintenance above, a @@ -4504,7 +4558,9 @@ pub async fn monitor_db( cleanup_scheduled_job_deletions_f, git_auto_pull_f, git_credential_maintenance_f, + datatable_role_set_sweep_f, ai_session_retention_f, + legacy_secret_upgrade_f, pipeline_freshness_watchdog_f, reconcile_unarmed_schedules_f, stranded_jobs_f, @@ -5689,8 +5745,13 @@ pub async fn reload_worker_config(db: &DB, tx: KillpillSender, kill_if_change: b .as_ref() .is_some_and(|dws| !dws.is_empty()); + // Pausing or resuming must not restart the workers, dedicated ones included: a reload + // whose only difference is the flag skips every restart below. + let only_pause_changed = wc.paused != config.paused + && WorkerConfig { paused: wc.paused, ..config.clone() } == **wc; + if **wc != config || has_dedicated { - if kill_if_change { + if kill_if_change && !only_pause_changed { if has_dedicated || wc.dedicated_worker != config.dedicated_worker || wc.dedicated_workers != config.dedicated_workers diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 2a518a7cc8..fa1c049746 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -132,7 +132,7 @@ input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_t FK: (workspace_id) -> workspace(id) instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char) job_logs: job_id(uuid), workspace_id(char), created_at(ts), logs(text), log_offset(int), log_file_index(text[]) -job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[]) +job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[]), viewer_data_access(bool) job_resolution: job_id(uuid), workspace_id(char), resolved_at(ts), resolved_by(char), note(text), automatic(bool) job_result_stream: job_id(uuid), workspace_id(text), stream(text) job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) @@ -180,6 +180,7 @@ resource_type: workspace_id(char), name(char), schema(jsonb), description(text), FK: (workspace_id) -> workspace(id) resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), approver(char), resume_id(int), approved(bool) FK: (flow) -> v2_job_queue(id) +runnable_job_stats: hour(bigint), workspace_id(char), runnable_path(char), worker_group(text), job_count(int), total_duration_ms(bigint), max_memory_peak(int), sum_memory_peak(bigint), memory_sample_count(int), total_cpu_ms(bigint) runnable_settings: hash(bigint), debouncing_settings(bigint), concurrency_settings(bigint) schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char), labels(text[]) FK: (workspace_id) -> workspace(id) @@ -219,7 +220,7 @@ websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), work windmill_migrations: name(text), created_at(ts) worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint) FK: (workspace_id) -> workspace(id) -worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]) +worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]), draining(bool) workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool) FK: (parent_workspace_id) -> workspace(id) workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts) diff --git a/backend/tests/ai_decision.rs b/backend/tests/ai_decision.rs index 04b310f2c1..d6359c1cea 100644 --- a/backend/tests/ai_decision.rs +++ b/backend/tests/ai_decision.rs @@ -117,7 +117,7 @@ async fn test_ai_decision_answers_route_a_following_branch( "expr": "results.d.output.intent.choice === 'refund'", "modules": [{"id": "r", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(choice){ return 'handled ' + choice }", "input_transforms": {"choice": { "type": "javascript", diff --git a/backend/tests/ai_sessions.rs b/backend/tests/ai_sessions.rs index 6b33a4141e..b2b8406db3 100644 --- a/backend/tests/ai_sessions.rs +++ b/backend/tests/ai_sessions.rs @@ -1580,3 +1580,163 @@ async fn test_backups_fall_back_to_the_instance_storage(db: Pool) -> a Ok(()) } + +/// Runs its closure when dropped, so process-wide state is put back even when an assertion +/// fails. +struct OnDrop(F); +impl Drop for OnDrop { + fn drop(&mut self) { + (self.0)() + } +} + +/// Once every worker reads AES-256-GCM and this server has been up long enough for any server +/// of the previous release to be gone, objects are written in it: a session backed up as CBC +/// before the switch still restores whole with the GCM pieces added after it, a head at its +/// cap and a push token still read back under the larger ciphertext, and relocated GCM +/// ciphertext does not decrypt for another user. +#[sqlx::test(fixtures("base"))] +async fn test_backups_switch_to_gcm_once_every_worker_reads_it( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::min_version::{ + MIN_VERSION, MIN_VERSION_SUPPORTS_AEAD_SECRETS, MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES, + }; + use windmill_common::workspace_crypt::V2_MAGIC; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let storage_dir = tempfile::tempdir()?; + configure_primary_lfs(&db, &storage_dir.path().to_string_lossy()).await?; + let before = MIN_VERSION.load_full(); + let _restore = OnDrop(move || MIN_VERSION.store(before.clone())); + let _uptime = OnDrop(|| windmill_common::db::set_process_uptime_offset(Default::default())); + let is_gcm = |b: &[u8]| b.starts_with(&V2_MAGIC); + let object = |path: &str| -> Vec { + std::fs::read(user_root(storage_dir.path(), "test@windmill.dev").join(path)).unwrap() + }; + + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES.version().clone(), + )); + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ + "id": "s1", + "whole": true, + "head": { "id": "s1", "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" }, + "chats": [{ "id": "c1", "record": { "id": "c1", "title": "BEFORE" } }] + }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let cbc_chat = object("sessions/s1/chats/c1.json"); + assert!(!cbc_chat.is_empty() && !is_gcm(&cbc_chat)); + + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_AEAD_SECRETS.version().clone(), + )); + // Every worker reads it, but this server was only just started: still CBC. + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ "id": "s1", "chats": [{ "id": "c0", "record": { "id": "c0", "title": "FRESH" } }] }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let fresh_chat = object("sessions/s1/chats/c0.json"); + assert!(!fresh_chat.is_empty() && !is_gcm(&fresh_chat)); + + windmill_common::db::set_process_uptime_offset( + windmill_common::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME, + ); + let resp = push( + &base, + "SECRET_TOKEN", + json!({ + "owner": "test@windmill.dev", + "sessions": [{ + "id": "s1", + "chats": [{ "id": "c2", "record": { "id": "c2", "title": "AFTER" } }], + "images": [{ "chat_id": "c2", "id": "i1", "data_url": "data:image/png;base64,AAAA" }] + }] + }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert_eq!( + object("sessions/s1/chats/c1.json"), + cbc_chat, + "not rewritten" + ); + assert!(is_gcm(&object("sessions/s1/chats/c2.json"))); + assert!(is_gcm(&object("images/s1/c2/i1"))); + + let pulled = pull(&base, "SECRET_TOKEN", &["s1"]).await?; + let mut titles: Vec = pulled["sessions"][0]["chats"] + .as_array() + .unwrap() + .iter() + .map(|c| c["record"]["title"].as_str().unwrap().to_string()) + .collect(); + titles.sort(); + assert_eq!(titles, ["AFTER", "BEFORE", "FRESH"], "{pulled}"); + assert_eq!( + pulled["sessions"][0]["images"][0]["data_url"], + "data:image/png;base64,AAAA" + ); + + // A head at its cap, pushed in two parts: the second reads back the push token. + let mut head = json!({ "id": "s2", "workspace_id": "test-workspace", "createdAt": 2, "chatId": "c", "pad": "" }); + let pad = 1024 * 1024 - head.to_string().len(); + head["pad"] = json!("x".repeat(pad)); + assert_eq!(head.to_string().len(), 1024 * 1024); + for part in [ + json!({ "id": "s2", "whole": true, "push": "p2", "opens": true, "partial": true, "head": head }), + json!({ "id": "s2", "whole": true, "push": "p2", "chats": [{ "id": "c", "record": { "id": "c" } }] }), + ] { + let resp = push( + &base, + "SECRET_TOKEN", + json!({ "owner": "test@windmill.dev", "sessions": [part] }), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let pushed: Value = resp.json().await?; + assert_eq!(pushed["results"], json!([{ "id": "s2" }]), "{pushed}"); + } + assert!(is_gcm(&object("sessions/s2/head.json"))); + let pulled = pull(&base, "SECRET_TOKEN", &["s2"]).await?; + assert_eq!( + pulled["sessions"][0]["head"]["pad"].as_str().map(str::len), + Some(pad) + ); + assert_eq!(pulled["sessions"][0]["chats"][0]["id"], "c", "{pulled}"); + + let first = user_root(storage_dir.path(), "test@windmill.dev"); + copy_dir( + &first, + &first + .parent() + .unwrap() + .join(calculate_hash("test2@windmill.dev")), + )?; + assert_eq!( + pull(&base, "SECRET_TOKEN_2", &["s1", "s2"]).await?["sessions"], + json!([]), + "relocated ciphertext must not decrypt for another user" + ); + + Ok(()) +} diff --git a/backend/tests/app_ctx_inputs.rs b/backend/tests/app_ctx_inputs.rs new file mode 100644 index 0000000000..cf386adc89 --- /dev/null +++ b/backend/tests/app_ctx_inputs.rs @@ -0,0 +1,88 @@ +//! A context input a raw app runnable declares (`fields. = { type: "ctx", .. }`) is +//! filled by the server from the viewer's session. Whatever the request sends under that +//! name is discarded: a runnable filters rows on it, so a viewer who could set it would +//! read someone else's. +//! +//! Users from the `base` fixture: +//! test-user (admin, token SECRET_TOKEN) +//! test-user-2 (developer, token SECRET_TOKEN_2) + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; +const VIEWER_TOKEN: &str = "SECRET_TOKEN_2"; +const APP_PATH: &str = "u/test-user/ctx_app"; +const CONTENT: &str = "export function main(who: string) { return who }"; +// sha256 of CONTENT, the key an inline runnable is pinned under. +const CONTENT_SHA: &str = "6e198199621d57e4fed834510cb0c2692274aa4cbe976e1704fab2806be9d744"; + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +#[sqlx::test(fixtures("base"))] +async fn a_declared_ctx_input_ignores_the_value_the_viewer_sends( + 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 client = reqwest::Client::new(); + + let resp = authed(client.post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP_PATH, + "summary": "ctx inputs", + "value": { + "runnables": { + "whoami": { + "type": "inline", + "inlineScript": { "language": "bun", "content": CONTENT }, + "fields": { "who": { "type": "ctx", "ctx": "username" } } + } + } + }, + // The policy says nothing of context inputs: the server reads the + // declaration from the deployed runnable. + "policy": { + "execution_mode": "anonymous", + "triggerables_v2": { + format!("whoami:rawscript/{CONTENT_SHA}"): { + "static_inputs": {}, + "one_of_inputs": {} + } + } + } + })) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + + // Both spellings a viewer could send: a forged literal, and no argument at all. + for args in [json!({ "who": "test-user" }), json!({})] { + let resp = authed( + client.post(format!("{ws}/apps_u/execute_component/{APP_PATH}")), + VIEWER_TOKEN, + ) + .json(&json!({ + "component": "whoami", + "args": args, + "raw_code": { "language": "bun", "content": CONTENT } + })) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + let job_id: uuid::Uuid = resp.text().await?.parse()?; + + let queued: serde_json::Value = sqlx::query_scalar("SELECT args FROM v2_job WHERE id = $1") + .bind(job_id) + .fetch_one(&db) + .await?; + assert_eq!(queued["who"], json!("test-user-2"), "sent {args}"); + } + + Ok(()) +} diff --git a/backend/tests/app_publisher_permit.rs b/backend/tests/app_publisher_permit.rs new file mode 100644 index 0000000000..e590efaeb2 --- /dev/null +++ b/backend/tests/app_publisher_permit.rs @@ -0,0 +1,40 @@ +//! A refused publisher-mode app run is not remembered: once the caller is granted +//! read access to the app, the very next run in the same process is allowed. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base", "app_publisher_permit"))] +async fn test_publisher_app_refusal_is_not_cached(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = format!( + "http://localhost:{port}/api/w/test-workspace/apps_u/execute_component/u/test-user/papp" + ); + + let run = || async { + let resp = reqwest::Client::new() + .post(&url) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ "component": "c", "path": "script/u/test-user/hello", "args": {} })) + .send() + .await?; + let status = resp.status().as_u16(); + anyhow::Ok((status, resp.text().await?)) + }; + + let (status, body) = run().await?; + assert_eq!(status, 401, "unshared app must be refused: {body}"); + assert!(body.contains("Missing read permissions"), "{body}"); + + sqlx::query("UPDATE app SET extra_perms = '{\"u/test-user-2\": false}' WHERE id = 999101") + .execute(&db) + .await?; + let (status, body) = run().await?; + assert_eq!(status, 200, "a grant must apply to the next run: {body}"); + + Ok(()) +} diff --git a/backend/tests/debounce_e2e.rs b/backend/tests/debounce_e2e.rs index 9a2351f135..709629d825 100644 --- a/backend/tests/debounce_e2e.rs +++ b/backend/tests/debounce_e2e.rs @@ -33,7 +33,7 @@ mod debounce_e2e { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export async function main(items: any[]) { return items }", "input_transforms": { "items": { "type": "javascript", "expr": "flow_input.items" } diff --git a/backend/tests/drafts_delete_for_user.rs b/backend/tests/drafts_delete_for_user.rs new file mode 100644 index 0000000000..9c48f8828c --- /dev/null +++ b/backend/tests/drafts_delete_for_user.rs @@ -0,0 +1,85 @@ +//! Only a workspace admin can delete a draft that belongs to another user. +//! +//! A discard through `drafts/update` is scoped to the caller's own row, so it +//! never reaches someone else's draft. `drafts/delete_for_user` is the admin +//! route for that, and it must stay closed to everyone else. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base"))] +async fn test_only_admin_deletes_another_users_draft(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/drafts"); + let client = reqwest::Client::new(); + let path = "script/u/test-user-2/theirs"; + + let save = |token: &'static str| { + client + .post(format!("{base}/update/{path}")) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "value": { "path": "u/test-user-2/theirs", "content": "" } })) + .send() + }; + let resp = save("SECRET_TOKEN_2").await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let delete = |token: &'static str| { + client + .post(format!( + "{base}/delete_for_user/{path}?username=test-user-2" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + }; + let own_draft = || async { + client + .get(format!("{base}/get_own/{path}")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .send() + .await? + .json::() + .await + }; + + let resp = delete("SECRET_TOKEN_3").await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + assert!( + !own_draft().await?.is_null(), + "a non-admin deleted the draft" + ); + + let resp = delete("SECRET_TOKEN").await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert!(own_draft().await?.is_null(), "the draft is still there"); + + let resp = delete("SECRET_TOKEN").await?; + assert_eq!(resp.status(), 404, "{}", resp.text().await?); + + // Without a username the delete takes every draft at the path. + for token in ["SECRET_TOKEN_2", "SECRET_TOKEN"] { + let resp = save(token).await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + } + let delete_all = |token: &'static str| { + client + .post(format!("{base}/delete_for_user/{path}")) + .header("Authorization", format!("Bearer {token}")) + .send() + }; + let resp = delete_all("SECRET_TOKEN_3").await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + let resp = delete_all("SECRET_TOKEN").await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let left: i64 = sqlx::query_scalar( + "SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND path = 'u/test-user-2/theirs'", + ) + .fetch_one(&db) + .await?; + assert_eq!(left, 0, "drafts survived a path-wide delete"); + Ok(()) +} diff --git a/backend/tests/end_user_email.rs b/backend/tests/end_user_email.rs index 366c220c62..15eb9a64f1 100644 --- a/backend/tests/end_user_email.rs +++ b/backend/tests/end_user_email.rs @@ -1,10 +1,8 @@ //! Tests for WM_END_USER_EMAIL environment variable. //! //! These tests verify that WM_END_USER_EMAIL is populated with the authenticated -//! user's email when executing app components. -//! -//! TODO: Add tests for scripts and flows once public execution endpoints are identified. -//! Currently only apps support non-workspace-member execution via OptAuthed + token lookup. +//! user's email when executing app components and when running scripts and flows directly. +//! Only apps support non-workspace-member execution via OptAuthed + token lookup. use serde_json::json; use sqlx::{Pool, Postgres}; @@ -27,40 +25,6 @@ fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuil builder.header("Authorization", format!("Bearer {}", token)) } -// TODO: Script tests - need to identify public execution endpoints for non-workspace-members -// async fn run_script(port: u16, token: &str) -> anyhow::Result { -// let url = format!( -// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/p/f/test/get_end_user_email", -// port -// ); -// let resp = authed(client().post(&url), token) -// .json(&json!({})) -// .send() -// .await?; -// if !resp.status().is_success() { -// anyhow::bail!("script run failed: {} - {}", resp.status(), resp.text().await?); -// } -// Ok(resp.json::().await? -// .as_str().unwrap_or("").to_string()) -// } - -// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members -// async fn run_flow(port: u16, token: &str) -> anyhow::Result { -// let url = format!( -// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/f/f/test/get_end_user_email_flow", -// port -// ); -// let resp = authed(client().post(&url), token) -// .json(&json!({})) -// .send() -// .await?; -// if !resp.status().is_success() { -// anyhow::bail!("flow run failed: {} - {}", resp.status(), resp.text().await?); -// } -// Ok(resp.json::().await? -// .as_str().unwrap_or("").to_string()) -// } - /// Create an app with inline script via API async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> { let url = format!("http://localhost:{}/api/w/test-workspace/apps/create", port); @@ -74,8 +38,8 @@ async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result< "subgrids": {}, "hiddenInlineScripts": [{ "name": "get_email", - "language": "deno", - "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", + "language": "bun", + "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", "path": "f/test/email_app/get_email" }] }, @@ -89,7 +53,7 @@ async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result< "one_of_inputs": {} }, // SHA256 hash of raw_code content for anonymous execution - "rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": { + "rawscript/894d2f85ea2411e777e7d5787366c9fec4c1a7a5f83482748f1d129196c9f54f": { "static_inputs": {}, "one_of_inputs": {} } @@ -120,8 +84,8 @@ async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Res "css": "", "inlineScripts": [{ "name": "get_email", - "language": "deno", - "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }" + "language": "bun", + "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" }] }, "policy": { @@ -134,7 +98,7 @@ async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Res "one_of_inputs": {} }, // SHA256 hash of raw_code content for anonymous execution - "rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": { + "rawscript/894d2f85ea2411e777e7d5787366c9fec4c1a7a5f83482748f1d129196c9f54f": { "static_inputs": {}, "one_of_inputs": {} } @@ -233,8 +197,8 @@ async fn run_app_inline_script( "args": {}, "component": "get_email", "raw_code": { - "language": "deno", - "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", + "language": "bun", + "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", "path": format!("{}/get_email", app_path) } }); @@ -270,8 +234,8 @@ async fn run_raw_app_inline_script( "args": {}, "component": "get_email", "raw_code": { - "language": "deno", - "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }" + "language": "bun", + "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" } }); if force_viewer { @@ -312,41 +276,111 @@ async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Re anyhow::bail!("timeout waiting for job result") } -// TODO: Script tests - need to identify public execution endpoints for non-workspace-members -// #[cfg(feature = "deno_core")] -// #[sqlx::test(fixtures("base", "end_user_email"))] -// async fn test_script_wm_end_user_email(db: Pool) -> anyhow::Result<()> { -// initialize_tracing().await; -// set_jwt_secret().await; -// let server = ApiServer::start(db.clone()).await?; -// let port = server.addr.port(); -// -// in_test_worker(Connection::Sql(db.clone()), async move { -// let result = run_script(port, SAME_WS_TOKEN).await?; -// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email"); -// Ok::<(), anyhow::Error>(()) -// }, port).await?; -// -// Ok(()) -// } +async fn run_direct(port: u16, token: &str, route: &str) -> anyhow::Result { + let url = format!( + "http://localhost:{}/api/w/test-workspace/jobs/run/{}", + port, route + ); + let resp = authed(client().post(&url), token) + .json(&json!({})) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!( + "run {route} failed: {} - {}", + resp.status(), + resp.text().await? + ); + } + let job_id = resp.text().await?; + wait_for_job_result(port, token, &job_id).await +} -// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members -// #[cfg(feature = "deno_core")] -// #[sqlx::test(fixtures("base", "end_user_email"))] -// async fn test_flow_wm_end_user_email(db: Pool) -> anyhow::Result<()> { -// initialize_tracing().await; -// set_jwt_secret().await; -// let server = ApiServer::start(db.clone()).await?; -// let port = server.addr.port(); -// -// in_test_worker(Connection::Sql(db.clone()), async move { -// let result = run_flow(port, SAME_WS_TOKEN).await?; -// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email"); -// Ok::<(), anyhow::Error>(()) -// }, port).await?; -// -// Ok(()) -// } +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "end_user_email"))] +async fn test_direct_run_wm_end_user_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + in_test_worker( + Connection::Sql(db.clone()), + async move { + let result = run_direct(port, SAME_WS_TOKEN, "p/f/test/get_end_user_email").await?; + assert_eq!(result, SAME_WS_EMAIL, "a script run should see its caller"); + + let result = + run_direct(port, SAME_WS_TOKEN, "f/f/test/get_end_user_email_flow").await?; + assert_eq!( + result, SAME_WS_EMAIL, + "every flow step should see the flow's caller" + ); + + Ok::<(), anyhow::Error>(()) + }, + port, + ) + .await?; + + Ok(()) +} + +/// A job's `WM_TOKEN` names the identity the job runs as, which differs from its end user when +/// the job runs on behalf of another account. A run it starts must keep the job's end user. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "end_user_email"))] +async fn test_job_token_run_keeps_end_user_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let calling_job = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email) + VALUES ($1, 'test2@windmill.dev', 'test-user-2', false, false, '{}', '{}', 'test-workspace', $2)", + ) + .bind(calling_job) + .bind(SAME_WS_EMAIL) + .execute(&db) + .await?; + let wm_token = windmill_common::auth::create_jwt_token( + windmill_common::db::Authed { + email: "test2@windmill.dev".to_string(), + username: "test-user-2".to_string(), + is_admin: false, + is_operator: false, + groups: vec!["all".to_string()], + folders: vec![], + scopes: None, + token_prefix: None, + }, + "test-workspace", + 3600, + Some(calling_job), + None, + None, + None, + ) + .await?; + + in_test_worker( + Connection::Sql(db.clone()), + async move { + let result = run_direct(port, &wm_token, "p/f/test/get_end_user_email").await?; + assert_eq!( + result, SAME_WS_EMAIL, + "the calling job's end user, not its run-as identity" + ); + Ok::<(), anyhow::Error>(()) + }, + port, + ) + .await?; + + Ok(()) +} #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base", "end_user_email"))] @@ -475,3 +509,89 @@ async fn test_raw_app_wm_end_user_email(db: Pool) -> anyhow::Result<() Ok(()) } + +/// Connecting to data tables as the viewer is honored on inline runnables only: a path runnable +/// is someone else's script, which must not read with the viewer's roles because the app's +/// policy entry for it says so. +#[sqlx::test(fixtures("base", "end_user_email"))] +async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let app_path = "f/test/viewer_data_access_app"; + let script_path = "script/f/test/get_end_user_email"; + + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/apps/create" + )), + SAME_WS_TOKEN, + ) + .json(&json!({ + "path": app_path, + "summary": "", + "value": { "type": "app", "grid": [], "subgrids": {}, "hiddenInlineScripts": [] }, + "policy": { + "execution_mode": "anonymous", + "on_behalf_of": null, + "on_behalf_of_email": null, + "triggerables_v2": { + "rawscript/894d2f85ea2411e777e7d5787366c9fec4c1a7a5f83482748f1d129196c9f54f": { + "static_inputs": {}, "one_of_inputs": {}, "viewer_data_access": true + }, + (script_path): { + "static_inputs": {}, "one_of_inputs": {}, "viewer_data_access": true + } + } + } + })) + .send() + .await?; + anyhow::ensure!(resp.status().is_success(), "create app: {}", resp.text().await?); + + let execute = |payload: serde_json::Value| async move { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/apps_u/execute_component/{app_path}" + )), + SAME_WS_TOKEN, + ) + .json(&payload) + .send() + .await?; + anyhow::ensure!(resp.status().is_success(), "execute: {}", resp.text().await?); + Ok::<_, anyhow::Error>(resp.text().await?.trim_matches('"').parse::()?) + }; + let as_viewer = |job_id: uuid::Uuid| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, bool>( + "SELECT viewer_data_access FROM job_perms WHERE job_id = $1", + ) + .bind(job_id) + .fetch_one(&db) + .await + } + }; + + let inline = execute(json!({ + "args": {}, + "component": "get_email", + "raw_code": { + "language": "bun", + "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" + } + })) + .await?; + assert!(as_viewer(inline).await?); + + let path = execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?; + assert!( + !as_viewer(path).await?, + "a path runnable connected as the viewer because its policy entry said so" + ); + Ok(()) +} diff --git a/backend/tests/error_handler.rs b/backend/tests/error_handler.rs index 4f58685221..2e10006d2f 100644 --- a/backend/tests/error_handler.rs +++ b/backend/tests/error_handler.rs @@ -104,7 +104,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 1111111111, 'f/test/error_handler', $1, 'deno', 'script', 'test-user', '{}', 'Error handler script', 'Handles failed job completions', '') + VALUES ('test-workspace', 1111111111, 'f/test/error_handler', $1, 'bun', 'script', 'test-user', '{}', 'Error handler script', 'Handles failed job completions', E'{}\n//bun.lock\n') "#, error_handler_code ) @@ -118,7 +118,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', $1, 'f/test/failing_script', $2, 'deno', 'script', 'test-user', '{}', 'Failing test script', 'A script that always fails', '') + VALUES ('test-workspace', $1, 'f/test/failing_script', $2, 'bun', 'script', 'test-user', '{}', 'Failing test script', 'A script that always fails', E'{}\n//bun.lock\n') "#, failing_script_hash, failing_script_code @@ -155,7 +155,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - language: ScriptLang::Deno, + language: ScriptLang::Bun, priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), @@ -234,7 +234,7 @@ async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Resul sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 3333333333, 'f/test/error_handler', 'export function main() { return "handled"; }', 'deno', 'script', 'test-user', '{}', '', '', '') + VALUES ('test-workspace', 3333333333, 'f/test/error_handler', 'export function main() { return "handled"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n') "#, ) .execute(&db) @@ -245,7 +245,7 @@ async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Resul sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, ws_error_handler_muted) - VALUES ('test-workspace', $1, 'f/test/muted_failing_script', 'export function main() { throw new Error("fail"); }', 'deno', 'script', 'test-user', '{}', '', '', '', true) + VALUES ('test-workspace', $1, 'f/test/muted_failing_script', 'export function main() { throw new Error("fail"); }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n', true) "#, failing_script_hash, ) @@ -280,7 +280,7 @@ async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Resul cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - language: ScriptLang::Deno, + language: ScriptLang::Bun, priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), @@ -331,7 +331,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyh sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 5555555555, 'f/test/error_handler', 'export function main() { return "handled"; }', 'deno', 'script', 'test-user', '{}', '', '', '') + VALUES ('test-workspace', 5555555555, 'f/test/error_handler', 'export function main() { return "handled"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n') "#, ) .execute(&db) @@ -342,7 +342,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyh sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', $1, 'f/test/success_script', 'export function main() { return "ok"; }', 'deno', 'script', 'test-user', '{}', '', '', '') + VALUES ('test-workspace', $1, 'f/test/success_script', 'export function main() { return "ok"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n') "#, success_script_hash, ) @@ -377,7 +377,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyh cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - language: ScriptLang::Deno, + language: ScriptLang::Bun, priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), @@ -411,3 +411,70 @@ async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyh Ok(()) } + +/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job, +/// and its failure must reach the workspace error handler like a workspace script's. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(fixtures("base"))] +async fn test_error_handler_triggered_on_hub_script_failure( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::jobs::JobPayload; + + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + + let version = "990000002"; + let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; + tokio::fs::create_dir_all(hub_dir).await?; + tokio::fs::write( + format!("{hub_dir}/{version}"), + r#"{"content":"exit 1","lockfile":null,"language":"bash","schema":{},"summary":null}"#, + ) + .await?; + + // The workspace handler settings are cached per workspace id across the tests of this + // binary, so this reuses the handler path every other test here configures. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) + VALUES ('test-workspace', 1111111112, 'f/test/error_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')", + ) + .execute(&db) + .await?; + sqlx::query( + r#"UPDATE workspace_settings SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + + let completed_job = RunJob::from(JobPayload::ScriptHub { + path: format!("hub/{version}/test/fail"), + apply_preprocessor: false, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!(!completed_job.success, "Hub script should have failed"); + + let mut handler_parent = None; + for _ in 0..50 { + handler_parent = sqlx::query_scalar::<_, Option>( + "SELECT parent_job FROM v2_job + WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'error_handler@windmill.dev'", + ) + .fetch_optional(&db) + .await?; + if handler_parent.is_some() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + assert_eq!( + handler_parent, + Some(Some(completed_job.id)), + "Workspace error handler should handle the failed hub script" + ); + + Ok(()) +} diff --git a/backend/tests/fixtures/app_publisher_permit.sql b/backend/tests/fixtures/app_publisher_permit.sql new file mode 100644 index 0000000000..c169e53ec5 --- /dev/null +++ b/backend/tests/fixtures/app_publisher_permit.sql @@ -0,0 +1,10 @@ +-- A publisher-mode app owned by `test-user`, not shared with anyone, whose one +-- runnable is a deployed script. + +INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES + ('test-workspace', 'test-user', 'export async function main() { return "ok" }', '{}', '', '', 'u/test-user/hello', 7788101, 'deno', ''); + +INSERT INTO app (id, workspace_id, path, summary, policy, versions) VALUES + (999101, 'test-workspace', 'u/test-user/papp', 'publisher app', + '{"execution_mode": "publisher", "on_behalf_of": "u/test-user", "on_behalf_of_email": "test@windmill.dev", + "triggerables_v2": {"c:script/u/test-user/hello": {"static_inputs": {}, "one_of_inputs": {}}}}'::jsonb, '{}'); diff --git a/backend/tests/fixtures/end_user_email.sql b/backend/tests/fixtures/end_user_email.sql index 596d34e3e3..3b376525a4 100644 --- a/backend/tests/fixtures/end_user_email.sql +++ b/backend/tests/fixtures/end_user_email.sql @@ -40,9 +40,9 @@ VALUES (encode(sha256('NO_WS_TOKEN'::bytea), 'hex'), 'NO_WS_TOKE', 'NO_WS_TOKEN' INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms) VALUES ( 'test-workspace', 'test-user', - 'export function main() { return Deno.env.get("WM_END_USER_EMAIL") || ""; }', + 'export function main() { return process.env.WM_END_USER_EMAIL || ""; }', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', - 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email', 900001, 'deno', '', 'script', + 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email', 900001, 'bun', E'{}\n//bun.lock\n', 'script', '{"g/all": true}' ); @@ -51,7 +51,7 @@ INSERT INTO flow (workspace_id, summary, description, path, versions, schema, va VALUES ( 'test-workspace', 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email_flow', '{900002}', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', - '{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}, {"id": "b", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}', + '{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "bun", "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", "input_transforms": {}}}, {"id": "b", "value": {"type": "rawscript", "language": "bun", "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", "input_transforms": {}}}]}', 'test-user', '{"g/all": true}' ); @@ -60,6 +60,6 @@ INSERT INTO flow_version (id, workspace_id, path, schema, value, created_by) VALUES ( 900002, 'test-workspace', 'f/test/get_end_user_email_flow', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', - '{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}, {"id": "b", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}', + '{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "bun", "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", "input_transforms": {}}}, {"id": "b", "value": {"type": "rawscript", "language": "bun", "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", "input_transforms": {}}}]}', 'test-user' ); diff --git a/backend/tests/fixtures/hello.sql b/backend/tests/fixtures/hello.sql index 962f6441d9..4c40ac3290 100644 --- a/backend/tests/fixtures/hello.sql +++ b/backend/tests/fixtures/hello.sql @@ -11,7 +11,7 @@ export function main(world: string) { '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"world":{"default":"world","description":"","type":"string"}},"required":[],"type":"object"}', '', '', -'f/system/hello', 123412, 'deno', ''); +'f/system/hello', 123412, 'bun', E'{}\n//bun.lock\n'); INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( 'test-workspace', @@ -28,7 +28,7 @@ export async function main(foo: string, bar: string) { '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"foo":{"default":null,"description":"","originalType":"string","type":"string"},"bar":{"default":null,"description":"","originalType":"string","type":"string"}},"required":["foo","bar"],"type":"object"}', '', '', -'f/system/hello_with_preprocessor', 123413, 'deno', ''); +'f/system/hello_with_preprocessor', 123413, 'bun', E'{}\n//bun.lock\n'); INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( 'test-workspace', @@ -140,7 +140,7 @@ INSERT INTO public.flow(workspace_id, summary, description, path, versions, sche 'f/system/hello_with_nodes_flow', '{1443253234253454}', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"world":{"default":"world","description":"","type":"string"}},"required":[],"type":"object"}', -E'{"modules":[{"id":"a","value":{"type":"forloopflow","modules":[{"id":"b","value":{"type":"rawscript","content":"export function main(world: string) {\\n const greet = `Hello ${world}!`;\\n console.log(greet)\\n return greet\\n}\\n","language":"deno","input_transforms":{"world":{"type":"javascript","expr":"flow_input.iter.value"}},"is_trigger":false}},{"id":"c","value":{"type":"rawscript","content":"export function main(hello: string) {\\n const dareyou = `Did you just say \\"${hello}\\"??!`;\\n console.log(dareyou)\\n return dareyou\\n}","language":"deno","input_transforms":{"hello":{"type":"javascript","value":"${results.b}","expr":"`${results.b}`"}},"is_trigger":false}}],"iterator":{"type":"javascript","expr":"[''foo'', ''bar'', ''baz'']"},"skip_failures":true,"parallel":false}}],"same_worker":false}', +E'{"modules":[{"id":"a","value":{"type":"forloopflow","modules":[{"id":"b","value":{"type":"rawscript","content":"export function main(world: string) {\\n const greet = `Hello ${world}!`;\\n console.log(greet)\\n return greet\\n}\\n","language":"bun","input_transforms":{"world":{"type":"javascript","expr":"flow_input.iter.value"}},"is_trigger":false}},{"id":"c","value":{"type":"rawscript","content":"export function main(hello: string) {\\n const dareyou = `Did you just say \\"${hello}\\"??!`;\\n console.log(dareyou)\\n return dareyou\\n}","language":"bun","input_transforms":{"hello":{"type":"javascript","value":"${results.b}","expr":"`${results.b}`"}},"is_trigger":false}}],"iterator":{"type":"javascript","expr":"[''foo'', ''bar'', ''baz'']"},"skip_failures":true,"parallel":false}}],"same_worker":false}', 'system' ); @@ -149,7 +149,7 @@ INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_b 'test-workspace', 'f/system/hello_with_nodes_flow', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"world":{"default":"world","description":"","type":"string"}},"required":[],"type":"object"}', -E'{"modules":[{"id":"a","value":{"type":"forloopflow","modules":[{"id":"b","value":{"type":"rawscript","content":"export function main(world: string) {\\n const greet = `Hello ${world}!`;\\n console.log(greet)\\n return greet\\n}\\n","language":"deno","input_transforms":{"world":{"type":"javascript","expr":"flow_input.iter.value"}},"is_trigger":false}},{"id":"c","value":{"type":"rawscript","content":"export function main(hello: string) {\\n const dareyou = `Did you just say \\"${hello}\\"??!`;\\n console.log(dareyou)\\n return dareyou\\n}","language":"deno","input_transforms":{"hello":{"type":"javascript","value":"${results.b}","expr":"`${results.b}`"}},"is_trigger":false}}],"iterator":{"type":"javascript","expr":"[''foo'', ''bar'', ''baz'']"},"skip_failures":true,"parallel":false}}],"same_worker":false}', +E'{"modules":[{"id":"a","value":{"type":"forloopflow","modules":[{"id":"b","value":{"type":"rawscript","content":"export function main(world: string) {\\n const greet = `Hello ${world}!`;\\n console.log(greet)\\n return greet\\n}\\n","language":"bun","input_transforms":{"world":{"type":"javascript","expr":"flow_input.iter.value"}},"is_trigger":false}},{"id":"c","value":{"type":"rawscript","content":"export function main(hello: string) {\\n const dareyou = `Did you just say \\"${hello}\\"??!`;\\n console.log(dareyou)\\n return dareyou\\n}","language":"bun","input_transforms":{"hello":{"type":"javascript","value":"${results.b}","expr":"`${results.b}`"}},"is_trigger":false}}],"iterator":{"type":"javascript","expr":"[''foo'', ''bar'', ''baz'']"},"skip_failures":true,"parallel":false}}],"same_worker":false}', 'system' ); @@ -160,7 +160,7 @@ INSERT INTO public.flow(workspace_id, summary, description, path, versions, sche 'f/system/hello_with_preprocessor', '{1443253234253456}', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"foo":{"type":"string","description":"","default":""},"bar":{"type":"string","description":"","default":""}},"required":[],"type":"object","order":["foo","bar"]}', -E'{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main(foo: string, bar: string) {\\n return ''Hello '' + foo + ''-'' + bar\\n}\\n","language":"deno","is_trigger":false,"input_transforms":{"bar":{"expr":"flow_input.bar","type":"javascript"},"foo":{"expr":"flow_input.foo","type":"javascript"}}}}],"preprocessor_module":{"id":"preprocessor","value":{"type":"rawscript","content":"export async function preprocessor(\\n\\twm_trigger: {\\n\\t\\tkind: ''http'' | ''email'' | ''webhook'' | ''websocket'' | ''kafka'' | ''nats'',\\n\\t\\thttp?: {\\n\\t\\t\\troute: string\\n\\t\\t\\tpath: string\\n\\t\\t\\tmethod: string\\n\\t\\t\\tparams: Record\\n\\t\\t\\tquery: Record\\n\\t\\t\\theaders: Record\\n\\t\\t},\\n\\t\\twebsocket?: {\\n\\t\\t\\turl: string\\n\\t\\t},\\n\\t\\tkafka?: {\\n\\t\\t\\tbrokers: string[]\\n\\t\\t\\ttopic: string\\n\\t\\t\\tgroup_id: string\\n\\t\\t},\\n\\t\\tnats?: {\\n\\t\\t\\tservers: string[]\\n\\t\\t\\tsubject: string\\n\\t\\t\\theaders?: Record\\n\\t\\t\\tstatus?: number\\n\\t\\t\\tdescription?: string\\n\\t\\t\\tlength: number\\n\\t\\t}\\n\\t},\\n) {\\n\\treturn {\\n\\t\\tfoo: ''bar'',\\n\\t\\tbar: ''baz''\\n\\t}\\n}","language":"deno","is_trigger":false,"input_transforms":{"wm_trigger":{"type":"static"}}}}}', +E'{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main(foo: string, bar: string) {\\n return ''Hello '' + foo + ''-'' + bar\\n}\\n","language":"bun","is_trigger":false,"input_transforms":{"bar":{"expr":"flow_input.bar","type":"javascript"},"foo":{"expr":"flow_input.foo","type":"javascript"}}}}],"preprocessor_module":{"id":"preprocessor","value":{"type":"rawscript","content":"export async function preprocessor(\\n\\twm_trigger: {\\n\\t\\tkind: ''http'' | ''email'' | ''webhook'' | ''websocket'' | ''kafka'' | ''nats'',\\n\\t\\thttp?: {\\n\\t\\t\\troute: string\\n\\t\\t\\tpath: string\\n\\t\\t\\tmethod: string\\n\\t\\t\\tparams: Record\\n\\t\\t\\tquery: Record\\n\\t\\t\\theaders: Record\\n\\t\\t},\\n\\t\\twebsocket?: {\\n\\t\\t\\turl: string\\n\\t\\t},\\n\\t\\tkafka?: {\\n\\t\\t\\tbrokers: string[]\\n\\t\\t\\ttopic: string\\n\\t\\t\\tgroup_id: string\\n\\t\\t},\\n\\t\\tnats?: {\\n\\t\\t\\tservers: string[]\\n\\t\\t\\tsubject: string\\n\\t\\t\\theaders?: Record\\n\\t\\t\\tstatus?: number\\n\\t\\t\\tdescription?: string\\n\\t\\t\\tlength: number\\n\\t\\t}\\n\\t},\\n) {\\n\\treturn {\\n\\t\\tfoo: ''bar'',\\n\\t\\tbar: ''baz''\\n\\t}\\n}","language":"bun","is_trigger":false,"input_transforms":{"wm_trigger":{"type":"static"}}}}}', 'system' ); @@ -169,6 +169,6 @@ INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_b 'test-workspace', 'f/system/hello_with_preprocessor', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"foo":{"type":"string","description":"","default":""},"bar":{"type":"string","description":"","default":""}},"required":[],"type":"object","order":["foo","bar"]}', -E'{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main(foo: string, bar: string) {\\n return ''Hello '' + foo + ''-'' + bar\\n}\\n","language":"deno","is_trigger":false,"input_transforms":{"bar":{"expr":"flow_input.bar","type":"javascript"},"foo":{"expr":"flow_input.foo","type":"javascript"}}}}],"preprocessor_module":{"id":"preprocessor","value":{"type":"rawscript","content":"export async function preprocessor(\\n\\twm_trigger: {\\n\\t\\tkind: ''http'' | ''email'' | ''webhook'' | ''websocket'' | ''kafka'' | ''nats'',\\n\\t\\thttp?: {\\n\\t\\t\\troute: string\\n\\t\\t\\tpath: string\\n\\t\\t\\tmethod: string\\n\\t\\t\\tparams: Record\\n\\t\\t\\tquery: Record\\n\\t\\t\\theaders: Record\\n\\t\\t},\\n\\t\\twebsocket?: {\\n\\t\\t\\turl: string\\n\\t\\t},\\n\\t\\tkafka?: {\\n\\t\\t\\tbrokers: string[]\\n\\t\\t\\ttopic: string\\n\\t\\t\\tgroup_id: string\\n\\t\\t},\\n\\t\\tnats?: {\\n\\t\\t\\tservers: string[]\\n\\t\\t\\tsubject: string\\n\\t\\t\\theaders?: Record\\n\\t\\t\\tstatus?: number\\n\\t\\t\\tdescription?: string\\n\\t\\t\\tlength: number\\n\\t\\t}\\n\\t},\\n) {\\n\\treturn {\\n\\t\\tfoo: ''bar'',\\n\\t\\tbar: ''baz''\\n\\t}\\n}","language":"deno","is_trigger":false,"input_transforms":{"wm_trigger":{"type":"static"}}}}}', +E'{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main(foo: string, bar: string) {\\n return ''Hello '' + foo + ''-'' + bar\\n}\\n","language":"bun","is_trigger":false,"input_transforms":{"bar":{"expr":"flow_input.bar","type":"javascript"},"foo":{"expr":"flow_input.foo","type":"javascript"}}}}],"preprocessor_module":{"id":"preprocessor","value":{"type":"rawscript","content":"export async function preprocessor(\\n\\twm_trigger: {\\n\\t\\tkind: ''http'' | ''email'' | ''webhook'' | ''websocket'' | ''kafka'' | ''nats'',\\n\\t\\thttp?: {\\n\\t\\t\\troute: string\\n\\t\\t\\tpath: string\\n\\t\\t\\tmethod: string\\n\\t\\t\\tparams: Record\\n\\t\\t\\tquery: Record\\n\\t\\t\\theaders: Record\\n\\t\\t},\\n\\t\\twebsocket?: {\\n\\t\\t\\turl: string\\n\\t\\t},\\n\\t\\tkafka?: {\\n\\t\\t\\tbrokers: string[]\\n\\t\\t\\ttopic: string\\n\\t\\t\\tgroup_id: string\\n\\t\\t},\\n\\t\\tnats?: {\\n\\t\\t\\tservers: string[]\\n\\t\\t\\tsubject: string\\n\\t\\t\\theaders?: Record\\n\\t\\t\\tstatus?: number\\n\\t\\t\\tdescription?: string\\n\\t\\t\\tlength: number\\n\\t\\t}\\n\\t},\\n) {\\n\\treturn {\\n\\t\\tfoo: ''bar'',\\n\\t\\tbar: ''baz''\\n\\t}\\n}","language":"bun","is_trigger":false,"input_transforms":{"wm_trigger":{"type":"static"}}}}}', 'system' ); diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index e32b777a74..e175a9c065 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -98,6 +98,32 @@ INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, resu ('18181818-1818-1818-1818-181818181818', 'test-workspace', 1000, 'success'::job_status, '{"agent": "AGENT_CHAT_RESULT"}'); +-- A lock job for flow `f/shared/flow1`, as `wmill generate-metadata` queues it, and the +-- token CI would lock with. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('RUN_SCOPED_DEPS_TOKEN'::bytea), 'hex'), 'RUN_DEPS_', 'RUN_SCOPED_DEPS_TOKEN', + 'test2@windmill.dev', 'lock token', false, + ARRAY['jobs:run:dependencies:f/shared/*'] +); +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, runnable_path, tag, visible_to_owner +) VALUES ( + '19191919-1919-1919-1919-191919191919', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flowdependencies', 'f/shared/flow1', 'dependency', true +), ( + -- Queued by a broader token with `*` as its path, which the request body allows. + '20202020-2020-2020-2020-202020202020', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'dependencies', '*', 'dependency', true +); +INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES + ('19191919-1919-1919-1919-191919191919', 'test-workspace', 1000, 'success'::job_status, + '{"lock": "DEPS_RESULT"}'), + ('20202020-2020-2020-2020-202020202020', 'test-workspace', 1000, 'success'::job_status, + '{"lock": "WILDCARD_DEPS_RESULT"}'); + -- A token pairing an app scope with a run scope, as someone driving an app's components -- programmatically would build. `APP_INLINE_JOB` is an inline-script component run: no -- `jobs:run` scope can name its kind, so only the `apps:run` half puts it in reach. diff --git a/backend/tests/fixtures/mcp_resource_authz.sql b/backend/tests/fixtures/mcp_resource_authz.sql index ecf3f01dc3..17fcc553b9 100644 --- a/backend/tests/fixtures/mcp_resource_authz.sql +++ b/backend/tests/fixtures/mcp_resource_authz.sql @@ -23,3 +23,19 @@ VALUES ( '{}', 'test-user' ); + +-- MCP resource whose URL lives in a variable. The host only appears in the +-- variable, so an error naming it proves the reference was resolved. +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/test-user/mcp_url', 'https://mcp-from-var.invalid.windmill.test/key', false, '', '{}'); + +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ( + 'test-workspace', + 'u/test-user/var_mcp', + '{"name": "var_mcp", "url": "$var:u/test-user/mcp_url"}', + 'MCP resource with its URL in a variable', + 'mcp', + '{}', + 'test-user' +); diff --git a/backend/tests/fixtures/schedule.sql b/backend/tests/fixtures/schedule.sql index 8d851369b0..999aa4770f 100644 --- a/backend/tests/fixtures/schedule.sql +++ b/backend/tests/fixtures/schedule.sql @@ -13,7 +13,7 @@ export async function main(fail: boolean = true) { '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"fail":{"default":true,"description":"","type":"boolean"}},"required":[],"type":"object"}', '', '', -'f/system/failing_script', 12349, 'deno', ''); +'f/system/failing_script', 12349, 'bun', E'{}\n//bun.lock\n'); INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( 'test-workspace', @@ -26,7 +26,7 @@ export async function main() { '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"path":{"default":null,"description":"","type":"string"},"schedule_path":{"default":null,"description":"","type":"string"},"error":{"default":null,"description":"","properties":{},"type":"object"}},"required":["path","schedule_path","error"],"type":"object"}', '', '', -'f/system/schedule_error_handler', 123410, 'deno', ''); +'f/system/schedule_error_handler', 123410, 'bun', E'{}\n//bun.lock\n'); INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( 'test-workspace', @@ -39,7 +39,7 @@ export async function main() { '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"path":{"default":null,"description":"","type":"string"},"schedule_path":{"default":null,"description":"","type":"string"},"previous_job_error":{"default":null,"description":"","type":"string"},"result":{"default":null,"description":"","type":"string"}},"required":["path","schedule_path","previous_job_error","result"],"type":"object"}', '', '', -'f/system/schedule_recovery_handler', 123411, 'deno', ''); +'f/system/schedule_recovery_handler', 123411, 'bun', E'{}\n//bun.lock\n'); INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ( 'test-workspace', diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index 3806bb2886..2d4f72f336 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -85,7 +85,7 @@ async fn test_flow_linear_input_transforms(db: Pool) -> anyhow::Result modules: vec![ flow_module("a", FlowModuleValue::RawScript { input_transforms: [static_input("x", 10), static_input("y", 5)].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(x: number, y: number) { return {sum: x + y, product: x * y, items: [1, 2, 3, 4, 5]}; @@ -105,7 +105,7 @@ export function main(x: number, y: number) { js_input("filtered", "results.a.items.filter(x => x > 2)"), js_input("from_flow_input", "flow_input.multiplier * results.a.sum"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(total: number, doubled_items: number[], filtered: number[], from_flow_input: number) { return {total, doubled_items, filtered, from_flow_input}; @@ -157,7 +157,7 @@ async fn test_flow_forloop_complex_expressions(db: Pool) -> anyhow::Re "a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -199,7 +199,7 @@ export function main() { js_input("index", "flow_input.iter.index"), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(user_name: string, user_score: number, bonus: number, index: number) { return {name: user_name, final_score: user_score + bonus, position: index}; @@ -261,7 +261,7 @@ async fn test_flow_branchone_conditions(db: Pool) -> anyhow::Result<() "a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {status: "premium", score: 95, items: [1, 2, 3]}; @@ -293,7 +293,7 @@ export function main() { js_input("score_from_a", "results.a.score"), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(discount: number, score_from_a: number) { return {branch: "premium_high", discount, score_from_a}; @@ -320,7 +320,7 @@ export function main(discount: number, score_from_a: number) { "premium_low", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {branch: "premium_low", discount: 10}; @@ -344,7 +344,7 @@ export function main() { "default_branch", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {branch: "default", discount: 0}; @@ -398,7 +398,7 @@ async fn test_flow_previous_result_aggregation(db: Pool) -> anyhow::Re "a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {value: 10, items: [1, 2, 3]}; @@ -424,7 +424,7 @@ export function main() { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(prev_value: number, prev_items_sum: number) { return {value: prev_value * 2, sum: prev_items_sum}; @@ -452,7 +452,7 @@ export function main(prev_value: number, prev_items_sum: number) { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(a_value: number, b_value: number, b_sum: number, combined: number) { return {a_value, b_value, b_sum, combined}; @@ -504,7 +504,7 @@ async fn test_flow_nested_complexity(db: Pool) -> anyhow::Result<()> { modules: vec![ flow_module("data", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -541,7 +541,7 @@ export function main() { js_input("values", "results.data.base_values.map(v => v * flow_input.iter.value.multiplier)"), js_input("sum", "results.data.base_values.reduce((a, b) => a + b, 0) * flow_input.iter.value.multiplier"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(cat_name: string, multiplier: number, values: number[], sum: number) { return {category: cat_name, multiplier, computed_values: values, total: sum}; @@ -602,7 +602,7 @@ async fn test_flow_object_transformations(db: Pool) -> anyhow::Result< modules: vec![ flow_module("source", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -633,7 +633,7 @@ export function main() { js_input("active_bonus", "results.source.config.activeBonus"), js_input("all_users", "results.source.users"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(admins: string[], active_count: number, admin_bonus: number, active_bonus: number, all_users: any[]) { // Compute user_summary and total_bonus in the script since complex expressions in input_transforms @@ -702,7 +702,7 @@ async fn test_flow_skip_if_expressions(db: Pool) -> anyhow::Result<()> "check", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {should_skip: true, value: 100}; @@ -722,7 +722,7 @@ export function main() { "maybe_skipped", FlowModuleValue::RawScript { input_transforms: [js_input("input_val", "results.check.value * 2")].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(input_val: number) { return {processed: input_val, was_run: true}; @@ -750,7 +750,7 @@ export function main(input_val: number) { js_input("prev", "previous_result"), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(check_val: number, prev: any) { return {check_val, previous: prev}; @@ -799,7 +799,7 @@ async fn test_flow_template_literals(db: Pool) -> anyhow::Result<()> { modules: vec![ flow_module("data", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -825,7 +825,7 @@ export function main() { js_input("upper_name", "results.data.firstName.toUpperCase()"), js_input("items_formatted", "`Items: ${results.data.items.map(i => i.charAt(0).toUpperCase() + i.slice(1)).join(', ')}`"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(full_name: string, greeting: string, items_str: string, upper_name: string, items_formatted: string) { return {full_name, greeting, items_str, upper_name, items_formatted}; @@ -873,7 +873,7 @@ async fn test_flow_optional_chaining(db: Pool) -> anyhow::Result<()> { modules: vec![ flow_module("data", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -905,7 +905,7 @@ export function main() { js_input("default_city", "results.data.partial_user?.address?.city ?? 'Unknown'"), js_input("default_country", "results.data.user?.address?.country ?? 'USA'"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(city: string, missing_city: any, null_user_name: any, default_city: string, default_country: string) { return {city, missing_city, null_user_name, default_city, default_country}; @@ -955,7 +955,7 @@ async fn test_flow_parallel_forloop(db: Pool) -> anyhow::Result<()> { "data", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {items: [1, 2, 3, 4, 5]}; @@ -990,7 +990,7 @@ export function main() { js_input("cubed", "flow_input.iter.value.value ** 3"), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(original: number, squared: number, cubed: number) { return {original, squared, cubed}; @@ -1089,7 +1089,7 @@ async fn test_flow_env_access(db: Pool) -> anyhow::Result<()> { js_input("retry_count", "flow_env.CONFIG.retries"), js_input("has_auth", "flow_env.CONFIG.features.includes('auth')"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(env_name: string, is_debug: boolean, timeout_val: number, api_url: string, retry_count: number, has_auth: boolean) { return {env_name, is_debug, timeout_val, api_url, retry_count, has_auth}; @@ -1158,7 +1158,7 @@ async fn test_flow_input_and_env_combined(db: Pool) -> anyhow::Result< // Conditional based on both js_input("multiplier", "flow_env.ENV === 'production' ? flow_input.prod_mult : 1"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(effective_limit: number, env_prefix: string, is_prod: boolean, doubled_input: number, multiplier: number) { return {effective_limit, env_prefix, is_prod, doubled_input, final_value: doubled_input * multiplier}; @@ -1215,7 +1215,7 @@ async fn test_flow_results_optional_chaining(db: Pool) -> anyhow::Resu "a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -1287,7 +1287,7 @@ export function main() { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main( user_name: string, @@ -1361,7 +1361,7 @@ async fn test_flow_large_integers(db: Pool) -> anyhow::Result<()> { "a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -1401,7 +1401,7 @@ export function main() { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main( small: number, i32_max: number, over_i32: number, @@ -1459,7 +1459,7 @@ async fn test_flow_unicode_emoji(db: Pool) -> anyhow::Result<()> { modules: vec![ flow_module("a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -1489,7 +1489,7 @@ export function main() { js_input("mixed_upper", "results.a.mixed.toUpperCase()"), js_input("template", "`Welcome: ${results.a.greeting}`"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main( greeting: string, greeting_len: number, simple_str: string, has_world: boolean, @@ -1544,7 +1544,7 @@ async fn test_flow_complex_array_operations(db: Pool) -> anyhow::Resul modules: vec![ flow_module("a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -1585,7 +1585,7 @@ export function main() { js_input("first_three", "results.a.numbers.slice(0, 3)"), js_input("last_two", "results.a.numbers.slice(-2)"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main( sorted_asc: number[], sorted_desc: number[], active_names: string[], @@ -1651,7 +1651,7 @@ async fn test_flow_multiline_expressions(db: Pool) -> anyhow::Result<( modules: vec![ flow_module("a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -1710,7 +1710,7 @@ export function main() { } "#), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main( subtotal: number, discounted_total: number, @@ -1768,7 +1768,7 @@ async fn test_flow_spread_with_results(db: Pool) -> anyhow::Result<()> modules: vec![ flow_module("a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -1803,7 +1803,7 @@ export function main() { // Combining multiple spreads js_input("combined", "{config: {...results.a.config}, tags: [...results.a.tags], source: 'flow'}"), ].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main( merged_config: any, all_tags: string[], full_user: any, @@ -1869,7 +1869,7 @@ async fn test_flow_nested_forloop_results_access(db: Pool) -> anyhow:: "a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { @@ -1908,7 +1908,7 @@ export function main() { js_input("multiplier", "results.a.multiplier"), // Access outer step from inside for-loop ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(category: string, multiplier: number) { return { @@ -1939,7 +1939,7 @@ export function main(category: string, multiplier: number) { js_input("original_mult", "results.a.multiplier"), // Access outer step ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(items: any[], category: string, original_mult: number) { return { @@ -2013,7 +2013,7 @@ async fn test_flow_results_non_existent_step(db: Pool) -> anyhow::Resu "a", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { value: 42 }; @@ -2050,7 +2050,7 @@ export function main() { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main( existing: number, @@ -2126,7 +2126,7 @@ async fn test_flow_env_skip_if_inside_branchone(db: Pool) -> anyhow::R "inner", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {ran: true}; @@ -2156,7 +2156,7 @@ export function main() { "branch_marker", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {marker: "branch-marker"}; @@ -2193,7 +2193,7 @@ export function main() { "after", FlowModuleValue::RawScript { input_transforms: [js_input("prev", "previous_result")].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(prev: any) { return {prev}; @@ -2258,7 +2258,7 @@ async fn test_flow_env_skip_if_nested_branchone(db: Pool) -> anyhow::R "leaf", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {ran: true}; @@ -2282,7 +2282,7 @@ export function main() { "inner_marker", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {marker: "inner"}; @@ -2337,7 +2337,7 @@ export function main() { "after", FlowModuleValue::RawScript { input_transforms: [js_input("prev", "previous_result")].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(prev: any) { return {prev}; @@ -2414,7 +2414,7 @@ async fn test_flow_env_complex_input_transform_in_branch(db: Pool) -> "Math.min(flow_env.LIMIT, 10) + flow_env.OFFSET", )] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(value: number) { return {value}; @@ -2487,7 +2487,7 @@ async fn test_flow_env_skip_if_in_parallel_forloop(db: Pool) -> anyhow "marker", FlowModuleValue::RawScript { input_transforms: [js_input("i", "flow_input.iter.value")].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(i: number) { return {marker: i}; @@ -2508,7 +2508,7 @@ export function main(i: number) { "leaf", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {ran: true}; @@ -2594,7 +2594,7 @@ async fn test_flow_env_imported_flow_uses_own_env(db: Pool) -> anyhow: "id": "leaf", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(key: string) { return {key}; }", "input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } @@ -2679,7 +2679,7 @@ async fn test_flow_env_imported_flow_inherits_when_unset(db: Pool) -> "id": "marker", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return {marker: \"from-imported\"}; }", "input_transforms": {} } @@ -2688,7 +2688,7 @@ async fn test_flow_env_imported_flow_inherits_when_unset(db: Pool) -> "id": "leaf", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return {ran: true}; }", "input_transforms": {} }, @@ -2791,7 +2791,7 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> a "id": "marker", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return {marker: \"from-imported-branch\"}; }", "input_transforms": {} } @@ -2800,7 +2800,7 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> a "id": "leaf", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return {ran: true}; }", "input_transforms": {} }, @@ -2932,13 +2932,13 @@ async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( "parallel": true, "modules": [{ "id": "leaf", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(key: any) { return key ?? null; }", "input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } } }}, { "id": "tail", "value": { // A single simple step is inlined rather than stored as a flow node. "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(prev: any) { return prev; }", "input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } } }}] @@ -3024,7 +3024,7 @@ async fn test_flow_env_in_stop_after_if(db: Pool) -> anyhow::Result<() "first", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {stage: "first"}; @@ -3052,7 +3052,7 @@ export function main() { "second", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {stage: "second"}; @@ -3110,7 +3110,7 @@ async fn test_stop_after_if_error_include_result(db: Pool) -> anyhow:: "step", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return { userErrors: ["email taken"], ok: false }; @@ -3200,7 +3200,7 @@ async fn test_flow_env_in_retry_if(db: Pool) -> anyhow::Result<()> { "fails", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { throw new Error("nope"); @@ -3274,7 +3274,7 @@ async fn test_flow_env_in_stop_after_all_iters_if(db: Pool) -> anyhow: "iter_step", FlowModuleValue::RawScript { input_transforms: [js_input("i", "flow_input.iter.value")].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(i: number) { return {iter: i}; @@ -3316,7 +3316,7 @@ export function main(i: number) { "after", FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main() { return {stage: "after-loop"}; @@ -3375,7 +3375,7 @@ async fn test_stop_after_all_iters_if_error_includes_result( "iter_step", FlowModuleValue::RawScript { input_transforms: [js_input("i", "flow_input.iter.value")].into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#" export function main(i: number) { return { iter: i }; @@ -3453,8 +3453,8 @@ async fn test_flow_expr_step_tag(db: Pool) -> anyhow::Result<()> { id, FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, - content: "export function main() { return { lang: 'bun' } }".to_string(), + language: ScriptLang::Bun, + content: "export function main() { return { lang: 'python3' } }".to_string(), path: None, lock: None, tag: tag.map(str::to_string), @@ -3484,7 +3484,7 @@ async fn test_flow_expr_step_tag(db: Pool) -> anyhow::Result<()> { .bind(job.id) .fetch_one(&db) .await?; - assert_eq!(b_tag, "bun"); + assert_eq!(b_tag, "python3"); assert!(!job.success); let result = job.json_result().unwrap(); diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 52ebbee7e7..24937a3ce5 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -237,7 +237,7 @@ async fn test_diff_global_settings_empty_string_routes_to_delete(db: Pool) { ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); assert_eq!( get_global_setting(&db, "key_a").await, @@ -468,7 +468,7 @@ async fn test_apply_settings_diff_deletes_only(db: Pool) { ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); assert!(get_global_setting(&db, "to_delete_1").await.is_none()); assert!(get_global_setting(&db, "to_delete_2").await.is_none()); @@ -489,7 +489,7 @@ async fn test_apply_settings_diff_upserts_and_deletes(db: Pool) { ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); assert!(get_global_setting(&db, "old_key").await.is_none()); assert_eq!( @@ -503,7 +503,7 @@ async fn test_apply_settings_diff_empty_noop(db: Pool) { insert_global_setting(&db, "preexisting", serde_json::json!("value")).await; let diff = SettingsDiff::default(); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); assert_eq!( get_global_setting(&db, "preexisting").await, @@ -525,7 +525,7 @@ async fn test_apply_settings_diff_upsert_overwrites(db: Pool) { ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); assert_eq!( get_global_setting(&db, "overwrite_me").await, @@ -553,7 +553,7 @@ async fn test_apply_settings_diff_complex_json(db: Pool) { ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); let stored = get_global_setting(&db, "complex_setting").await.unwrap(); assert_eq!(stored, complex_value); @@ -568,7 +568,7 @@ async fn test_apply_settings_diff_delete_nonexistent_is_noop(db: Pool) }; // Should not error - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); } // ======================================================================== @@ -589,7 +589,7 @@ async fn test_apply_configs_diff_upserts_with_prefix(db: Pool) { deletes: vec![], }; - apply_configs_diff(&db, &diff).await.unwrap(); + apply_configs_diff(&db, &diff, &()).await.unwrap(); let stored = get_config(&db, "worker__mygroup").await.unwrap(); assert_eq!(stored["init_bash"], "echo hi"); @@ -601,7 +601,7 @@ async fn test_apply_configs_diff_deletes_with_prefix(db: Pool) { let diff = ConfigsDiff { upserts: BTreeMap::new(), deletes: vec!["to_remove".to_string()] }; - apply_configs_diff(&db, &diff).await.unwrap(); + apply_configs_diff(&db, &diff, &()).await.unwrap(); assert!(get_config(&db, "worker__to_remove").await.is_none()); } @@ -619,7 +619,7 @@ async fn test_apply_configs_diff_upsert_overwrites(db: Pool) { deletes: vec![], }; - apply_configs_diff(&db, &diff).await.unwrap(); + apply_configs_diff(&db, &diff, &()).await.unwrap(); let stored = get_config(&db, "worker__grp").await.unwrap(); assert_eq!(stored, serde_json::json!({"new": true})); @@ -630,7 +630,7 @@ async fn test_apply_configs_diff_empty_noop(db: Pool) { insert_config(&db, "worker__keep", serde_json::json!({"keep": true})).await; let diff = ConfigsDiff::default(); - apply_configs_diff(&db, &diff).await.unwrap(); + apply_configs_diff(&db, &diff, &()).await.unwrap(); assert!(get_config(&db, "worker__keep").await.is_some()); } @@ -641,7 +641,7 @@ async fn test_apply_configs_diff_does_not_touch_non_worker_configs(db: Pool) { assert!(diff.upserts.contains_key("expose_metrics")); assert!(diff.deletes.is_empty()); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); // Step 5: Read back and verify let config2 = InstanceConfig::from_db(&db).await.unwrap(); @@ -729,7 +729,7 @@ async fn test_roundtrip_replace_mode_deletes(db: Pool) { assert!(diff.deletes.contains(&"retention_period_secs".to_string())); assert!(diff.deletes.contains(&"expose_metrics".to_string())); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); assert!(get_global_setting(&db, "retention_period_secs") .await @@ -782,7 +782,7 @@ async fn test_roundtrip_worker_configs(db: Pool) { assert!(diff.upserts.contains_key("gpu")); // new assert_eq!(diff.deletes, vec!["legacy".to_string()]); - apply_configs_diff(&db, &diff).await.unwrap(); + apply_configs_diff(&db, &diff, &()).await.unwrap(); // Verify let config2 = InstanceConfig::from_db(&db).await.unwrap(); @@ -820,7 +820,7 @@ async fn test_roundtrip_to_settings_map_from_db_consistency(db: Pool) let diff = SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); let config = InstanceConfig::from_db(&db).await.unwrap(); assert_eq!(config.global_settings.base_url, original.base_url); @@ -868,8 +868,8 @@ async fn test_idempotent_apply(db: Pool) { }; // Apply twice - apply_settings_diff(&db, &diff).await.unwrap(); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); assert_eq!( get_global_setting(&db, "idem_key").await, @@ -1069,7 +1069,7 @@ async fn test_diff_apply_only_touches_changed_rows(db: Pool) { assert!(!diff.upserts.contains_key("unchanged_1")); assert!(!diff.upserts.contains_key("unchanged_2")); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); // Verify all 4 settings present assert_eq!(count_global_settings(&db).await, 4); @@ -1148,7 +1148,7 @@ async fn test_replace_mode_protects_settings_in_integration(db: Pool) // But normal_setting should be deleted assert!(diff.deletes.contains(&"normal_setting".to_string())); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); // Verify protected settings survived assert!(get_global_setting(&db, "ducklake_settings").await.is_some()); @@ -1196,7 +1196,7 @@ async fn test_jwt_secret_roundtrip_as_string_or_secret_ref(db: Pool) { let diff = SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); // Read back from DB — jwt_secret should survive the roundtrip let config = InstanceConfig::from_db(&db).await.unwrap(); @@ -1239,7 +1239,7 @@ async fn test_rsa_keys_roundtrip_via_extra(db: Pool) { let map = settings.to_settings_map(); let diff = SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() }; - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); // Read back from DB let config = InstanceConfig::from_db(&db).await.unwrap(); @@ -1296,7 +1296,7 @@ async fn test_replace_mode_protects_jwt_secret_and_rsa_keys(db: Pool) "normal_setting should be deleted" ); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); // Both protected settings survive assert_eq!( @@ -1371,7 +1371,7 @@ async fn test_alert_config_in_global_settings_roundtrip(db: Pool) { "alert_config change should be detected in diff" ); - apply_settings_diff(&db, &diff).await.unwrap(); + apply_settings_diff(&db, &diff, &()).await.unwrap(); // Re-read and verify the update let config2 = InstanceConfig::from_db(&db).await.unwrap(); @@ -1455,6 +1455,7 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool &db, &BTreeMap::new(), &desired, + &(), ) .await .expect_err("a javascript: banner link must fail the sync"); @@ -1545,6 +1548,7 @@ async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool) { &db, &BTreeMap::new(), &desired, + &(), ) .await .expect_err("a non-hex accent color must fail the sync"); @@ -1681,7 +1691,109 @@ async fn declarative_sync_rejects_a_non_hex_accent_color(db: Pool) { &db, &BTreeMap::new(), &desired, + &(), ) .await .expect("a hex accent color must sync"); } + +/// A declarative sync has no user request behind it, so its observer is the only thing +/// that audits it: one entry per setting or worker config it actually writes, values +/// recorded under the API path's rule, and none for what it leaves as is. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(fixtures("base"))] +async fn test_declarative_sync_audits_only_what_it_writes(db: Pool) { + use windmill_audit::instance_config::{ + declarative_sync_author, DeclarativeSource, InstanceConfigAuditor, + }; + + insert_global_setting(&db, "retention_period_secs", serde_json::json!(100)).await; + insert_global_setting(&db, "expose_metrics", serde_json::json!(true)).await; + insert_global_setting(&db, "scim_token", serde_json::json!("old-scim-secret")).await; + insert_global_setting( + &db, + "npm_config_registry", + serde_json::json!("https://u:p@npm"), + ) + .await; + insert_config( + &db, + "worker__kept", + serde_json::json!({"worker_tags": ["a"]}), + ) + .await; + insert_config( + &db, + "worker__dropped", + serde_json::json!({"worker_tags": ["b"]}), + ) + .await; + + let config: windmill_common::instance_config::InstanceConfig = + serde_json::from_value(serde_json::json!({ + "global_settings": { + "retention_period_secs": 200, + "expose_metrics": true, + "scim_token": "new-scim-secret", + }, + "worker_configs": { + "kept": {"worker_tags": ["a"]}, + "added": {"init_bash": "export TOKEN=init-secret"}, + }, + })) + .unwrap(); + let author = declarative_sync_author(); + config + .sync_to_db( + &db, + &InstanceConfigAuditor { + db: &db, + author: &author, + source: Some(DeclarativeSource::SyncConfig), + }, + ) + .await + .unwrap(); + + let entries: Vec<(String, String, serde_json::Value)> = sqlx::query_as( + "SELECT operation, resource, parameters FROM audit_partitioned \ + WHERE workspace_id = 'global' AND username = $1 AND resource = ANY($2) \ + ORDER BY resource", + ) + .bind(&author.username) + .bind(vec![ + "retention_period_secs", + "expose_metrics", + "scim_token", + "npm_config_registry", + "worker__kept", + "worker__dropped", + "worker__added", + ]) + .fetch_all(&db) + .await + .unwrap(); + + let source = serde_json::json!({"source": "sync-config"}); + let entry = |operation: &str, resource: &str, parameters: &serde_json::Value| { + ( + operation.to_string(), + resource.to_string(), + parameters.clone(), + ) + }; + assert_eq!( + entries, + vec![ + entry("global_settings.delete", "npm_config_registry", &source), + entry( + "global_settings.update", + "retention_period_secs", + &serde_json::json!({"source": "sync-config", "value": "200"}), + ), + entry("global_settings.update", "scim_token", &source), + entry("worker_config.update", "worker__added", &source), + entry("worker_config.delete", "worker__dropped", &source), + ] + ); +} diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index 0038effc78..c40feb1650 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -54,7 +54,7 @@ mod job_payload { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - language: ScriptLang::Deno, + language: ScriptLang::Bun, priority: None, apply_preprocessor: false, job_token_scopes: None, @@ -85,7 +85,7 @@ mod job_payload { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - language: ScriptLang::Deno, + language: ScriptLang::Bun, priority: None, apply_preprocessor: true, concurrency_settings: @@ -165,7 +165,7 @@ mod job_payload { let test = || async { let result = RunJob::from(JobPayload::FlowScript { id: flow_scripts[0], - language: ScriptLang::Deno, + language: ScriptLang::Bun, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), cache_ttl: None, @@ -185,7 +185,7 @@ mod job_payload { let test = || async { let result = RunJob::from(JobPayload::FlowScript { id: flow_scripts[1], - language: ScriptLang::Deno, + language: ScriptLang::Bun, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), cache_ttl: None, @@ -263,7 +263,7 @@ mod job_payload { let result = RunJob::from(JobPayload::Dependencies { path: "f/system/hello".to_string(), hash: ScriptHash(123412), - language: ScriptLang::Deno, + language: ScriptLang::Bun, debouncing_settings: Default::default(), dedicated_worker: None, }) @@ -351,7 +351,7 @@ mod job_payload { console.log(greet) return greet }"#, - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" } } @@ -407,7 +407,7 @@ mod job_payload { return greet }"# .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, }) .run_until_complete(&db, false, port) .await @@ -416,7 +416,10 @@ mod job_payload { assert_eq!( result, - json!({ "lock": "", "status": "Successful lock file generation" }) + json!({ + "lock": "{\n \"dependencies\": {}\n}\n//bun.lock\n", + "status": "Successful lock file generation" + }) ); }; test_for_versions(VERSION_FLAGS.iter().copied(), test).await; @@ -626,7 +629,7 @@ mod job_payload { console.log(greet) return greet }"#, - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" } } @@ -673,7 +676,7 @@ mod job_payload { "content": r#"export function main(world: string) { return `Hello ${world}!`; }"#, - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" } } @@ -685,7 +688,7 @@ mod job_payload { "content": r#"export function main(world: string, a: string) { return `${a} ${world}!`; }"#, - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" }, "a": { "type": "javascript", "expr": "results.a" } @@ -704,7 +707,7 @@ mod job_payload { "x": { "type": "javascript", "expr": "flow_input.iter.value" } }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": r#"export function main(world: string, b: string, x: string) { return `${x}: ${b} ${world}!`; }"#, @@ -857,7 +860,7 @@ mod job_payload { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" } }, @@ -874,7 +877,7 @@ mod job_payload { "id": "inner_first", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" } }, @@ -884,7 +887,7 @@ mod job_payload { "id": "inner_second", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" }, "first": { "type": "javascript", "expr": "results.inner_first" } @@ -1045,7 +1048,7 @@ mod job_payload { "id": "inner", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "iter_val": { "type": "javascript", "expr": "flow_input.iter.value" }, "tag": { "type": "javascript", "expr": "flow_input.tag" } @@ -1244,7 +1247,7 @@ mod job_payload { "id": "leaf", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "tag": { "type": "javascript", "expr": "flow_input.tag" }, "iv": { "type": "javascript", "expr": "flow_input.iter.value" } @@ -1388,7 +1391,7 @@ mod job_payload { "id": "leaf", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "tag": { "type": "javascript", "expr": "flow_input.tag" } }, diff --git a/backend/tests/job_s3_viewer_data_access.rs b/backend/tests/job_s3_viewer_data_access.rs new file mode 100644 index 0000000000..a68b695259 --- /dev/null +++ b/backend/tests/job_s3_viewer_data_access.rs @@ -0,0 +1,112 @@ +//! A job whose `job_perms.viewer_data_access` is set reads workspace object storage as its viewer: +//! the advanced permission rules are checked against the viewer, not the job's own identity, and +//! such a job with no viewer is refused rather than checked as itself. +//! +//! Advanced S3 permissions are an enterprise feature. +#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))] + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +/// A job of `test-user-3`, run for `viewer`, reading data as that viewer or not. +async fn job_token( + db: &Pool, + viewer: Option<&str>, + viewer_data_access: bool, +) -> anyhow::Result { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag) + VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev', + 'script', 'deno', 'u/test-user-3/app_runnable', 'deno')", + ) + .bind(id) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, + workspace_id, end_user_email, viewer_data_access) + VALUES ($1, 'test3@windmill.dev', 'test-user-3', false, false, '{}', '{}', + 'test-workspace', $2, $3)", + ) + .bind(id) + .bind(viewer) + .bind(viewer_data_access) + .execute(db) + .await?; + Ok(windmill_common::auth::create_token_for_owner( + db, + "test-workspace", + "u/test-user-3", + "ephemeral-script", + 300, + "test3@windmill.dev", + &id, + None, + None, + ) + .await?) +} + +#[sqlx::test(fixtures("base"))] +async fn a_job_reading_data_as_its_viewer_gets_the_viewers_storage_paths( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let storage_dir = tempfile::tempdir()?; + for user in ["test-user-2", "test-user-3"] { + let dir = storage_dir.path().join("u").join(user); + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("file.txt"), user)?; + } + sqlx::query("UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2") + .bind(json!({ + "type": "FilesystemStorage", + "root_path": storage_dir.path().to_string_lossy(), + "public_resource": null, + "advanced_permissions": [{ "pattern": "u/{username}/**", "allow": "read" }] + })) + .bind("test-workspace") + .execute(&db) + .await?; + + let read = |token: String, user: &'static str| async move { + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/job_helpers/download_s3_file" + )) + .query(&[("file_key", format!("u/{user}/file.txt"))]) + .bearer_auth(token) + .send() + .await?; + Ok::<_, anyhow::Error>(resp.status().is_success()) + }; + + let own = job_token(&db, Some("test2@windmill.dev"), false).await?; + assert!(read(own.clone(), "test-user-3").await?); + assert!( + !read(own, "test-user-2").await?, + "a job not reading as its viewer reached the viewer's paths" + ); + + let as_viewer = job_token(&db, Some("test2@windmill.dev"), true).await?; + assert!(read(as_viewer.clone(), "test-user-2").await?); + assert!( + !read(as_viewer, "test-user-3").await?, + "a job reading as its viewer kept its own identity's paths" + ); + + let no_viewer = job_token(&db, None, true).await?; + assert!( + !read(no_viewer, "test-user-3").await?, + "a job reading as a viewer it does not have was checked as itself" + ); + Ok(()) +} diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs index 6af694bff0..f805168bfa 100644 --- a/backend/tests/job_token_scopes.rs +++ b/backend/tests/job_token_scopes.rs @@ -410,5 +410,22 @@ async fn test_oidc_claim_carries_the_presented_scopes(db: Pool) -> any )?; assert_eq!(claims["job_token_scopes"], expected, "{job}"); } + + // A soft-canceled job is still `running` until its worker kills it. + sqlx::query("UPDATE v2_job_queue SET canceled_by = 'test-user' WHERE id = $1") + .bind(Uuid::parse_str(OIDC_JOB)?) + .execute(&db) + .await?; + let resp = client + .post(format!("{base}/oidc/token/test-audience")) + .bearer_auth(job_token(&db, OIDC_JOB).await?) + .send() + .await?; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "{}", + resp.text().await? + ); Ok(()) } diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 6a101200a1..bffacfbaf0 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -476,6 +476,44 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul ); } + // A lock job belongs to `jobs:run:dependencies`: its path is whatever the caller sent, + // so a token scoped to run the flow at that path must not read it. + let path = "completed/get_result/19191919-1919-1919-1919-191919191919"; + let (status, body) = get(&base, path, Some("RUN_SCOPED_DEPS_TOKEN")).await; + assert!( + status.is_success() && body.contains("DEPS_RESULT"), + "dependencies-scoped token must read its lock job (got {status}): {body}" + ); + let (status, body) = get(&base, path, Some("RUN_SCOPED_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "a flow-scoped token must not read a lock job (got {status}): {body}" + ); + let (status, body) = get( + &base, + &format!("completed/get_result/{FLOW_JOB}"), + Some("RUN_SCOPED_DEPS_TOKEN"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "dependencies-scoped token must not read a flow run at its path (got {status}): {body}" + ); + // A lock job filed at `*` matches no path-scoped grant: the stored path is not a pattern. + let (status, body) = get( + &base, + "completed/get_result/20202020-2020-2020-2020-202020202020", + Some("RUN_SCOPED_DEPS_TOKEN"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "a path-scoped token must not read a lock job filed at `*` (got {status}): {body}" + ); + // An `apps:run:` scope is a start grant too: the inline-script component run it // launched — a kind no `jobs:run` scope can name — stays readable to a token scoped // to that app, and stays out of reach for one that is only scoped to run jobs. diff --git a/backend/tests/mcp_header_var_refs.rs b/backend/tests/mcp_header_var_refs.rs new file mode 100644 index 0000000000..158bc71a5f --- /dev/null +++ b/backend/tests/mcp_header_var_refs.rs @@ -0,0 +1,110 @@ +//! An MCP resource header whose value is a `$var:` reference reaches the MCP +//! server as the variable's value, and only for a caller allowed to read it. +#![cfg(feature = "mcp")] + +use sqlx::{Pool, Postgres}; +use tokio::io::AsyncReadExt; +use windmill_common::client::AuthedClient; +use windmill_test_utils::*; +use windmill_worker::{load_mcp_tools, McpResourceConfig}; + +const SECRET_VALUE: &str = "S3CRET-MCP-HEADER-VALUE"; + +/// A secret variable and an MCP resource at `url` sending it as a header. +async fn seed(db: &Pool, url: &str) -> anyhow::Result<()> { + let mc = windmill_common::variables::build_crypt(db, "test-workspace").await?; + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) + VALUES ('test-workspace', 'u/test-user/mcp_header', $1, true, '', '{}')", + ) + .bind(windmill_common::variables::encrypt(&mc, SECRET_VALUE)) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/mcp', $1, 'mcp', '{}', 'test-user')", + ) + .bind(serde_json::json!({ + "name": "mcp", + "url": url, + "headers": { "X-Api-Key": "$var:u/test-user/mcp_header", "X-Plain": "plain" } + })) + .execute(db) + .await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_mcp_header_var_ref_is_resolved(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + std::env::set_var("ALLOW_PRIVATE_MCP_SERVER_URLS", "true"); + + // Not an MCP server: it captures the first request and hangs up, so loading + // fails at the connect step after the headers were sent. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let mcp_port = listener.local_addr()?.port(); + let first_request = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await?; + let mut buf = vec![0u8; 16 * 1024]; + let n = socket.read(&mut buf).await?; + anyhow::Ok(String::from_utf8_lossy(&buf[..n]).to_lowercase()) + }); + seed(&db, &format!("http://127.0.0.1:{mcp_port}/mcp")).await?; + + let server = ApiServer::start(db.clone()).await?; + let client = AuthedClient::new( + format!("http://localhost:{}", server.addr.port()), + "test-workspace".to_string(), + "SECRET_TOKEN".to_string(), + None, + ); + let config = vec![McpResourceConfig { + resource_path: "$res:u/test-user/mcp".to_string(), + include_tools: None, + exclude_tools: None, + }]; + let _ = load_mcp_tools(&db, "test-workspace", config, &client).await; + + let request = first_request.await??; + assert!( + request.contains(&format!("x-api-key: {}", SECRET_VALUE.to_lowercase())), + "the header must carry the variable's value, not the reference: {request}" + ); + assert!(request.contains("x-plain: plain"), "{request}"); + + Ok(()) +} + +/// A token scoped to the resource alone must not have the API read the +/// variable a header points at. +#[sqlx::test(fixtures("base"))] +async fn test_mcp_header_var_ref_requires_variable_scope(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + seed(&db, "https://mcp.invalid.windmill.test").await?; + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) + VALUES (encode(sha256('RES_ONLY_TOKEN'::bytea), 'hex'), 'RES_ONLY_T', 'RES_ONLY_TOKEN', + 'test@windmill.dev', 'resources only', false, '{resources:read}')", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{}/api/w/test-workspace/resources/mcp_tools/u/test-user/mcp", + server.addr.port() + )) + .header("Authorization", "Bearer RES_ONLY_TOKEN") + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{body}"); + assert!( + body.contains("variables:read:u/test-user/mcp_header"), + "{body}" + ); + + Ok(()) +} diff --git a/backend/tests/mcp_resource_authz.rs b/backend/tests/mcp_resource_authz.rs index 0fa35933f4..6ccf5f4f2d 100644 --- a/backend/tests/mcp_resource_authz.rs +++ b/backend/tests/mcp_resource_authz.rs @@ -23,9 +23,9 @@ use windmill_common::client::AuthedClient; use windmill_test_utils::*; use windmill_worker::{load_mcp_tools, McpResourceConfig}; -fn config() -> Vec { +fn config(resource_path: &str) -> Vec { vec![McpResourceConfig { - resource_path: "$res:f/private/admin_mcp".to_string(), + resource_path: resource_path.to_string(), include_tools: None, exclude_tools: None, }] @@ -34,7 +34,16 @@ fn config() -> Vec { // The Ok variant ((HashMap<_, Arc>, Vec)) does not implement // Debug, so `expect_err` is unavailable — extract the error explicitly. async fn load_err(db: &Pool, client: &AuthedClient, ctx: &str) -> String { - match load_mcp_tools(db, "test-workspace", config(), client).await { + load_err_for(db, client, "$res:f/private/admin_mcp", ctx).await +} + +async fn load_err_for( + db: &Pool, + client: &AuthedClient, + resource_path: &str, + ctx: &str, +) -> String { + match load_mcp_tools(db, "test-workspace", config(resource_path), client).await { Ok(_) => panic!("{ctx}"), Err(e) => e.to_string(), } @@ -100,3 +109,31 @@ async fn test_mcp_resource_not_loaded_without_authorization( Ok(()) } + +/// A `$var:` in the resource's `url` is resolved before connecting, rather than +/// being handed to the client as a literal (and rejected as a relative URL). +#[sqlx::test(fixtures("base", "mcp_resource_authz"))] +async fn test_mcp_resource_url_variable_is_resolved(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let client = AuthedClient::new( + format!("http://localhost:{}", server.addr.port()), + "test-workspace".to_string(), + "SECRET_TOKEN".to_string(), + None, + ); + let msg = load_err_for( + &db, + &client, + "$res:u/test-user/var_mcp", + "the variable's URL is non-resolvable, so the connect step must fail", + ) + .await; + assert!( + msg.contains("mcp-from-var.invalid.windmill.test"), + "the URL should come from the variable: {msg}" + ); + + Ok(()) +} diff --git a/backend/tests/otel.rs b/backend/tests/otel.rs index f1e042037f..30585c08a1 100644 --- a/backend/tests/otel.rs +++ b/backend/tests/otel.rs @@ -176,6 +176,10 @@ async fn test_all_metrics_e2e() { otel_set_worker_uptime("w-uptime", 3600.0); otel_set_health_status_phase("healthy"); otel_set_health_db_unresponsive(true); + otel_set_worker_memory("w-res", "gpu", Some(512), Some(128), Some(1024)); + otel_add_worker_cpu("w-res", "gpu", 1.5, 40, 7, 0.35); + otel_set_worker_occupancy_rate("w-res", "gpu", "5m", 0.25); + otel_record_job_memory_peak("python3", 100 * 1024 * 1024); // ── Histograms ────────────────────────────────────────────────── @@ -189,7 +193,7 @@ async fn test_all_metrics_e2e() { let metrics = flush_and_get_metrics(&state); let names = metric_names(&metrics); - // ── Verify all 21 metric names are present ────────────────────── + // ── Verify all metric names are present ───────────────────────── let expected = [ "windmill.queue.push_count", @@ -213,6 +217,15 @@ async fn test_all_metrics_e2e() { "windmill.worker.uptime", "windmill.health.status", "windmill.health.db_unresponsive", + "windmill.worker.memory_usage", + "windmill.worker.windmill_memory_usage", + "windmill.worker.memory_limit", + "windmill.worker.cpu_usage", + "windmill.worker.cpu_periods", + "windmill.worker.cpu_throttled_periods", + "windmill.worker.cpu_throttled_time", + "windmill.worker.occupancy_rate", + "windmill.worker.job_memory_peak", ]; for name in expected { assert!( @@ -290,6 +303,18 @@ async fn test_all_metrics_e2e() { .expect("worker.busy data point with worker=worker-test-1 not found"); assert_eq!(dp.1, 1); + let m = find_metric(&metrics, "windmill.worker.memory_limit").unwrap(); + let values = gauge_i64_values(m); + let dp = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "worker_group" && kv.value.as_str() == "gpu") + }) + .expect("worker.memory_limit data point with worker_group=gpu not found"); + assert_eq!(dp.1, 1024); + let m = find_metric(&metrics, "windmill.db.pool.active").unwrap(); assert_eq!(gauge_i64_values(m)[0].1, 5); let m = find_metric(&metrics, "windmill.db.pool.idle").unwrap(); diff --git a/backend/tests/restart_at_step_api.rs b/backend/tests/restart_at_step_api.rs index 2b57de2c64..f6fc2a04d3 100644 --- a/backend/tests/restart_at_step_api.rs +++ b/backend/tests/restart_at_step_api.rs @@ -217,7 +217,7 @@ async fn test_api_restart_at_step_rejects_parallel_loop(db: Pool) -> a "id": "inner", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": {}, "content": "export function main() { return 'ok' }" } diff --git a/backend/tests/retry.rs b/backend/tests/retry.rs index e804b96f0e..d80e386b61 100644 --- a/backend/tests/retry.rs +++ b/backend/tests/retry.rs @@ -70,12 +70,20 @@ mod retry { fn inner_step() -> &'static str { r#" +import { connect } from "node:net"; + export async function main(index, port) { - const buf = new Uint8Array([0]); - const sock = await Deno.connect({ port }); - await sock.write(new Uint8Array([index])); - if (await sock.read(buf) != 1) throw Error("read"); - return buf[0]; + return await new Promise((resolve, reject) => { + const sock = connect({ port, host: "127.0.0.1" }, () => { + sock.write(new Uint8Array([index])); + }); + sock.on("data", (data) => { + resolve(data[0]); + sock.destroy(); + }); + sock.on("error", reject); + sock.on("close", () => reject(Error("read"))); + }); } "# } @@ -105,7 +113,7 @@ def main(last, port): "port": { "type": "javascript", "expr": "flow_input.port" }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": inner_step(), }, }], @@ -281,7 +289,7 @@ def main(last, port): "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return \"ok\" }", }, }, { @@ -293,7 +301,7 @@ def main(last, port): "port": { "type": "javascript", "expr": "flow_input.port" }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": inner_step(), }, "retry": { "constant": { "attempts": 1, "seconds": 0 } }, diff --git a/backend/tests/s3_proxy_headers.rs b/backend/tests/s3_proxy_headers.rs new file mode 100644 index 0000000000..8f43f8c761 --- /dev/null +++ b/backend/tests/s3_proxy_headers.rs @@ -0,0 +1,29 @@ +//! Every s3_proxy response carries `nosniff` and the sandbox CSP, error responses included, +//! since the presigned route serves workspace objects from the app origin without a login. +#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))] + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base"))] +async fn test_s3_proxy_sets_content_security_headers(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/s3_proxy/_default_/x.html?exp=9999999999&sig=00" + )) + .send() + .await?; + + assert!(resp.status().is_client_error(), "status: {}", resp.status()); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); + assert_eq!( + resp.headers()["content-security-policy"], + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads" + ); + Ok(()) +} diff --git a/backend/tests/success_handler.rs b/backend/tests/success_handler.rs index 8fc4182d9e..14df1a4c25 100644 --- a/backend/tests/success_handler.rs +++ b/backend/tests/success_handler.rs @@ -2,6 +2,36 @@ use sqlx::{Pool, Postgres}; use windmill_test_utils::*; +/// Both job-running tests seed `f/test/success_handler` with a different hash, each in its own +/// database, while the deployed-script caches are process-global and keyed by workspace and +/// path. With them on, one test resolves the handler to the other's hash and the handler push +/// 404s whenever that hash's info is not cached yet. +#[cfg(all(feature = "enterprise", feature = "private"))] +fn disable_deployed_script_cache() { + windmill_common::DEPLOYED_SCRIPT_CACHE_DISABLED + .store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// The handler job is pushed after the job it handles is reported complete. +#[cfg(all(feature = "enterprise", feature = "private"))] +async fn wait_for_success_handler_job( + db: &Pool, +) -> anyhow::Result, Option)>> { + for _ in 0..100 { + let job = sqlx::query_as::<_, (Option, Option)>( + "SELECT runnable_path, parent_job FROM v2_job + WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'success_handler@windmill.dev'", + ) + .fetch_optional(db) + .await?; + if job.is_some() { + return Ok(job); + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + Ok(None) +} + /// Test that the workspace success handler cache works correctly with 60s TTL #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base"))] @@ -108,6 +138,7 @@ async fn test_success_handler_triggered_on_success(db: Pool) -> anyhow use windmill_common::scripts::{ScriptHash, ScriptLang}; initialize_tracing().await; + disable_deployed_script_cache(); let server = ApiServer::start(db.clone()).await?; @@ -123,7 +154,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'deno', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', '') + VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'bun', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', E'{}\n//bun.lock\n') "#, success_handler_code ) @@ -138,7 +169,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: sqlx::query!( r#" INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'deno', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', '') + VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'bun', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', E'{}\n//bun.lock\n') "#, test_script_hash, test_script_code @@ -175,7 +206,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - language: ScriptLang::Deno, + language: ScriptLang::Bun, priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), @@ -191,51 +222,72 @@ export async function main(path: string, email: string, job_id: string, is_flow: let main_job_id = completed_job.id; - // Wait a short time for the success handler job to be created - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // Verify the success handler job was created (query by email since trigger is not set) - let success_handler_job = sqlx::query!( - r#" - SELECT - id, - runnable_path, - permissioned_as_email, - parent_job - FROM v2_job - WHERE workspace_id = 'test-workspace' - AND permissioned_as_email = 'success_handler@windmill.dev' - ORDER BY created_at DESC - LIMIT 1 - "# - ) - .fetch_optional(&db) - .await?; - - assert!( - success_handler_job.is_some(), - "Success handler job should have been created" - ); - - let handler_job = success_handler_job.unwrap(); - - // Verify the success handler job has correct parameters + let handler_job = wait_for_success_handler_job(&db).await?; assert_eq!( - handler_job.runnable_path.as_deref(), - Some("f/test/success_handler"), - "Success handler should run the configured script" - ); - assert_eq!( - handler_job.permissioned_as_email.as_str(), - "success_handler@windmill.dev", - "Success handler should run as success_handler user" - ); - assert_eq!( - handler_job.parent_job, - Some(main_job_id), - "Success handler should have main job as parent" + handler_job, + Some(( + Some("f/test/success_handler".to_string()), + Some(main_job_id) + )), + "Success handler should run the configured script with the main job as parent" ); // Note: root_job may be None when it equals parent_job (optimization in push function) Ok(()) } + +/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job, +/// and its success must reach the workspace success handler like a workspace script's. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(fixtures("base"))] +async fn test_success_handler_triggered_on_hub_script_success( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::jobs::JobPayload; + + initialize_tracing().await; + disable_deployed_script_cache(); + + let server = ApiServer::start(db.clone()).await?; + + let version = "990000003"; + let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; + tokio::fs::create_dir_all(hub_dir).await?; + tokio::fs::write( + format!("{hub_dir}/{version}"), + r#"{"content":"echo ok","lockfile":null,"language":"bash","schema":{},"summary":null}"#, + ) + .await?; + + // The workspace handler settings are cached per workspace id across the tests of this + // binary, so this reuses the handler path every other test here configures. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) + VALUES ('test-workspace', 1234567891, 'f/test/success_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')", + ) + .execute(&db) + .await?; + sqlx::query( + r#"UPDATE workspace_settings SET success_handler = '{"path": "script/f/test/success_handler"}'::jsonb + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + + let completed_job = RunJob::from(JobPayload::ScriptHub { + path: format!("hub/{version}/test/ok"), + apply_preprocessor: false, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!(completed_job.success, "Hub script should have succeeded"); + + let handler_job = wait_for_success_handler_job(&db).await?; + assert_eq!( + handler_job.map(|(_, parent)| parent), + Some(Some(completed_job.id)), + "Workspace success handler should handle the successful hub script" + ); + + Ok(()) +} diff --git a/backend/tests/suspend_resume.rs b/backend/tests/suspend_resume.rs index 6fb5d68004..0decc16519 100644 --- a/backend/tests/suspend_resume.rs +++ b/backend/tests/suspend_resume.rs @@ -63,11 +63,11 @@ mod suspend_resume { "op": { "type": "javascript", "expr": "flow_input.op ?? 'resume'", }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "\ export async function main(n, port, op) {\ - const job = Deno.env.get('WM_JOB_ID'); - const token = Deno.env.get('WM_TOKEN'); + const job = process.env.WM_JOB_ID; + const token = process.env.WM_TOKEN; const r = await fetch( `http://localhost:${port}/api/w/test-workspace/jobs/job_signature/${job}/0?token=${token}&approver=ruben`,\ {\ @@ -102,7 +102,7 @@ mod suspend_resume { "resumes": { "type": "javascript", "expr": "resumes", }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(n, resume, resumes) { return { n: n + 1, resume, resumes } }" }, "suspend": { @@ -116,7 +116,7 @@ mod suspend_resume { "resumes": { "type": "javascript", "expr": "resumes", }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(last, resume, resumes) { return { last, resume, resumes } }" }, }], @@ -256,11 +256,11 @@ mod suspend_resume { "port": { "type": "javascript", "expr": "flow_input.port" }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "\ export async function main(port) {\ - const job = Deno.env.get('WM_JOB_ID');\ - const token = Deno.env.get('WM_TOKEN');\ + const job = process.env.WM_JOB_ID;\ + const token = process.env.WM_TOKEN;\ const secret = await (await fetch(\ `http://localhost:${port}/api/w/test-workspace/jobs/job_signature/${job}/0?token=${token}&approver=ruben`,\ { headers: { 'Authorization': `Bearer ${token}` } }\ @@ -278,7 +278,7 @@ mod suspend_resume { "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'b ran' }", }, // The gate holds `b` back, so `b` never runs and its error policy describes @@ -290,7 +290,7 @@ mod suspend_resume { "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'handled' }", }, }, @@ -343,7 +343,7 @@ mod suspend_resume { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step1'; }" }, "suspend": { @@ -355,7 +355,7 @@ mod suspend_resume { "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step2 - after approval'; }" } }] @@ -454,7 +454,7 @@ mod suspend_resume { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step1'; }" }, "suspend": { @@ -466,7 +466,7 @@ mod suspend_resume { "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step2 - after approval'; }" } }] @@ -556,7 +556,7 @@ mod suspend_resume { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step1'; }" }, "suspend": { @@ -567,7 +567,7 @@ mod suspend_resume { "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step2 - after approval'; }" } }] @@ -658,7 +658,7 @@ mod suspend_resume { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step1'; }" }, "suspend": { @@ -670,7 +670,7 @@ mod suspend_resume { "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step2 - after approval'; }" } }] @@ -764,7 +764,7 @@ mod suspend_resume { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step1'; }" }, "suspend": { @@ -776,7 +776,7 @@ mod suspend_resume { "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 'step2 - after approval'; }" } }] @@ -933,7 +933,7 @@ mod suspend_resume { "suspend": { "required_events": 1, "timeout": 86400 }, "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export async function main() { return 1 }", "input_transforms": {}, }, @@ -942,7 +942,7 @@ mod suspend_resume { "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export async function main() { return 42 }", "input_transforms": {}, }, @@ -980,7 +980,7 @@ mod suspend_resume { "suspend": { "required_events": APPROVALS, "timeout": 86400 }, "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export async function main() { return 1 }", "input_transforms": {}, }, @@ -989,7 +989,7 @@ mod suspend_resume { "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(resumes) { return resumes.length }", "input_transforms": { "resumes": { "type": "javascript", "expr": "resumes" }, diff --git a/backend/tests/wac_suspend_started_at.rs b/backend/tests/wac_suspend_started_at.rs index 5a83730d24..ca364e5993 100644 --- a/backend/tests/wac_suspend_started_at.rs +++ b/backend/tests/wac_suspend_started_at.rs @@ -109,3 +109,91 @@ async fn wac_suspend_stands_down_for_a_cancel(db: Pool) -> anyhow::Res Ok(()) } + +/// Each park clears `started_at`, so the completion would otherwise store only the last +/// segment: a workflow that waited an hour on its tasks would read as a sub-second job that +/// started an hour late. The completed row spans the workflow from its first start, and +/// keeps the last segment aside: that is what is metered and what worker-time readers sum. +#[sqlx::test] +async fn wac_completion_spans_the_workflow_and_keeps_the_last_segment( + db: Pool, +) -> anyhow::Result<()> { + let job_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \ + permissioned_as_email, kind, script_lang, runnable_path, tag, visible_to_owner) \ + VALUES ($1, 'test-workspace', 'test-user', now() - interval '2 hours', 'u/test-user', \ + 'test@windmill.dev', 'script', 'bun', 'u/test-user/wac', 'bun', true)", + ) + .bind(job_id) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at) \ + VALUES ($1, 'test-workspace', now(), true, 'bun', now() - interval '1 hour')", + ) + .bind(job_id) + .execute(&db) + .await?; + + // Two parks, each followed by the pull re-stamping `started_at`: the second park must + // not replace the first start with its own segment's. + for resumed_ago in ["10 minutes", "3 seconds"] { + let mut tx = db.begin().await?; + suspend_wac_parent(&mut tx, &job_id, "test-workspace", 1, 3600.0).await?; + tx.commit().await?; + sqlx::query( + "UPDATE v2_job_queue SET suspend = 0, started_at = now() - $2::text::interval \ + WHERE id = $1", + ) + .bind(job_id) + .bind(resumed_ago) + .execute(&db) + .await?; + } + + let job = windmill_queue::get_mini_completed_job(&job_id, "test-workspace", &db) + .await? + .unwrap(); + let result = serde_json::value::to_raw_value(&serde_json::json!("done"))?; + let (_, span) = windmill_queue::add_completed_job( + &db, + &job, + true, + false, + sqlx::types::Json(&result), + None, + 0, + None, + false, + Some(3000), + false, + ) + .await?; + + let (started_ago_s, duration_ms, last_segment_ms): (f64, i64, Option) = sqlx::query_as( + "SELECT extract(epoch FROM now() - started_at)::float8, duration_ms, \ + (extras->>'wac_last_segment_ms')::bigint \ + FROM v2_job_completed WHERE id = $1", + ) + .bind(job_id) + .fetch_one(&db) + .await?; + assert!( + (started_ago_s - 3600.0).abs() < 60.0, + "started_at must be the first start, got {started_ago_s}s ago" + ); + assert!( + (duration_ms - 3_600_000).abs() < 60_000, + "duration_ms must be the wall time since the first start, got {duration_ms}" + ); + assert_eq!(last_segment_ms, Some(3000)); + assert_eq!( + span.duration_ms, duration_ms, + "a flow records the step with the span the row holds" + ); + assert!(span.outlasts_run(job.started_at)); + assert_eq!(span.last_run_ms, 3000, "only the last segment is left to meter"); + + Ok(()) +} diff --git a/backend/tests/wm_token_confinement.rs b/backend/tests/wm_token_confinement.rs index 23d88301b7..e75170ec6f 100644 --- a/backend/tests/wm_token_confinement.rs +++ b/backend/tests/wm_token_confinement.rs @@ -319,7 +319,7 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool) -> anyho ); } // ...and the `settings/global` keys on the allowlist, which the CLI reads from a job: on a - // git-sync push, and in `u/admin/hub_sync`. `ws_base_url` is the control: the handler + // git-sync push, and in `wmill hub pull`. `ws_base_url` is the control: the handler // leaves it as ungated as these, so only the allowlist stops it. for key in ["automate_username_creation", "uid", "hub_base_url"] { let resp = authed( diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 73d3fc2567..751752eb00 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -166,7 +166,7 @@ async fn test_iteration_parallel(db: Pool) -> anyhow::Result<()> { #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base"))] -async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { +async fn test_bun_flow(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; @@ -183,7 +183,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { id: "a".to_string(), value: FlowModuleValue::RawScript { input_transforms: Default::default(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: numbers.to_string(), path: None, lock: None, @@ -232,7 +232,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { }, )] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: doubles.to_string(), path: None, lock: None, @@ -296,7 +296,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { let port = server.addr.port(); for i in 0..50 { - println!("deno flow iteration: {}", i); + println!("bun flow iteration: {}", i); let job = run_job_in_new_worker_until_complete(&db, false, job.clone(), port).await; // println!("job: {:#?}", job.flow_status); let result = job.json_result().unwrap(); @@ -346,13 +346,13 @@ async fn test_identity(db: Pool) -> anyhow::Result<()> { #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base"))] -async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { +async fn test_bun_flow_same_worker(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let write_file = r#"export async function main(loop: boolean, i: number, path: string) { - await Deno.writeTextFile(`./shared/${path}`, `${loop} ${i}`); + await Bun.write(`./shared/${path}`, `${loop} ${i}`); }"# .to_string(); @@ -373,7 +373,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: write_file.clone(), path: None, lock: None, @@ -432,7 +432,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: write_file, path: None, lock: None, @@ -472,9 +472,9 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { InputTransform::Static { value: windmill_common::worker::to_raw_value(&"outer.txt") }, )] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#"export async function main(path: string, path2: string) { - return await Deno.readTextFile(`./shared/${path}`) + "," + await Deno.readTextFile(`./shared/${path2}`); + return await Bun.file(`./shared/${path}`).text() + "," + await Bun.file(`./shared/${path2}`).text(); }"# .to_string(), path: None, @@ -546,9 +546,9 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { ), ] .into(), - language: ScriptLang::Deno, + language: ScriptLang::Bun, content: r#"export async function main(path: string, loops: string[], path2: string) { - return await Deno.readTextFile(`./shared/${path}`) + "," + loops + "," + await Deno.readTextFile(`./shared/${path2}`); + return await Bun.file(`./shared/${path}`).text() + "," + loops + "," + await Bun.file(`./shared/${path2}`).text(); }"# .to_string(), path: None, @@ -618,7 +618,7 @@ async fn test_same_worker_survives_empty_branch(db: Pool) -> anyhow::R "id": "c", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return 1 }", } }] @@ -630,7 +630,7 @@ async fn test_same_worker_survives_empty_branch(db: Pool) -> anyhow::R "id": "b", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return 42 }", } } @@ -671,7 +671,7 @@ async fn test_flow_result_by_id(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return 42 }", } }, @@ -686,7 +686,7 @@ async fn test_flow_result_by_id(db: Pool) -> anyhow::Result<()> { "value": { "input_transforms": {"v": {"type": "javascript", "expr": "results.a"}}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(v){ return v }", } @@ -3857,7 +3857,7 @@ fn module_add_item_to_list(i: i32, id: &str) -> serde_json::Value { } }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(array, i){ array.push(i); return array }", } }) @@ -3868,7 +3868,7 @@ fn module_failure() -> serde_json::Value { "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ throw Error('failure') }", } }) @@ -3886,7 +3886,7 @@ async fn test_branchone_simple(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [1] }", } }, @@ -3924,7 +3924,7 @@ async fn test_branchone_with_cond(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [1] }", } }, @@ -3962,7 +3962,7 @@ async fn test_branchall_sequential(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [1] }", } }, @@ -4002,7 +4002,7 @@ async fn test_branchall_simple(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [1] }", } }, @@ -4050,7 +4050,7 @@ async fn test_branchall_skip_failure(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [1] }", } }, @@ -4086,7 +4086,7 @@ async fn test_branchall_skip_failure(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [1] }", } }, @@ -4132,7 +4132,7 @@ async fn test_branchone_nested(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [] }", } }, @@ -4191,7 +4191,7 @@ async fn test_branchall_nested(db: Pool) -> anyhow::Result<()> { "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(){ return [1] }", } }, @@ -4257,7 +4257,7 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { "n": { "type": "javascript", "expr": "flow_input.n", }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(n, l) { if (n == 0) throw Error(JSON.stringify(l)); return { l: [...l, 0] } }", }, }, { @@ -4268,7 +4268,7 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { "n": { "type": "javascript", "expr": "flow_input.n", }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(n, l) { if (n == 1) throw Error(JSON.stringify(l)); return { l: [...l, 1] } }", }, }, { @@ -4278,7 +4278,7 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { "n": { "type": "javascript", "expr": "flow_input.n", }, }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(n, l) { if (n == 2) throw Error(JSON.stringify(l)); return { l: [...l, 2] } }", }, }], @@ -4286,7 +4286,7 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { "value": { "input_transforms": { "error": { "type": "javascript", "expr": "previous_result", } }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(error) { return { 'from failure module': error } }", } }, @@ -4475,7 +4475,7 @@ fn hanging_step_value() -> serde_json::Value { serde_json::json!({ "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export async function main() { await new Promise((r) => setTimeout(r, 600000)); }", }) } @@ -4487,7 +4487,7 @@ fn marker_failure_module() -> serde_json::Value { "value": { "input_transforms": { "error": { "type": "javascript", "expr": "previous_result", } }, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main(error) { return { handled_unrecoverable: true, error } }", } }) @@ -4585,7 +4585,7 @@ async fn test_unrecoverable_failure_on_continue_on_error_runs_failure_module( "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return { ran_b: true }; }", }, }, @@ -4624,7 +4624,7 @@ async fn test_run_wait_result_early_return_with_failure_module( "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": {}, "content": "export function main() { throw new Error('boom'); }", }, @@ -4632,7 +4632,7 @@ async fn test_run_wait_result_early_return_with_failure_module( "failure_module": { "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": {}, "content": "export function main() { return { recovered: true } }", }, @@ -4720,7 +4720,7 @@ async fn test_failure_module_recover_inside_loop(db: Pool) -> anyhow:: "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { throw new Error('boom'); }", }, }], @@ -4734,7 +4734,7 @@ async fn test_failure_module_recover_inside_loop(db: Pool) -> anyhow:: "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return { ran_b: true } }", }, }], @@ -4742,7 +4742,7 @@ async fn test_failure_module_recover_inside_loop(db: Pool) -> anyhow:: "value": { "input_transforms": {}, "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return { handled: true, recover: true } }", }, }, @@ -4797,14 +4797,23 @@ async fn test_failure_module_recover_inside_loop(db: Pool) -> anyhow:: server.close().await.unwrap(); #[cfg(feature = "quickjs")] - assert!(!chaining_error.success, "an error no handler recovered must keep the flow failed"); + assert!( + !chaining_error.success, + "an error no handler recovered must keep the flow failed" + ); - assert!(stopped.success, "a recovered failure inside a loop should end the flow as a success"); + assert!( + stopped.success, + "a recovered failure inside a loop should end the flow as a success" + ); assert!( stopped.json_result().unwrap().get("ran_b").is_none(), "the step after the loop must not run, as without recovery" ); - assert_eq!(stopped_handler_runs, 1, "the loop must stop at the first failed iteration"); + assert_eq!( + stopped_handler_runs, 1, + "the loop must stop at the first failed iteration" + ); assert!(skipping.success); assert_eq!( @@ -4815,7 +4824,11 @@ async fn test_failure_module_recover_inside_loop(db: Pool) -> anyhow:: assert_eq!(skipping_handler_runs, 3); assert!( - !parallel.json_result().unwrap().to_string().contains("ran_b"), + !parallel + .json_result() + .unwrap() + .to_string() + .contains("ran_b"), "the parallel loop fails as without recovery, so the step after it must not run" ); Ok(()) @@ -4987,6 +5000,101 @@ async fn test_flow_lock_all(db: Pool) -> anyhow::Result<()> { Ok(()) } +/// A deployed flow runs a multi-step loop body as the flow node its deploy stored, which +/// carries its own copy of the flow's `preserve_step_tags`. A preview never loads that node. +#[sqlx::test(fixtures("base"))] +async fn test_saved_flow_preserves_nested_step_tags(db: Pool) -> anyhow::Result<()> { + use futures::StreamExt; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = reqwest::Client::new(); + let flow = json!({ + "path": "g/all/saved_step_tags", "summary": "", "description": "", "tag": "python3", + "schema": { "type": "object", "properties": {}, "required": [] }, + "value": { + "preserve_step_tags": true, + "modules": [{ + "id": "loop", + "value": { + "type": "forloopflow", "skip_failures": false, + "iterator": { "type": "static", "value": [2, 3] }, + "modules": [{ + "id": "own_tag", + "value": { + "type": "rawscript", "language": "bun", "lock": "", "tag": "bun", + "content": "export function main(n: number) { return n * n; }", + "input_transforms": { + "n": { "type": "javascript", "expr": "flow_input.iter.value" } + } + } + }, { + "id": "untagged", + "value": { + "type": "rawscript", "language": "bun", "lock": "", + "content": "export function main(n: number) { return n + 1; }", + "input_transforms": { + "n": { "type": "javascript", "expr": "results.own_tag" } + } + } + }] + } + }] + } + }); + let mut completed = listen_for_completed_jobs(&db).await; + client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/flows/create" + )) + .bearer_auth("SECRET_TOKEN") + .json(&flow) + .send() + .await? + .error_for_status()?; + let deployment = in_test_worker(&db, completed.next(), port).await.unwrap(); + let deployed = completed_job(deployment, &db).await; + assert!(deployed.success, "{:?}", deployed.result); + + let job_id = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/jobs/run/f/g/all/saved_step_tags" + )) + .bearer_auth("SECRET_TOKEN") + .json(&json!({})) + .send() + .await? + .error_for_status()? + .text() + .await? + .parse::()?; + in_test_worker(&db, completed.find(&job_id), port).await; + let job = completed_job(job_id, &db).await; + assert!(job.success, "{:?}", job.result); + let tags = sqlx::query_as::<_, (String, String)>( + "WITH RECURSIVE descendants AS ( + SELECT id FROM v2_job WHERE id = $1 + UNION ALL + SELECT child.id FROM v2_job child JOIN descendants ON child.parent_job = descendants.id + ) + SELECT DISTINCT flow_step_id, tag FROM v2_job + WHERE id IN (SELECT id FROM descendants) AND flow_step_id IN ('own_tag', 'untagged') + ORDER BY flow_step_id, tag", + ) + .bind(job_id) + .fetch_all(&db) + .await?; + assert_eq!( + tags, + vec![ + ("own_tag".to_owned(), "bun".to_owned()), + ("untagged".to_owned(), "python3".to_owned()) + ] + ); + Ok(()) +} + #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base"))] @@ -5687,7 +5795,7 @@ async fn test_job_labels(db: Pool) -> anyhow::Result<()> { console.log(greet) return { greet, wm_labels: ["yolo", "greet", "greet", world] }; }"#, - "language": "deno", + "language": "bun", "input_transforms": { "world": { "type": "javascript", "expr": "flow_input.world" } } @@ -5896,7 +6004,7 @@ async fn test_flow_substep_tag_availability_check(db: Pool) -> anyhow: "id": "a", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return 42; }", "tag": "restricted-tag", }, @@ -5956,7 +6064,7 @@ async fn test_flow_substep_tag_checked_on_resolved_value(db: Pool) -> "id": id, "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "content": "export function main() { return { lang: 'bun' } }", "tag": tag, }, diff --git a/backend/tests/worker_group_pause.rs b/backend/tests/worker_group_pause.rs new file mode 100644 index 0000000000..b528bbfd77 --- /dev/null +++ b/backend/tests/worker_group_pause.rs @@ -0,0 +1,71 @@ +//! In its own test binary: it flips the process-wide `WORKER_CONFIG`, which would stall every +//! other worker test sharing the process. + +use futures::StreamExt; +use sqlx::{Pool, Postgres}; +use std::time::Duration; +use windmill_common::{ + jobs::{JobPayload, RawCode}, + scripts::ScriptLang, + worker::{Connection, WORKER_CONFIG}, +}; +use windmill_test_utils::*; + +fn set_paused(paused: bool) { + let mut wc = (**WORKER_CONFIG.load()).clone(); + wc.paused = paused; + WORKER_CONFIG.store(std::sync::Arc::new(wc)); +} + +#[sqlx::test(fixtures("base"))] +async fn paused_worker_leaves_jobs_queued_until_resumed(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + set_paused(true); + let id = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "echo done".to_string(), + path: None, + lock: None, + language: ScriptLang::Bash, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .push(&db) + .await; + + let mut completed = listen_for_completed_jobs(&db).await; + in_test_worker( + Connection::Sql(db.clone()), + async { + tokio::time::sleep(Duration::from_secs(3)).await; + let running = + sqlx::query_scalar::<_, bool>("SELECT running FROM v2_job_queue WHERE id = $1") + .bind(id) + .fetch_optional(&db) + .await + .unwrap(); + assert_eq!( + running, + Some(false), + "a paused worker must not pull the job" + ); + + set_paused(false); + while completed.next().await != Some(id) {} + }, + port, + ) + .await; + + assert!(completed_job(id, &db).await.success); + Ok(()) +} diff --git a/backend/tests/worker_ping_ip.rs b/backend/tests/worker_ping_ip.rs index 772f4193c3..2421e888ef 100644 --- a/backend/tests/worker_ping_ip.rs +++ b/backend/tests/worker_ping_ip.rs @@ -2,10 +2,19 @@ use sqlx::{Pool, Postgres}; use uuid::Uuid; use windmill_common::{ external_ip::UNKNOWN_IP, - worker::{insert_ping_query, update_worker_ping_main_loop_query}, + worker::{insert_ping_query, set_worker_draining_query, update_worker_ping_main_loop_query}, }; async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> anyhow::Result<()> { + register(db, worker, ip, None).await +} + +async fn register( + db: &Pool, + worker: &str, + ip: Option<&str>, + draining: Option, +) -> anyhow::Result<()> { insert_ping_query( "test-instance", worker, @@ -19,12 +28,33 @@ async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> any None, None, false, + draining, db, ) .await?; Ok(()) } +/// A worker re-registers mid-life once its external IP resolves. That registration must not +/// clear a draining flag the same process already published; only a worker starting up under +/// a reclaimed name resets it. +#[sqlx::test] +async fn only_a_starting_worker_resets_draining(db: Pool) -> anyhow::Result<()> { + let draining = || async { + sqlx::query_scalar::<_, bool>("SELECT draining FROM worker_ping WHERE worker = 'wk-drain'") + .fetch_one(&db) + .await + }; + register(&db, "wk-drain", None, Some(false)).await?; + set_worker_draining_query("wk-drain", &db).await?; + register(&db, "wk-drain", Some("1.2.3.4"), None).await?; + assert!(draining().await?); + + register(&db, "wk-drain", None, Some(false)).await?; + assert!(!draining().await?); + Ok(()) +} + /// The external IP resolves in the background, so the initial ping often has none yet. That must /// not blank the address a previous process wrote to the row this one reclaims — worker names are /// stable across restarts under EXIT_AFTER_N_JOBS. diff --git a/backend/tests/zombie_flow_recovery.rs b/backend/tests/zombie_flow_recovery.rs index fb658dfb03..333aac70a8 100644 --- a/backend/tests/zombie_flow_recovery.rs +++ b/backend/tests/zombie_flow_recovery.rs @@ -78,7 +78,7 @@ async fn test_between_steps_zombie_restart_reuses_all_children( "id": "inner", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "v": { "type": "javascript", "expr": "flow_input.iter.value" } }, @@ -91,7 +91,7 @@ async fn test_between_steps_zombie_restart_reuses_all_children( "id": "after", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "loop_res": { "type": "javascript", "expr": "results.fanout" } }, @@ -210,7 +210,7 @@ async fn test_mid_iteration_zombie_not_reused(db: Pool) -> anyhow::Res "id": "inner", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "v": { "type": "javascript", "expr": "flow_input.iter.value" } }, @@ -223,7 +223,7 @@ async fn test_mid_iteration_zombie_not_reused(db: Pool) -> anyhow::Res "id": "after", "value": { "type": "rawscript", - "language": "deno", + "language": "bun", "input_transforms": { "loop_res": { "type": "javascript", "expr": "results.fanout" } }, @@ -331,7 +331,7 @@ async fn test_nested_restart_not_swallowed_by_zombie_reuse( { "id": "inner_first", "value": { - "type": "rawscript", "language": "deno", + "type": "rawscript", "language": "bun", "input_transforms": {}, "content": "export function main() { return 'first' }" } @@ -339,7 +339,7 @@ async fn test_nested_restart_not_swallowed_by_zombie_reuse( { "id": "inner_second", "value": { - "type": "rawscript", "language": "deno", + "type": "rawscript", "language": "bun", "input_transforms": { "first": { "type": "javascript", "expr": "results.inner_first" } }, @@ -353,7 +353,7 @@ async fn test_nested_restart_not_swallowed_by_zombie_reuse( { "id": "after", "value": { - "type": "rawscript", "language": "deno", + "type": "rawscript", "language": "bun", "input_transforms": { "b": { "type": "javascript", "expr": "results.branch" } }, @@ -466,7 +466,7 @@ async fn test_raw_flow_restart_does_not_reuse_edited_step( "modules": [{ "id": "inner", "value": { - "type": "rawscript", "language": "deno", + "type": "rawscript", "language": "bun", "input_transforms": { "v": { "type": "javascript", "expr": "flow_input.iter.value" } }, @@ -478,7 +478,7 @@ async fn test_raw_flow_restart_does_not_reuse_edited_step( { "id": "after", "value": { - "type": "rawscript", "language": "deno", + "type": "rawscript", "language": "bun", "input_transforms": { "loop_res": { "type": "javascript", "expr": "results.fanout" } }, @@ -578,7 +578,7 @@ async fn test_non_monitor_cancel_is_not_reused(db: Pool) -> anyhow::Re "modules": [{ "id": "inner", "value": { - "type": "rawscript", "language": "deno", + "type": "rawscript", "language": "bun", "input_transforms": { "v": { "type": "javascript", "expr": "flow_input.iter.value" } }, @@ -590,7 +590,7 @@ async fn test_non_monitor_cancel_is_not_reused(db: Pool) -> anyhow::Re { "id": "after", "value": { - "type": "rawscript", "language": "deno", + "type": "rawscript", "language": "bun", "input_transforms": { "loop_res": { "type": "javascript", "expr": "results.fanout" } }, diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index b1dea3ae54..44c4ac83c7 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -28,6 +28,7 @@ eventsource-stream.workspace = true futures.workspace = true http.workspace = true mime_guess.workspace = true +rand.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index a9318a1f3a..f55fcca415 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -477,6 +477,29 @@ pub async fn assume_role_with_oidc_token( // Error Formatting // ============================================================================ +/// The error for a Bedrock stream that failed after it opened, retrying the exceptions the +/// Vercel AI SDK retries (`amazon-bedrock-stream-error.ts`) and a broken connection. The +/// SDK's own retries cover the opening call alone. +pub fn bedrock_stream_error( + error: &aws_sdk_bedrockruntime::error::SdkError< + aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError, + R, + >, +) -> Error { + let message = format!("Bedrock stream error: {}", format_bedrock_error(error)); + match error.as_service_error() { + Some(e) + if !(e.is_internal_server_exception() + || e.is_model_stream_error_exception() + || e.is_service_unavailable_exception() + || e.is_throttling_exception()) => + { + Error::AIError(message) + } + _ => crate::retry::transient_error(message, None), + } +} + /// Format AWS SDK errors with detailed information pub fn format_bedrock_error(error: &aws_sdk_bedrockruntime::error::SdkError) -> String where diff --git a/backend/windmill-ai/src/ai_google.rs b/backend/windmill-ai/src/ai_google.rs index bb8ae83e13..9f48edca8a 100644 --- a/backend/windmill-ai/src/ai_google.rs +++ b/backend/windmill-ai/src/ai_google.rs @@ -326,6 +326,17 @@ pub struct GeminiSSEEvent { pub candidates: Option>, #[serde(rename = "usageMetadata")] pub usage_metadata: Option, + #[serde(default)] + pub error: Option, + #[serde(rename = "promptFeedback", default)] + pub prompt_feedback: Option, +} + +/// Set when Gemini refuses the prompt itself, in which case no candidate follows. +#[derive(Deserialize, Debug)] +pub struct GeminiPromptFeedback { + #[serde(rename = "blockReason", default)] + pub block_reason: Option, } // ============================================================================ @@ -550,10 +561,40 @@ pub fn openai_tools_to_gemini( } } +/// Follows a Gemini stream to tell a complete turn from one cut short. +#[derive(Default)] +pub struct GeminiTurn { + ended: bool, + has_output: bool, +} + +impl GeminiTurn { + pub fn observe(&mut self, event: &GeminiParsedEvent) { + // A function call arrives whole and may come without a `finishReason`, so it + // counts too, as in gemini-cli's stream validation. + self.ended |= event.finish_reason.is_some() || !event.tool_calls.is_empty(); + self.has_output |= event.text.is_some() || event.reasoning.is_some(); + } + + /// Whether the turn's final event arrived. + pub fn ended(&self) -> bool { + self.ended + } + + /// Whether a stream that closed cleanly here holds the whole turn. Gemini 2.5 closes + /// its stream without a single event when thinking spends the whole + /// `maxOutputTokens`: an empty answer, which a retry would only get again, rather + /// than a cut one. + pub fn complete_at_close(&self) -> bool { + self.ended || !self.has_output + } +} + /// Parse one Gemini SSE data line into a [`GeminiParsedEvent`]. /// /// Returns `Ok(None)` for empty data or unrecognised payloads (e.g. `"[DONE]"`). /// Logs a warning and returns `Ok(None)` on JSON parse errors rather than propagating. +/// An error the stream reports is returned as one. pub fn parse_gemini_sse_event(data: &str) -> Result, Error> { if data.is_empty() || data == "[DONE]" { return Ok(None); @@ -567,6 +608,21 @@ pub fn parse_gemini_sse_event(data: &str) -> Result, E } }; + if let Some(error) = event.error { + return Err(error.into_error("Gemini")); + } + + // `BLOCK_REASON_UNSPECIFIED` is the protobuf enum's placeholder, not a block. + if let Some(reason) = event + .prompt_feedback + .and_then(|f| f.block_reason) + .filter(|r| !matches!(r.as_str(), "" | "BLOCK_REASON_UNSPECIFIED")) + { + return Err(Error::AIError(format!( + "Gemini blocked the prompt: {reason}" + ))); + } + let mut parsed = GeminiParsedEvent { usage: event.usage_metadata, ..Default::default() }; let Some(candidates) = event.candidates else { diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index ccc40da432..634614a3cf 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -11,6 +11,7 @@ pub mod model_context; pub mod providers; pub mod proxy; pub mod query_builder; +pub mod retry; pub mod sse; pub mod types; pub mod utils; diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 836244a666..5aa142a127 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -9,11 +9,12 @@ use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL}; use crate::{ ai_bedrock::{ - bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, - bedrock_stream_event_to_reasoning_delta, bedrock_stream_event_to_text, - bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_delta_with_block_index, - bedrock_stream_event_to_tool_start, bedrock_stream_event_to_tool_start_with_block_index, - build_tool_config, create_inference_config, format_bedrock_error, json_to_document, + bedrock_model_supports_prompt_caching, bedrock_stream_error, + bedrock_stream_event_is_block_stop, bedrock_stream_event_to_reasoning_delta, + bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, + bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, + bedrock_stream_event_to_tool_start_with_block_index, build_tool_config, + create_inference_config, format_bedrock_error, json_to_document, openai_messages_to_bedrock, streaming_tool_calls_to_openai, BearerTokenProvider, BedrockClient, StreamingToolCall, }, @@ -24,6 +25,7 @@ use crate::{ image_handler::prepare_messages_for_api, proxy::ProxyBuildArgs, query_builder::{ParsedResponse, StreamEventSink}, + retry::{openai_stream_error_chunk, truncated_stream_error}, types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef}, }; use bytes::Bytes; @@ -461,26 +463,31 @@ pub fn sdk_stream_to_sse( async_stream::stream! { let mut stream = stream; let mut state = BedrockSseStreamState::new(id, model, created); + let mut message_stopped = false; - loop { + let failure = loop { match stream.recv().await { Ok(Some(event)) => { + if matches!( + event, + aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(_) + ) { + message_stopped = true; + } for chunk in bedrock_sse_chunks_for_event(&event, &mut state) { yield Ok(chunk); } } - Ok(None) => break, - Err(e) => { - yield Err(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )); - break; - } + Ok(None) => break (!message_stopped).then(truncated_stream_error), + Err(_) if message_stopped => break None, + Err(e) => break Some(bedrock_stream_error(&e)), } - } + }; - yield Ok(Bytes::from("data: [DONE]\n\n")); + match failure { + Some(error) => yield Ok(Bytes::from(openai_stream_error_chunk(&error))), + None => yield Ok(Bytes::from("data: [DONE]\n\n")), + } } } @@ -1081,11 +1088,19 @@ impl BedrockQueryBuilder { // Claude reasoning block for the turn (only populated when thinking is on), // attached to the first tool call for replay before toolUse. let mut reasoning: Option = None; + let mut message_stopped = false; // Process stream events using shared parsing functions loop { match stream.recv().await { Ok(Some(event)) => { + if matches!( + event, + aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(_) + ) { + message_stopped = true; + } + // Fold reasoning deltas into the turn's reasoning block (for // replay before toolUse, required by Claude when thinking is // on) and stream the readable summary as a thinking affordance. @@ -1161,11 +1176,13 @@ impl BedrockQueryBuilder { } } Ok(None) => break, - Err(e) => { - return Err(Error::internal_err(format!("Bedrock stream error: {}", e))); - } + Err(_) if message_stopped => break, + Err(e) => return Err(bedrock_stream_error(&e)), } } + if !message_stopped { + return Err(truncated_stream_error()); + } // Send tool call events to stream processor if let Some(processor) = stream_event_sink.as_ref() { diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 97d0383ba4..bbf45e5e57 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -5,17 +5,20 @@ use crate::{ parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent, GeminiTextRequest, - GeminiThinkingConfig, GeminiTool, + GeminiThinkingConfig, GeminiTool, GeminiTurn, }, image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, proxy::{ProxyBuildArgs, ProxyRequest}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, + retry::{ + openai_stream_error_chunk, send_with_retries, transient_error, truncated_stream_error, + }, sse::{GeminiSSEParser, SSEParser}, types::*, }; use async_trait::async_trait; use bytes::Bytes; -use eventsource_stream::Eventsource; +use eventsource_stream::{EventStreamError, Eventsource}; use futures::{stream::BoxStream, StreamExt}; use http::{header, HeaderMap, HeaderValue, Method, StatusCode}; use serde::Deserialize; @@ -336,9 +339,7 @@ pub async fn handle_google_ai_chat_proxy( ) -> Result { let GoogleAIProxyRequest { request, model, stream } = build_google_ai_chat_proxy_request(args)?; - let response = - send_google_ai_proxy_request(client, request, "Failed to send request to Gemini API") - .await?; + let response = send_google_ai_proxy_request(client, request).await?; if stream { Ok(convert_streaming_response(response, &model)) @@ -356,8 +357,7 @@ pub async fn handle_google_ai_models_proxy( args: &ProxyBuildArgs<'_>, ) -> Result { let request = build_google_ai_models_proxy_request(args); - let response = - send_google_ai_proxy_request(client, request, "Failed to fetch Gemini models").await?; + let response = send_google_ai_proxy_request(client, request).await?; let gemini_resp: GeminiModelsResponse = response.json().await.map_err(|e| { Error::internal_err(format!("Failed to parse Gemini models response: {}", e)) @@ -505,26 +505,13 @@ fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, async fn send_google_ai_proxy_request( client: &reqwest::Client, proxy_request: ProxyRequest, - send_error_message: &str, ) -> Result { let mut request = client.request(proxy_request.method.clone(), &proxy_request.url); for (header_name, header_value) in &proxy_request.headers { request = request.header(header_name.as_str(), header_value.as_str()); } - let response = request - .body(proxy_request.body) - .send() - .await - .map_err(|e| Error::internal_err(format!("{}: {}", send_error_message, e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - Ok(response) + send_with_retries(request.body(proxy_request.body)).await } fn convert_streaming_response(response: reqwest::Response, model: &str) -> GoogleAIProxyResponse { @@ -535,10 +522,15 @@ fn convert_streaming_response(response: reqwest::Response, model: &str) -> Googl let openai_sse_stream = async_stream::stream! { tokio::pin!(gemini_sse_stream); let mut tool_call_index: usize = 0; - while let Some(event) = gemini_sse_stream.next().await { + let mut turn = GeminiTurn::default(); + let failure = loop { + let Some(event) = gemini_sse_stream.next().await else { + break (!turn.complete_at_close()).then(truncated_stream_error); + }; match event { Ok(event) => match parse_gemini_sse_event(&event.data) { Ok(Some(parsed)) => { + turn.observe(&parsed); for chunk in gemini_event_to_openai_sse_chunks( &parsed, &id, &model, &mut tool_call_index, ) { @@ -546,12 +538,23 @@ fn convert_streaming_response(response: reqwest::Response, model: &str) -> Googl } } Ok(None) => {} - Err(e) => tracing::error!("Error parsing Gemini SSE event: {}", e), + Err(e) => break Some(e), }, + Err(EventStreamError::Transport(_)) if turn.ended() => break None, + Err(EventStreamError::Transport(e)) => { + break Some(transient_error( + format!("The connection to Gemini broke off mid-response: {e}"), + None, + )); + } Err(e) => tracing::error!("Error reading Gemini SSE stream: {}", e), } - } - yield Ok::(Bytes::from("data: [DONE]\n\n")); + }; + let last = match failure { + Some(error) => openai_stream_error_chunk(&error), + None => "data: [DONE]\n\n".to_string(), + }; + yield Ok::(Bytes::from(last)); } .boxed(); diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index e08b038bb0..7dcddc93a3 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -62,9 +62,13 @@ const REASONING_RULES: &[ReasoningRule] = &[ can_disable: false, completions_tools_need_off: false, }, - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. ReasoningRule { - matches: |m| base_id(m).starts_with("gpt-6-astra"), + matches: |m| { + let base = base_id(m); + base.starts_with("gpt-6-astra") || base.starts_with("gpt-6.1-sol") + }, can_disable: false, completions_tools_need_off: true, }, diff --git a/backend/windmill-ai/src/providers/other.rs b/backend/windmill-ai/src/providers/other.rs index 1ea54a7e79..fea8edbdac 100644 --- a/backend/windmill-ai/src/providers/other.rs +++ b/backend/windmill-ai/src/providers/other.rs @@ -266,6 +266,7 @@ impl QueryBuilder for OtherQueryBuilder { mut events_str, stream_event_processor, usage: openai_usage, + .. } = openai_sse_parser; // Process streaming events with error handling diff --git a/backend/windmill-ai/src/retry.rs b/backend/windmill-ai/src/retry.rs new file mode 100644 index 0000000000..9de9d309da --- /dev/null +++ b/backend/windmill-ai/src/retry.rs @@ -0,0 +1,474 @@ +use std::time::Duration; + +use reqwest::{header::HeaderMap, RequestBuilder, Response, StatusCode}; +use windmill_common::error::{to_anyhow, Error}; + +/// How many times a provider call is retried after its first attempt. +pub const MAX_RETRIES: u32 = 3; +const BASE_DELAY: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(10); +/// A provider that asks for a longer wait is out of capacity for longer than anyone should +/// block on (a spent daily quota, say), so its error is returned instead of waited out. +const MAX_RETRY_AFTER: Duration = Duration::from_secs(60); + +/// A provider call that failed for a reason a later attempt may not meet: the provider was +/// overloaded, throttled or unreachable, or its response broke off part way. +#[derive(Debug)] +pub struct TransientAIError { + pub message: String, + /// The wait the provider asked for, when it named one. + pub retry_after: Option, +} + +impl std::fmt::Display for TransientAIError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for TransientAIError {} + +pub fn transient_error(message: impl Into, retry_after: Option) -> Error { + to_anyhow(TransientAIError { message: message.into(), retry_after }).into() +} + +pub fn as_transient(error: &Error) -> Option<&TransientAIError> { + match error { + Error::Anyhow { error, .. } => error.downcast_ref::(), + _ => None, + } +} + +/// The error to report once a transient failure is no longer retried, without the source +/// location `Error::Anyhow` renders. +pub fn into_final_error(error: Error) -> Error { + match as_transient(&error) { + Some(transient) => Error::AIError(transient.message.clone()), + None => error, + } +} + +/// The statuses the OpenAI and Anthropic SDKs and the Vercel AI SDK retry. +fn is_retryable_status(status: u16) -> bool { + matches!(status, 408 | 409 | 429 | 500..=599) +} + +/// The error for a provider's error response. The rules follow the OpenAI and Anthropic +/// SDKs (`x-should-retry`, then the status) and gemini-cli (a daily quota is spent until +/// tomorrow, and the wait Gemini asks for is in its body). +pub fn response_error( + status: StatusCode, + headers: &HeaderMap, + body: &str, + message: String, +) -> Error { + let retryable = match headers.get("x-should-retry").and_then(|v| v.to_str().ok()) { + Some("true") => true, + Some("false") => false, + _ => { + is_retryable_status(status.as_u16()) + // OpenAI answers a spent balance with the 429 it also uses for rate limits. + && !body.contains("insufficient_quota") + && !google_quota_spent_for_the_day(body) + } + }; + if retryable { + transient_error( + message, + retry_after(headers).or_else(|| google_retry_delay(body)), + ) + } else { + Error::AIError(message) + } +} + +/// The `details` of a Google API error body, where Gemini puts its quota and retry hints. +fn google_error_details(body: &str) -> Vec { + serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("error")?.get("details")?.as_array().cloned()) + .unwrap_or_default() +} + +fn google_quota_spent_for_the_day(body: &str) -> bool { + google_error_details(body).iter().any(|detail| { + detail + .get("violations") + .and_then(|v| v.as_array()) + .is_some_and(|violations| { + violations.iter().any(|violation| { + violation + .get("quotaId") + .and_then(|id| id.as_str()) + .is_some_and(|id| id.contains("PerDay") || id.contains("Daily")) + }) + }) + }) +} + +/// The `RetryInfo.retryDelay` of a Google API error, a protobuf duration such as `"53s"`. +fn google_retry_delay(body: &str) -> Option { + google_error_details(body).iter().find_map(|detail| { + let delay = detail.get("retryDelay")?.as_str()?.strip_suffix('s')?; + let seconds = delay + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 0.0)?; + Some(Duration::try_from_secs_f64(seconds).unwrap_or(Duration::MAX)) + }) +} + +/// The wait a provider asked for in its `retry-after-ms` or `retry-after` header. An +/// HTTP-date `retry-after` is ignored, which falls back to the computed backoff. +fn retry_after(headers: &HeaderMap) -> Option { + let seconds = |name: &str, scale: f64| { + headers + .get(name)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 0.0) + .map(|v| Duration::try_from_secs_f64(v / scale).unwrap_or(Duration::MAX)) + }; + seconds("retry-after-ms", 1000.0).or_else(|| seconds("retry-after", 1.0)) +} + +/// The error object a provider sends inside a stream: OpenAI-compatible chunks carry +/// `type` and a string or status `code`, Anthropic `type`, Gemini a status `code` and +/// `status`. +#[derive(Debug, Default, serde::Deserialize)] +pub struct StreamErrorBody { + #[serde(default)] + pub message: Option, + #[serde(default, rename = "type")] + pub kind: Option, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub status: Option, +} + +impl StreamErrorBody { + /// The error for a failure an OpenAI-compatible or Gemini stream reported after opening + /// with a 200, classified as the Vercel AI SDK classifies OpenAI stream errors + /// (`openai-stream-error.ts`): the status `code` holds, else the status its code and type + /// name, else a server error. + pub fn into_error(self, provider: &str) -> Error { + let code = self.code_str(); + let discriminator = [code.as_deref(), self.kind.as_deref()] + .into_iter() + .flatten() + .collect::>() + .join(" ") + .to_lowercase(); + let has = |terms: &[&str]| terms.iter().any(|term| discriminator.contains(term)); + let status = match code.as_deref().and_then(|code| code.parse::().ok()) { + Some(status @ 400..=599) => status, + _ if has(&["insufficient_quota", "rate_limit"]) => 429, + _ if has(&["authentication"]) => 401, + _ if has(&["permission"]) => 403, + _ if has(&["not_found"]) => 404, + _ if has(&["invalid", "bad_request", "context_length"]) => 400, + _ if has(&["overload"]) => 503, + _ if has(&["timeout"]) => 504, + _ => 500, + }; + let retryable = !has(&["insufficient_quota"]) && is_retryable_status(status); + self.into_classified_error(provider, retryable) + } + + /// The error for a failure an Anthropic stream reported after opening with a 200. The + /// kinds retried are those the Vercel AI SDK retries, plus `timeout_error`, which + /// Anthropic documents as its 504; any other kind is a fault in the request or account. + pub fn into_anthropic_error(self) -> Error { + let retryable = matches!( + self.kind.as_deref(), + Some("api_error" | "overloaded_error" | "rate_limit_error" | "timeout_error") + ); + self.into_classified_error("Anthropic", retryable) + } + + fn code_str(&self) -> Option { + match self.code.as_ref()? { + serde_json::Value::String(code) => Some(code.clone()), + serde_json::Value::Number(code) => Some(code.to_string()), + _ => None, + } + } + + fn into_classified_error(self, provider: &str, retryable: bool) -> Error { + let kind = [self.kind.clone(), self.code_str(), self.status] + .into_iter() + .flatten() + .collect::>() + .join("/"); + let message = format!( + "{provider} stream error{}: {}", + (!kind.is_empty()) + .then(|| format!(" ({kind})")) + .unwrap_or_default(), + self.message.as_deref().unwrap_or("no details") + ); + if retryable { + transient_error(message, None) + } else { + Error::AIError(message) + } + } +} + +/// The error for a stream that closed before the provider sent the event ending a +/// complete response: cut off by the provider or by a proxy in between. +pub fn truncated_stream_error() -> Error { + transient_error( + "The AI provider's response stream ended before the response was complete", + None, + ) +} + +/// An OpenAI-format stream chunk reporting `error`, which ends a response converted to that +/// format after it started streaming: the chat's OpenAI SDK throws on a chunk carrying +/// `error`, where a bare end of stream would read as a finished answer. +pub fn openai_stream_error_chunk(error: &Error) -> String { + let message = as_transient(error).map_or_else(|| error.to_string(), |t| t.message.clone()); + format!( + "data: {}\n\n", + serde_json::json!({ "error": { "message": message } }) + ) +} + +/// Sends a provider request, retrying a transient failure while nothing of the response +/// has been read: a failed connection or an error status. Once retries are spent the error +/// carries the provider's answer, as a single attempt's would. +pub async fn send_with_retries(request: RequestBuilder) -> Result { + let mut backoff = Backoff::default(); + loop { + // A streamed body cannot be replayed, so such a request is sent once. + let Some(attempt) = request.try_clone() else { + return send_once(request).await.map_err(into_final_error); + }; + let error = match send_once(attempt).await { + Ok(response) => return Ok(response), + Err(error) => error, + }; + let Some(delay) = as_transient(&error).and_then(|t| backoff.next_delay(t)) else { + return Err(into_final_error(error)); + }; + tracing::warn!( + "AI provider call failed ({error}); retrying in {:.1}s ({}/{MAX_RETRIES})", + delay.as_secs_f64(), + backoff.retries() + ); + tokio::time::sleep(delay).await; + } +} + +/// The error for a provider request that failed before any response arrived. The request +/// timeout is the caller's whole budget, so a timed-out call leaves no time for another +/// attempt; a connect timeout has its own, shorter one. +pub fn send_error(e: reqwest::Error, context: &str) -> Error { + let message = format!("{context}: {e}"); + if e.is_builder() || (e.is_timeout() && !e.is_connect()) { + Error::AIError(message) + } else { + transient_error(message, None) + } +} + +async fn send_once(request: RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|e| send_error(e, "Failed to reach the AI provider"))?; + let status = response.status(); + if status.is_client_error() || status.is_server_error() { + let headers = response.headers().clone(); + let body = response.text().await.unwrap_or_default(); + return Err(response_error(status, &headers, &body, body.clone())); + } + Ok(response) +} + +/// Counts the retries of one provider call and spaces them out. +#[derive(Debug, Default)] +pub struct Backoff { + retries: u32, +} + +impl Backoff { + /// The wait before retrying a call that failed with `error`, or `None` once it should + /// fail instead. + pub fn next_delay(&mut self, error: &TransientAIError) -> Option { + if self.retries >= MAX_RETRIES { + return None; + } + let delay = match error.retry_after { + Some(wait) if wait > MAX_RETRY_AFTER => return None, + // A zero wait would retry back to back, so it gets the computed backoff. + Some(wait) if !wait.is_zero() => wait, + // Jittered, so callers throttled together do not come back together. + _ => (BASE_DELAY * 2u32.pow(self.retries)) + .min(MAX_BACKOFF) + .mul_f64(0.5 + rand::random::() * 0.5), + }; + self.retries += 1; + Some(delay) + } + + /// Retries granted so far. + pub fn retries(&self) -> u32 { + self.retries + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Error responses as providers send them, each with the retry decision of the client + /// the rule is taken from. + #[test] + fn classifies_error_responses() { + const GEMINI_QUOTA: &str = r#"{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaId":"QUOTA_ID"}]},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"53s"}]}}"#; + let cases: &[(u16, &[(&str, &str)], &str, Option>)] = &[ + ( + 429, + &[], + r#"{"error":{"code":"rate_limit_exceeded"}}"#, + Some(None), + ), + ( + 429, + &[], + r#"{"error":{"type":"insufficient_quota","code":"insufficient_quota"}}"#, + None, + ), + ( + 529, + &[], + r#"{"type":"error","error":{"type":"overloaded_error"}}"#, + Some(None), + ), + (501, &[], "", Some(None)), + (400, &[], "", None), + (401, &[], "", None), + (400, &[("x-should-retry", "true")], "", Some(None)), + (503, &[("x-should-retry", "false")], "", None), + (429, &[("retry-after", "2")], "", Some(Some(2))), + ( + 429, + &[], + &GEMINI_QUOTA.replace("QUOTA_ID", "GenerateRequestsPerMinutePerProjectPerModel"), + Some(Some(53)), + ), + ( + 429, + &[], + &GEMINI_QUOTA.replace( + "QUOTA_ID", + "GenerateRequestsPerDayPerProjectPerModel-FreeTier", + ), + None, + ), + ]; + for (status, headers, body, expected) in cases { + let headers = headers + .iter() + .map(|(k, v)| (k.parse().unwrap(), v.parse().unwrap())) + .collect::(); + let error = response_error( + StatusCode::from_u16(*status).unwrap(), + &headers, + body, + String::new(), + ); + let got = as_transient(&error).map(|t| t.retry_after.map(|d| d.as_secs())); + assert_eq!(&got, expected, "{status} {headers:?} {body}"); + } + } + + /// In-stream error objects as providers send them, after the 200 opened the stream. + #[test] + fn classifies_stream_errors() { + let retried = |json: &str, anthropic: bool| { + let body: StreamErrorBody = serde_json::from_str(json).unwrap(); + let error = if anthropic { + body.into_anthropic_error() + } else { + body.into_error("AI provider") + }; + as_transient(&error).is_some() + }; + for (json, expected) in [ + (r#"{"message":"m","type":"server_error"}"#, true), + ( + r#"{"message":"m","type":"requests","code":"rate_limit_exceeded"}"#, + true, + ), + ( + r#"{"message":"m","type":"insufficient_quota","code":"insufficient_quota"}"#, + false, + ), + ( + r#"{"message":"m","type":"invalid_request_error","code":"context_length_exceeded"}"#, + false, + ), + // LiteLLM's proxy puts the upstream status in `code` as a string. + (r#"{"message":"m","type":"None","code":"400"}"#, false), + (r#"{"message":"m","code":"503"}"#, true), + (r#"{"code":503,"message":"m","status":"UNAVAILABLE"}"#, true), + ( + r#"{"code":400,"message":"m","status":"INVALID_ARGUMENT"}"#, + false, + ), + (r#"{"message":"m"}"#, true), + ] { + assert_eq!(retried(json, false), expected, "{json}"); + } + for (kind, expected) in [ + ("overloaded_error", true), + ("api_error", true), + ("rate_limit_error", true), + ("timeout_error", true), + ("billing_error", false), + ("invalid_request_error", false), + ("some_new_error", false), + ] { + let json = format!(r#"{{"type":"{kind}","message":"m"}}"#); + assert_eq!(retried(&json, true), expected, "{kind}"); + } + } + + #[test] + fn honours_retry_after_up_to_a_bound() { + let mut headers = HeaderMap::new(); + headers.insert("retry-after", "2".parse().unwrap()); + assert_eq!(retry_after(&headers), Some(Duration::from_secs(2))); + headers.insert("retry-after-ms", "1500".parse().unwrap()); + assert_eq!(retry_after(&headers), Some(Duration::from_millis(1500))); + headers.remove("retry-after-ms"); + headers.insert("retry-after", "1e30".parse().unwrap()); + assert_eq!(retry_after(&headers), Some(Duration::MAX)); + + let mut backoff = Backoff::default(); + let asked = |secs| TransientAIError { + message: String::new(), + retry_after: Some(Duration::from_secs(secs)), + }; + assert_eq!(backoff.next_delay(&asked(5)), Some(Duration::from_secs(5))); + assert_eq!(backoff.next_delay(&asked(3600)), None); + } + + #[test] + fn stops_after_max_retries() { + let mut backoff = Backoff::default(); + let error = TransientAIError { message: String::new(), retry_after: None }; + for _ in 0..MAX_RETRIES { + let delay = backoff.next_delay(&error).expect("retry"); + assert!(delay <= MAX_BACKOFF); + } + assert_eq!(backoff.next_delay(&error), None); + } +} diff --git a/backend/windmill-ai/src/sse.rs b/backend/windmill-ai/src/sse.rs index ada50afca5..bdaa75025f 100644 --- a/backend/windmill-ai/src/sse.rs +++ b/backend/windmill-ai/src/sse.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use eventsource_stream::Eventsource; +use eventsource_stream::{EventStreamError, Eventsource}; use indexmap::IndexMap; use reqwest::Response; use serde::Deserialize; @@ -8,12 +8,13 @@ use tokio_stream::StreamExt; use windmill_common::{error::Error, utils::rd_string}; use crate::{ - ai_google::{parse_gemini_sse_event, GeminiUsageMetadata}, + ai_google::{parse_gemini_sse_event, GeminiTurn, GeminiUsageMetadata}, ai_types::UrlCitation, ai_types::{ AnthropicExtraContent, ExtraContent, GoogleExtraContent, OpenAIFunction, OpenAIToolCall, }, query_builder::StreamEventSink, + retry::{transient_error, truncated_stream_error, StreamErrorBody}, types::{StreamingEvent, TokenUsage}, }; @@ -43,6 +44,8 @@ pub struct OpenAIChoiceDelta { #[derive(Deserialize)] pub struct OpenAIChoice { pub delta: Option, + #[serde(default)] + pub finish_reason: Option, } /// Nested prompt token details returned by the Chat Completions API. @@ -87,6 +90,8 @@ pub struct OpenAISSEEvent { pub choices: Option>, #[serde(default)] pub usage: Option, + #[serde(default)] + pub error: Option, } lazy_static::lazy_static! { @@ -99,6 +104,14 @@ lazy_static::lazy_static! { pub trait SSEParser { async fn parse_event_data(&mut self, data: &str) -> Result<(), Error>; + /// Whether the stream delivered the event that ends a complete response. + fn is_complete(&self) -> bool; + + /// Whether a stream that closed cleanly holds a complete response. + fn is_complete_at_close(&self) -> bool { + self.is_complete() + } + async fn parse_events(&mut self, response: Response) -> Result<(), Error> { let mut stream = response.bytes_stream().eventsource(); let mut consecutive_errors = 0; @@ -113,6 +126,18 @@ pub trait SSEParser { self.parse_event_data(&event.data).await?; } + // The body ends right after a transport error, so it cannot be skipped + // like a malformed event. A timeout spent the caller's whole budget. + Err(EventStreamError::Transport(_)) if self.is_complete() => break, + Err(EventStreamError::Transport(e)) => { + let message = + format!("The connection to the AI provider broke off mid-response: {e}"); + return Err(if e.is_timeout() { + Error::AIError(message) + } else { + transient_error(message, None) + }); + } Err(e) => { consecutive_errors += 1; tracing::error!("Failed to parse SSE event: {}", e); @@ -130,6 +155,9 @@ pub trait SSEParser { } } + if !self.is_complete_at_close() { + return Err(truncated_stream_error()); + } Ok(()) } } @@ -145,6 +173,9 @@ pub struct OpenAISSEParser { pub stream_event_processor: Box, /// Token usage from final chunk (when stream_options.include_usage is true) pub usage: Option, + /// Set by `[DONE]` or by a choice's `finish_reason`: OpenAI-compatible gateways do not + /// all send `[DONE]`. + complete: bool, } impl OpenAISSEParser { @@ -156,13 +187,19 @@ impl OpenAISSEParser { events_str: String::new(), stream_event_processor, usage: None, + complete: false, } } } impl SSEParser for OpenAISSEParser { + fn is_complete(&self) -> bool { + self.complete + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { if data == "[DONE]" { + self.complete = true; return Ok(()); } @@ -173,13 +210,21 @@ impl SSEParser for OpenAISSEParser { .ok(); if let Some(event) = event { + if let Some(error) = event.error { + return Err(error.into_error("AI provider")); + } + // Extract usage from final chunk (when stream_options.include_usage is true) if let Some(usage) = event.usage { self.usage = Some(usage); } if let Some(mut choices) = event.choices.filter(|s| !s.is_empty()) { - if let Some(delta) = choices.remove(0).delta { + let choice = choices.remove(0); + if choice.finish_reason.is_some() { + self.complete = true; + } + if let Some(delta) = choice.delta { if let Some(reasoning) = delta.reasoning_content.filter(|s| !s.is_empty()) { self.accumulated_reasoning.push_str(&reasoning); let event = StreamingEvent::ReasoningTokenDelta { content: reasoning }; @@ -345,6 +390,8 @@ pub enum AnthropicSSEEvent { MessageDelta { #[serde(default)] usage: Option, + #[serde(default)] + delta: Option, }, #[serde(rename = "message_stop")] MessageStop {}, @@ -353,12 +400,18 @@ pub enum AnthropicSSEEvent { #[serde(rename = "error")] Error { #[serde(default)] - message: Option, + error: StreamErrorBody, }, #[serde(other)] Unknown, } +#[derive(Deserialize, Debug)] +pub struct AnthropicMessageDelta { + #[serde(default)] + pub stop_reason: Option, +} + /// Tracks state of a content block during streaming #[derive(Debug)] #[allow(dead_code)] @@ -391,6 +444,9 @@ pub struct AnthropicSSEParser { /// can be replayed before `tool_use` (required by Claude when thinking is on). pending_reasoning: Option, reasoning_attached: bool, + /// Set by `message_stop` or by the `stop_reason` of the `message_delta` sent just + /// before it, so a gateway that drops `message_stop` does not fail a whole answer. + complete: bool, } impl AnthropicSSEParser { @@ -407,11 +463,16 @@ impl AnthropicSSEParser { usage: None, pending_reasoning: None, reasoning_attached: false, + complete: false, } } } impl SSEParser for AnthropicSSEParser { + fn is_complete(&self) -> bool { + self.complete + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { let event: Option = serde_json::from_str(data) .inspect_err(|e| { @@ -583,9 +644,8 @@ impl SSEParser for AnthropicSSEParser { .await?; } } - AnthropicSSEEvent::Error { message } => { - let error_msg = message.unwrap_or_else(|| "Unknown error".to_string()); - tracing::error!("Anthropic streaming error: {}", error_msg); + AnthropicSSEEvent::Error { error } => { + return Err(error.into_anthropic_error()); } AnthropicSSEEvent::MessageStart { message } => { // The only event carrying the prompt-side counts. `message_delta` @@ -595,7 +655,10 @@ impl SSEParser for AnthropicSSEParser { self.usage = Some(usage); } } - AnthropicSSEEvent::MessageDelta { usage } => { + AnthropicSSEEvent::MessageDelta { usage, delta } => { + if delta.is_some_and(|d| d.stop_reason.is_some()) { + self.complete = true; + } if let Some(u) = usage { match &mut self.usage { // Field by field, so the prompt counts from `message_start` @@ -614,10 +677,9 @@ impl SSEParser for AnthropicSSEParser { } } } + AnthropicSSEEvent::MessageStop {} => self.complete = true, // Ignore other events - AnthropicSSEEvent::MessageStop {} - | AnthropicSSEEvent::Ping {} - | AnthropicSSEEvent::Unknown => {} + AnthropicSSEEvent::Ping {} | AnthropicSSEEvent::Unknown => {} } } @@ -645,6 +707,7 @@ pub struct GeminiSSEParser { pub annotations: Vec, pub used_websearch: bool, pub usage: Option, + turn: GeminiTurn, } impl GeminiSSEParser { @@ -659,15 +722,25 @@ impl GeminiSSEParser { annotations: Vec::new(), used_websearch: false, usage: None, + turn: GeminiTurn::default(), } } } impl SSEParser for GeminiSSEParser { + fn is_complete(&self) -> bool { + self.turn.ended() + } + + fn is_complete_at_close(&self) -> bool { + self.turn.complete_at_close() + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { let Some(parsed) = parse_gemini_sse_event(data)? else { return Ok(()); }; + self.turn.observe(&parsed); if let Some(reasoning) = parsed.reasoning.filter(|s| !s.is_empty()) { self.accumulated_reasoning.push_str(&reasoning); @@ -797,6 +870,8 @@ impl OpenAIResponsesUsage { pub struct OpenAIResponsesResponse { #[serde(default)] pub usage: Option, + #[serde(default)] + pub error: Option, } /// SSE event types for OpenAI Responses API streaming @@ -833,6 +908,20 @@ pub enum OpenAIResponsesSSEEvent { #[serde(rename = "response.incomplete")] Incomplete { response: OpenAIResponsesResponse }, + #[serde(rename = "response.failed")] + Failed { response: OpenAIResponsesResponse }, + + /// Flat (`code`, `message`) from OpenAI, nested under `error` from some compatible servers. + #[serde(rename = "error")] + Error { + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, + #[serde(default)] + error: Option, + }, + /// Response created #[serde(rename = "response.created")] Created {}, @@ -890,6 +979,7 @@ pub struct OpenAIResponsesSSEParser { pub usage: Option, /// Reasoning summary parts seen so far, to separate them as paragraphs reasoning_summary_parts: usize, + response_ended: bool, } impl OpenAIResponsesSSEParser { @@ -906,11 +996,16 @@ impl OpenAIResponsesSSEParser { used_websearch: false, usage: None, reasoning_summary_parts: 0, + response_ended: false, } } } impl SSEParser for OpenAIResponsesSSEParser { + fn is_complete(&self) -> bool { + self.response_ended + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { let event: Option = serde_json::from_str(data) .inspect_err(|e| { @@ -1010,11 +1105,22 @@ impl SSEParser for OpenAIResponsesSSEParser { OpenAIResponsesSSEEvent::Completed { response } | OpenAIResponsesSSEEvent::Incomplete { response } => { + self.response_ended = true; if let Some(usage) = response.usage { self.usage = Some(usage); } } + OpenAIResponsesSSEEvent::Failed { response } => { + return Err(response.error.unwrap_or_default().into_error("OpenAI")); + } + + OpenAIResponsesSSEEvent::Error { code, message, error } => { + return Err(error + .unwrap_or(StreamErrorBody { code, message, ..Default::default() }) + .into_error("OpenAI")); + } + OpenAIResponsesSSEEvent::ReasoningSummaryPartAdded {} => { self.reasoning_summary_parts += 1; if self.reasoning_summary_parts > 1 { @@ -1037,9 +1143,10 @@ impl SSEParser for OpenAIResponsesSSEParser { } } + OpenAIResponsesSSEEvent::Done {} => self.response_ended = true, + // Ignore other event types - OpenAIResponsesSSEEvent::Done {} - | OpenAIResponsesSSEEvent::Created {} + OpenAIResponsesSSEEvent::Created {} | OpenAIResponsesSSEEvent::InProgress {} | OpenAIResponsesSSEEvent::OutputItemDone {} | OpenAIResponsesSSEEvent::ContentPartAdded {} @@ -1093,6 +1200,117 @@ mod tests { } } + fn sse_response(chunks: Vec>) -> Response { + let body = reqwest::Body::wrap_stream(futures::stream::iter(chunks)); + Response::from(http::Response::new(body)) + } + + /// A stream that breaks off, or reports a failure inside its 200, must fail the call + /// rather than hand back what arrived as if it were the whole answer. + #[tokio::test] + async fn stream_failures_are_errors_not_partial_answers() { + use crate::retry::as_transient; + + const STARTED: &str = concat!( + "data: {\"type\":\"message_start\"}\n\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}\n\n", + ); + let anthropic = |chunks| async move { + AnthropicSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(chunks)) + .await + }; + + let overloaded = anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"), + ]) + .await + .unwrap_err(); + assert!(as_transient(&overloaded).is_some(), "{overloaded}"); + + let refused = anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"bad\"}}\n\n"), + ]) + .await + .unwrap_err(); + assert!(as_transient(&refused).is_none(), "{refused}"); + + let truncated = anthropic(vec![Ok(STARTED)]).await.unwrap_err(); + assert!(as_transient(&truncated).is_some(), "{truncated}"); + + let dropped = anthropic(vec![ + Ok(STARTED), + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "reset", + )), + ]) + .await + .unwrap_err(); + assert!(as_transient(&dropped).is_some(), "{dropped}"); + + anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"message_stop\"}\n\n"), + ]) + .await + .unwrap(); + + anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n"), + ]) + .await + .unwrap(); + + let truncated = OpenAISSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(vec![Ok( + "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n", + )])) + .await + .unwrap_err(); + assert!(as_transient(&truncated).is_some(), "{truncated}"); + + let quota = OpenAIResponsesSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(vec![Ok( + "data: {\"type\":\"error\",\"error\":{\"type\":\"insufficient_quota\",\"code\":\"insufficient_quota\",\"message\":\"m\"}}\n\n", + )])) + .await + .unwrap_err(); + assert!(as_transient("a).is_none(), "{quota}"); + + GeminiSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(vec![Ok( + "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{}}}]}}]}\n\n", + )])) + .await + .unwrap(); + + let gemini = |chunks| async move { + GeminiSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(chunks)) + .await + }; + // Thinking spent the whole `maxOutputTokens`: a clean close with no event. + gemini(vec![]).await.unwrap(); + let dropped = gemini(vec![Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "reset", + ))]) + .await + .unwrap_err(); + assert!(as_transient(&dropped).is_some(), "{dropped}"); + let truncated = gemini(vec![Ok( + "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi\"}]}}]}\n\n", + )]) + .await + .unwrap_err(); + assert!(as_transient(&truncated).is_some(), "{truncated}"); + } + /// The prompt-side counts arrive only on `message_start` and the completion total /// only on `message_delta`; a parser that keeps just the last one reports a request /// with no input tokens at all. diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index e591e85159..59733e352c 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -1,3 +1,4 @@ +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::collections::HashMap; @@ -533,7 +534,7 @@ pub struct OpenAPISchema { #[serde(skip_serializing_if = "Option::is_none")] pub items: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option>>, + pub properties: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub required: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "oneOf")] @@ -560,9 +561,9 @@ pub struct OpenAPISchema { #[serde(skip_serializing_if = "Option::is_none", rename = "$ref")] pub ref_path: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "$defs")] - pub defs: Option>>, + pub defs: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - pub definitions: Option>>, + pub definitions: Option>>, // Schema composition #[serde(skip_serializing_if = "Option::is_none", rename = "allOf")] @@ -829,7 +830,7 @@ impl OpenAPISchema { // Merge properties (other's properties are added if key doesn't exist) if let Some(ref other_props) = other.properties { - let props = self.properties.get_or_insert_with(HashMap::new); + let props = self.properties.get_or_insert_with(IndexMap::new); for (key, value) in other_props { props.entry(key.clone()).or_insert_with(|| value.clone()); } @@ -880,13 +881,13 @@ impl OpenAPISchema { // Merge $defs and definitions if let Some(ref other_defs) = other.defs { - let defs = self.defs.get_or_insert_with(HashMap::new); + let defs = self.defs.get_or_insert_with(IndexMap::new); for (key, value) in other_defs { defs.entry(key.clone()).or_insert_with(|| value.clone()); } } if let Some(ref other_definitions) = other.definitions { - let definitions = self.definitions.get_or_insert_with(HashMap::new); + let definitions = self.definitions.get_or_insert_with(IndexMap::new); for (key, value) in other_definitions { definitions .entry(key.clone()) @@ -981,7 +982,29 @@ pub struct S3ObjectWithType { #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; + + /// The schema is part of the prompt prefix the provider caches on, so its keys must + /// serialize in the same order on every request. + #[test] + fn strict_schema_keeps_the_defined_property_order() { + let keys = [ + "zeta", "alpha", "mid", "beta", "omega", "gamma", "delta", "kappa", + ]; + let props = keys + .iter() + .map(|k| format!(r#""{k}":{{"type":"string"}}"#)) + .collect::>() + .join(","); + let mut schema: OpenAPISchema = + serde_json::from_str(&format!(r#"{{"type":"object","properties":{{{props}}}}}"#)) + .unwrap(); + schema.make_strict(); + + let value = serde_json::to_value(&schema).unwrap(); + let serialized: Vec<&String> = value["properties"].as_object().unwrap().keys().collect(); + assert_eq!(serialized, keys); + assert_eq!(value["required"], serde_json::json!(keys)); + } /// Whichever provider served the request, `input_tokens` has to end up meaning the /// whole prompt. Leaving the Anthropic shape as reported under-states it by the @@ -1248,7 +1271,7 @@ mod tests { #[test] fn test_make_strict_defs() { let def_schema = object_schema(vec![("prop", string_schema())]); - let mut defs = HashMap::new(); + let mut defs = IndexMap::new(); defs.insert("MyType".to_string(), Box::new(def_schema)); let mut schema = OpenAPISchema { defs: Some(defs), ..Default::default() }; @@ -1274,7 +1297,7 @@ mod tests { #[test] fn test_make_strict_definitions() { let def_schema = object_schema(vec![("prop", string_schema())]); - let mut definitions = HashMap::new(); + let mut definitions = IndexMap::new(); definitions.insert("MyType".to_string(), Box::new(def_schema)); let mut schema = OpenAPISchema { definitions: Some(definitions), ..Default::default() }; @@ -1528,7 +1551,7 @@ mod tests { #[test] fn test_flatten_all_of_preserves_defs() { let def_schema = object_schema(vec![("field", string_schema())]); - let mut defs = HashMap::new(); + let mut defs = IndexMap::new(); defs.insert("MyType".to_string(), Box::new(def_schema)); let schema_with_defs = OpenAPISchema { defs: Some(defs), ..Default::default() }; @@ -1642,7 +1665,7 @@ mod tests { schema_url: Some("http://json-schema.org/draft-07/schema#".to_string()), ..Default::default() }; - let mut defs = HashMap::new(); + let mut defs = IndexMap::new(); defs.insert("MyType".to_string(), Box::new(def_schema)); let mut schema = OpenAPISchema { diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index dce6a0ff72..21f916bf94 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -170,10 +170,16 @@ const FLOW_JOBS: [&'static str; 6] = [ /// scoped by the agent's path like a deployed runnable's, as `jobs:run:agents:`. const AGENT_JOBS: [&'static str; 1] = ["jobs/run/agent"]; +/// Lock resolution for scripts, apps and flows, scoped as `jobs:run:dependencies:`. +/// Resolving a package can run its build or install scripts on the worker, so this is +/// still code execution: the scope only takes away the direct ways to run code. +const DEPENDENCY_JOBS: [&'static str; 2] = ["jobs/run/dependencies", "jobs/run/flow_dependencies"]; + lazy_static::lazy_static! { static ref RUN_PATH_ACTIONS: Vec<&'static str> = { - let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"]; + let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "apps_u/execute_component", "apps_u/upload_s3_file"]; + v.extend(DEPENDENCY_JOBS); v.extend(SCRIPT_JOBS); v.extend(FLOW_JOBS); v.extend(AGENT_JOBS); @@ -227,11 +233,13 @@ fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction { } } -/// Checks the route path to determine the runnable kind ("agents", "flows" or "scripts"). +/// Checks the route path to determine the runnable kind ("dependencies", "agents", "flows" +/// or "scripts"). /// /// The order of checks is important: -/// - Flow-related paths are checked first to avoid false positives, as some flow paths -/// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`). +/// - Dependency paths are checked before flow paths: `flow_dependencies` starts with `f`. +/// - Flow-related paths are checked before script paths to avoid false positives, as some +/// flow paths (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`). /// /// Returns the kind based on the match, or `None` if no match is found. fn determine_kind_from_route(route_path: &str) -> Option { @@ -247,6 +255,12 @@ fn determine_kind_from_route(route_path: &str) -> Option { { return None; } + if DEPENDENCY_JOBS + .iter() + .any(|path| route_path.starts_with(path)) + { + return Some("dependencies".to_string()); + } if AGENT_JOBS.iter().any(|path| route_path.starts_with(path)) { return Some("agents".to_string()); } @@ -586,17 +600,23 @@ pub struct JobReadConfinement { } impl JobReadConfinement { - /// Whether a job that ran `runnable_path` as `kind` (`scripts`, `flows` or `agents`) - /// is inside the confinement. A read path admits every kind; a run scope only its own. + /// Whether a job that ran `runnable_path` as `kind` (`scripts`, `flows`, `agents` or + /// `dependencies`) is inside the confinement. A read path admits every kind; a run + /// scope only its own. pub fn admits(&self, kind: &str, runnable_path: &str) -> bool { if self.read_paths.allows(runnable_path) { return true; } + // Lock jobs and previews store the path their caller sent. The matcher reads `*` + // in it as a pattern, and `,`/`:` are scope syntax: such a path needs a grant + // covering every path, as enqueuing it did. + let resource = windmill_common::auth::is_scope_literal_path(runnable_path) + .then(|| vec![runnable_path.to_string()]); let required = ScopeDefinition::new( ScopeDomain::Jobs.as_str(), ScopeAction::Run.as_str(), Some(kind), - Some(vec![runnable_path.to_string()]), + resource, ); self.run.iter().any(|scope| scope.includes(&required)) } @@ -862,7 +882,8 @@ fn scope_grants_access( /// from its own super-admin gate, these among them, so each is already readable by every /// authenticated user; each is here because the CLI reads it from a job: /// `automate_username_creation` before creating a user during a git-sync push, `uid` and -/// `hub_base_url` when `u/admin/hub_sync` pulls resource types from the Hub. The other +/// `hub_base_url` when a job runs `wmill hub pull`, as an edited copy of the retired +/// `u/admin/hub_sync` still does (servers sync resource types themselves). The other /// ungated keys have no such caller, so they stay confined — being ungated earns a key /// nothing on its own. Listing a gated key earns it nothing either: `require_super_admin` /// refuses every job token, so `hub_api_secret`, which that pull reads for a private Hub, @@ -996,6 +1017,7 @@ pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uui ["job_metrics", "set_progress", ..] => (http_method == "POST", 2), ["job_metrics", "get_progress", ..] => (http_method == "GET", 2), ["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls", ..] => (http_method == "GET", 2), + ["jobs", "worker_is_draining", ..] => (http_method == "GET", 2), ["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3), ["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3), _ => return false, @@ -1713,6 +1735,24 @@ mod tests { scope_for_route("POST", "/api/w/ws/jobs/run/agent/u/x/y").as_deref(), Some("jobs:run:agents") ); + for path in [ + "/api/w/ws/jobs/run/dependencies", + "/api/w/ws/jobs/run/dependencies_async", + "/api/w/ws/jobs/run/flow_dependencies", + "/api/w/ws/jobs/run/flow_dependencies_async", + ] { + assert_eq!( + scope_for_route("POST", path).as_deref(), + Some("jobs:run:dependencies"), + "{path}" + ); + assert!(check_route_access(&["jobs:run:dependencies".into()], path, "POST").is_ok()); + assert!(check_route_access(&["jobs:run:flows".into()], path, "POST").is_err()); + } + // A dependencies grant reaches no other run route. + for path in ["/api/w/ws/jobs/run/preview", "/api/w/ws/jobs/run/p/u/x/y"] { + assert!(check_route_access(&["jobs:run:dependencies".into()], path, "POST").is_err()); + } // Preview/bundle runs have no deployed path and their handlers require the // broad `jobs:run` scope, so the derived scope must not carry a kind. diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index 7598021f00..f1f4792e71 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -224,12 +224,13 @@ async fn update_config( #[cfg(not(feature = "enterprise"))] let config = if name.starts_with("worker__") { - // In CE, only allow setting worker_tags, cache_clear, init_bash, and native_mode + // In CE, only allow setting worker_tags, cache_clear, init_bash, native_mode and paused serde_json::json!({ "worker_tags": config.get("worker_tags"), "cache_clear": config.get("cache_clear"), "init_bash": config.get("init_bash"), - "native_mode": config.get("native_mode") + "native_mode": config.get("native_mode"), + "paused": config.get("paused") }) } else { config @@ -272,6 +273,18 @@ async fn update_config( } let mut tx = db.begin().await?; + let was_paused = if name.starts_with("worker__") { + sqlx::query_scalar!( + "SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1 FOR UPDATE", + &name + ) + .fetch_optional(&mut *tx) + .await? + .flatten() + .unwrap_or(false) + } else { + false + }; sqlx::query!( "INSERT INTO config (name, config) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config", &name, @@ -291,6 +304,17 @@ async fn update_config( ) .await?; tx.commit().await?; + + if name.starts_with("worker__") { + let paused = config.get("paused").and_then(|v| v.as_bool()) == Some(true); + if paused != was_paused { + windmill_common::feature_usage::log_feature_usage( + "worker_group", + "pause_toggle", + if paused { "pause" } else { "resume" }, + ); + } + } Ok(format!("Updated config {name}")) } diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 6e4d6a1c71..a470648cbb 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -9,16 +9,11 @@ use windmill_common::DEFAULT_HUB_BASE_URL; #[cfg(feature = "embedding")] use windmill_common::HUB_BASE_URL; -use axum::Router; - -#[cfg(feature = "embedding")] use axum::{ extract::{Path, Query}, - Json, + routing::get, + Extension, Json, Router, }; - -#[cfg(feature = "embedding")] -use axum::routing::get; #[cfg(feature = "embedding")] use candle_core::{Device, Tensor}; #[cfg(feature = "embedding")] @@ -27,10 +22,7 @@ use candle_nn::VarBuilder; use candle_transformers::models::bert::{BertModel, Config, DTYPE}; #[cfg(feature = "embedding")] use hf_hub::api::tokio::Api; -#[cfg(feature = "embedding")] -use serde::Deserialize; -#[cfg(feature = "embedding")] -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[cfg(feature = "embedding")] use sqlx::{Pool, Postgres}; #[cfg(feature = "embedding")] @@ -44,9 +36,7 @@ use tokenizers::Tokenizer; use tokio::sync::RwLock; #[cfg(feature = "embedding")] use windmill_common::utils::http_get_from_hub; - -#[cfg(feature = "embedding")] -use windmill_common::error::JsonResult; +use windmill_common::{db::DB, error::JsonResult}; #[cfg(feature = "embedding")] use windmill_store::resources::ResourceType; @@ -60,6 +50,7 @@ lazy_static::lazy_static! { // full pulling interval, so a transient startup error doesn't leave the // embeddings DB uninitialized for a whole day. pub static ref HUB_EMBEDDINGS_RETRY_INTERVAL_SECS: u64 = std::env::var("HUB_EMBEDDINGS_RETRY_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); + pub static ref RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS: u64 = std::env::var("RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); } #[cfg(feature = "embedding")] @@ -104,14 +95,12 @@ async fn query_hub_scripts( } } -#[cfg(feature = "embedding")] #[derive(Deserialize)] struct ResourceTypesQuery { text: String, limit: Option, } -#[cfg(feature = "embedding")] #[derive(Serialize)] pub struct ResourceTypeResult { name: String, @@ -138,24 +127,107 @@ fn trim_to_top_score( .take_while(|r| (top_score - score(r)) / top_score <= max_relative_drop) .collect() } -#[cfg(feature = "embedding")] async fn query_resource_types( + Extension(db): Extension, Query(query): Query, Path(w_id): Path, ) -> JsonResult> { - let embeddings_db = EMBEDDINGS_DB.read().await; - - if let Some(embeddings_db) = embeddings_db.as_ref() { - let results = embeddings_db - .query_resource_types(w_id, &query.text, query.limit) - .await?; - - Ok(Json(results)) - } else { - Err(windmill_common::error::Error::internal_err( - "Embeddings db not initialized".to_string(), - )) + #[cfg(feature = "embedding")] + if let Some(embeddings_db) = EMBEDDINGS_DB.read().await.as_ref() { + return Ok(Json( + embeddings_db + .query_resource_types(w_id, &query.text, query.limit) + .await?, + )); } + Ok(Json( + match_resource_types_by_text(&db, &w_id, &query.text, query.limit).await?, + )) +} + +/// Serves the search when there is no embeddings index: a build without the `embedding` +/// feature, or one whose index never loaded (air-gapped, `DISABLE_EMBEDDING`). The AI chat +/// has no other way to read the instance's resource types. +async fn match_resource_types_by_text( + db: &DB, + w_id: &str, + text: &str, + limit: Option, +) -> windmill_common::error::Result> { + let words: Vec = text + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect(); + let rows: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, description FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", + ) + .bind(w_id) + .fetch_all(db) + .await?; + + let mut ranked: Vec<(f32, String, String)> = rows + .into_iter() + .map(|(workspace_id, name, description)| { + let score = text_match_score(&words, &name, description.as_deref().unwrap_or_default()); + (score, workspace_id, name) + }) + .filter(|(score, _, _)| *score > 0.0) + .collect(); + ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.2.cmp(&b.2))); + ranked.truncate(limit.unwrap_or(10).max(0) as usize); + + // Schemas are read for the kept rows only: a synced instance holds megabytes of them. + let (workspaces, names): (Vec<&str>, Vec<&str>) = ranked + .iter() + .map(|(_, workspace_id, name)| (workspace_id.as_str(), name.as_str())) + .unzip(); + let mut schemas: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, schema FROM resource_type WHERE (workspace_id, name) IN (SELECT * FROM unnest($1::text[], $2::text[]))", + ) + .bind(&workspaces) + .bind(&names) + .fetch_all(db) + .await?; + + Ok(ranked + .into_iter() + .map(|(score, workspace_id, name)| { + let schema = schemas + .iter_mut() + .find(|(w, n, _)| *w == workspace_id && *n == name) + .and_then(|(_, _, schema)| schema.take()); + ResourceTypeResult { score, name, schema } + }) + .collect()) +} + +/// A word found in the name counts twice a word found in the description: names are what +/// a query for a service spells out ("postgres" in `postgresql`, "sheets" in `gsheets`). +/// A word under 3 characters ("s3", "ai") would sit inside too many names, so it counts only +/// as a whole name or one of its `_`-separated parts. +fn text_match_score(words: &[String], name: &str, description: &str) -> f32 { + let name = name.to_lowercase(); + let description = description.to_lowercase(); + words + .iter() + .map(|word| { + let short = word.len() < 3; + let in_name = if short { + name.split('_').any(|part| part == word) + } else { + name.contains(word.as_str()) + }; + if in_name { + 2.0 + } else if !short && description.contains(word.as_str()) { + 1.0 + } else { + 0.0 + } + }) + .sum() } #[cfg(feature = "embedding")] @@ -288,17 +360,60 @@ impl ModelInstance { pub struct EmbeddingsDb { db: Db, model_instance: Arc, + /// Kept from the last full fill, so the resource types can be re-indexed between fills + /// without downloading the hub's embeddings again. + hub_resource_type_embeddings: Arc>>, + /// The `resource_types_fingerprint` the resource types collection was built from. + resource_types_fingerprint: Option, + /// See `ResourceTypeIndex::custom_vectors`. + custom_resource_type_vectors: Arc>>, +} + +/// A built resource types collection, with what a later refresh needs to tell it is stale and +/// to reuse its vectors. +#[cfg(feature = "embedding")] +struct ResourceTypeIndex { + fingerprint: Option, + embeddings: Vec, + /// Vectors of the types the hub has none for, by the `name;description` they embed: a + /// refresh runs the model only on types that are new or changed, not on every custom type. + custom_vectors: Arc>>, } #[cfg(feature = "embedding")] impl EmbeddingsDb { + fn empty(model_instance: Arc) -> Self { + Self { + db: Db::new(), + model_instance, + hub_resource_type_embeddings: Arc::new(HashMap::new()), + resource_types_fingerprint: None, + custom_resource_type_vectors: Arc::new(HashMap::new()), + } + } + pub async fn new(pg_db: &Pool, model_instance: Arc) -> Result { - let db = Db::new(); - - let mut embeddings_db = Self { db, model_instance: model_instance.clone() }; - + let mut embeddings_db = Self::empty(model_instance); embeddings_db.fill_db(pg_db).await?; + Ok(embeddings_db) + } + /// The resource types alone, all embedded locally, for when the full fill cannot download + /// the hub's embeddings: an air-gapped instance, or a private hub serving none. Hub script + /// search keeps failing, since it has no collection to search. + async fn resource_types_only( + pg_db: &Pool, + model_instance: Arc, + ) -> Result { + let mut embeddings_db = Self::empty(model_instance); + let index = resource_type_index( + pg_db, + &embeddings_db.model_instance, + &embeddings_db.hub_resource_type_embeddings, + &embeddings_db.custom_resource_type_vectors, + ) + .await?; + embeddings_db.replace_resource_types(index)?; Ok(embeddings_db) } @@ -310,13 +425,6 @@ impl EmbeddingsDb { self.db .create_collection("scripts".to_string(), 384, Distance::Cosine)?; - if self.db.get_collection("resource_types").is_some() { - self.db.delete_collection("resource_types")?; - } - - self.db - .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; - let hub_base_url = (**HUB_BASE_URL.load()).clone(); let response = match hub_base_url.as_str() { @@ -425,44 +533,40 @@ impl EmbeddingsDb { )); } let hub_resource_types = response.json::>().await?; + self.hub_resource_type_embeddings = Arc::new( + hub_resource_types + .into_iter() + .map(|rt| (rt.name, rt.embedding)) + .collect(), + ); - let resource_types: Vec = - sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) - .fetch_all(pg_db) - .await?; + let index = resource_type_index( + pg_db, + &self.model_instance, + &self.hub_resource_type_embeddings, + &self.custom_resource_type_vectors, + ) + .await?; + self.replace_resource_types(index) + } - for rt in resource_types { - let mut hm = HashMap::new(); - hm.insert("name".to_string(), rt.name.clone()); - if let Some(schema) = rt.schema.clone() { - hm.insert("schema".to_string(), serde_json::to_string(&schema)?); - } - hm.insert("workspace".to_string(), rt.workspace_id.clone()); - let hub_rt = hub_resource_types.iter().find(|hrt| hrt.name == rt.name); - - let vector = if let Some(hub_rt) = hub_rt { - hub_rt.embedding.clone() - } else { - self.model_instance - .clone() - .create_embedding(&format!( - "{};{}", - rt.name, - rt.description.unwrap_or_default() - )) - .await? - }; - - let embedding = Embedding { - id: format!("{}_{}", rt.workspace_id, rt.name), - vector, - metadata: Some(hm), - }; - - self.db - .insert_into_collection("resource_types", embedding)?; + fn replace_resource_types(&mut self, index: ResourceTypeIndex) -> Result<()> { + // Built aside and swapped in whole: an insert the collection refuses must not leave + // searches on a half-filled index. + const NEXT: &str = "resource_types_next"; + self.db.collections.remove(NEXT); + self.db + .create_collection(NEXT.to_string(), 384, Distance::Cosine)?; + for embedding in index.embeddings { + self.db.insert_into_collection(NEXT, embedding)?; } - + if let Some(next) = self.db.collections.remove(NEXT) { + self.db + .collections + .insert("resource_types".to_string(), next); + } + self.resource_types_fingerprint = index.fingerprint; + self.custom_resource_type_vectors = index.custom_vectors; Ok(()) } @@ -617,6 +721,101 @@ impl EmbeddingsDb { } } +/// Covers everything an indexed resource type is built from, whichever path wrote it: the +/// create and update routes, a hub sync's direct upserts, a delete. +/// +/// A sum of per-row hashes needs no sort. Don't go back to an ORDER BY aggregate: sorting +/// every schema spills to disk once they exceed work_mem, on every check of every server. +#[cfg(feature = "embedding")] +async fn resource_types_fingerprint(pg_db: &Pool) -> Result> { + Ok(sqlx::query_scalar( + "SELECT count(*)::text || ':' || coalesce(sum(('x' || left(md5(workspace_id || '/' || name || '/' || coalesce(description, '') || '/' || coalesce(schema::text, '')), 15))::bit(60)::bigint), 0)::text FROM resource_type", + ) + .fetch_one(pg_db) + .await?) +} + +/// The fingerprint is read before the rows: a write landing in between leaves it stale, so +/// the next check indexes again rather than keeping an index that misses the write. +#[cfg(feature = "embedding")] +async fn resource_type_index( + pg_db: &Pool, + model_instance: &Arc, + hub_embeddings: &HashMap>, + known_custom_vectors: &HashMap>, +) -> Result { + let fingerprint = resource_types_fingerprint(pg_db).await?; + let resource_types: Vec = + sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) + .fetch_all(pg_db) + .await?; + + let mut embeddings = Vec::with_capacity(resource_types.len()); + let mut custom_vectors = HashMap::new(); + for rt in resource_types { + let mut hm = HashMap::new(); + hm.insert("name".to_string(), rt.name.clone()); + if let Some(schema) = rt.schema.clone() { + hm.insert("schema".to_string(), serde_json::to_string(&schema)?); + } + hm.insert("workspace".to_string(), rt.workspace_id.clone()); + + let vector = match hub_embeddings.get(&rt.name) { + Some(vector) => vector.clone(), + None => { + let text = format!("{};{}", rt.name, rt.description.unwrap_or_default()); + let vector = match known_custom_vectors + .get(&text) + .or_else(|| custom_vectors.get(&text)) + { + Some(vector) => vector.clone(), + None => model_instance.clone().create_embedding(&text).await?, + }; + custom_vectors.insert(text, vector.clone()); + vector + } + }; + + embeddings.push(Embedding { + // Neither part can hold a `/`, so two types never share an id; with `_` between + // them, `acme_c` + `c_x` and `acme` + `c_c_x` would. + id: format!("{}/{}", rt.workspace_id, rt.name), + vector, + metadata: Some(hm), + }); + } + Ok(ResourceTypeIndex { fingerprint, embeddings, custom_vectors: Arc::new(custom_vectors) }) +} + +/// The full fill runs once a day, so a resource type created or synced in between would stay +/// unsearchable until then; this re-indexes the resource types alone when the table changed. +#[cfg(feature = "embedding")] +pub async fn refresh_resource_type_embeddings(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_none() { + return Ok(()); + } + let fingerprint = resource_types_fingerprint(pg_db).await?; + let (model_instance, hub_embeddings, custom_vectors) = match EMBEDDINGS_DB.read().await.as_ref() + { + Some(db) if db.resource_types_fingerprint != fingerprint => ( + db.model_instance.clone(), + db.hub_resource_type_embeddings.clone(), + db.custom_resource_type_vectors.clone(), + ), + _ => return Ok(()), + }; + let index = + resource_type_index(pg_db, &model_instance, &hub_embeddings, &custom_vectors).await?; + if let Some(db) = EMBEDDINGS_DB.write().await.as_mut() { + // A full fill that landed meanwhile indexed the types from newer hub vectors than + // the ones this index was built from. + if Arc::ptr_eq(&db.hub_resource_type_embeddings, &hub_embeddings) { + db.replace_resource_types(index)?; + } + } + Ok(()) +} + #[cfg(feature = "embedding")] fn normalize_l2(v: &Tensor) -> Result { Ok(v.broadcast_div(&v.sqr()?.sum_keepdim(1)?.sqrt()?)?) @@ -630,6 +829,19 @@ pub fn load_embeddings_db(db: &Pool) -> () { .unwrap_or(false); if !disable_embedding { + let resource_types_db = db.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs( + *RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS, + )) + .await; + if let Err(e) = refresh_resource_type_embeddings(&resource_types_db).await { + tracing::warn!("Failed to refresh resource type embeddings: {e:#}"); + } + } + }); + let db_clone = db.clone(); tokio::spawn(async move { // Keep retrying model init: a transient failure here must not @@ -664,6 +876,9 @@ pub fn load_embeddings_db(db: &Pool) -> () { backoff_secs = *HUB_EMBEDDINGS_RETRY_INTERVAL_SECS; *HUB_EMBEDDINGS_PULLING_INTERVAL_SECS } else { + if let Err(e) = index_resource_types_alone(&db_clone).await { + tracing::warn!("Failed to index the resource types alone: {e:#}"); + } let secs = backoff_secs; backoff_secs = backoff_secs .saturating_mul(2) @@ -696,7 +911,29 @@ pub async fn update_embeddings_db(db: &Pool) -> bool { } } +/// After a failed full fill, so a fill still downloading at startup is not raced. A full fill +/// that succeeds later replaces this index. #[cfg(feature = "embedding")] +async fn index_resource_types_alone(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_some() { + return Ok(()); + } + let Some(model_instance) = MODEL_INSTANCE.read().await.clone() else { + return Ok(()); + }; + let started = std::time::Instant::now(); + let embeddings_db = EmbeddingsDb::resource_types_only(pg_db, model_instance).await?; + let mut current = EMBEDDINGS_DB.write().await; + if current.is_none() { + *current = Some(embeddings_db); + tracing::info!( + "Indexed the resource types alone in {:?}, without the hub's embeddings", + started.elapsed() + ); + } + Ok(()) +} + pub fn workspaced_service() -> Router { Router::new().route("/query_resource_types", get(query_resource_types)) } @@ -706,11 +943,6 @@ pub fn global_service() -> Router { Router::new().route("/query_hub_scripts", get(query_hub_scripts)) } -#[cfg(not(feature = "embedding"))] -pub fn workspaced_service() -> Router { - Router::new() -} - #[cfg(not(feature = "embedding"))] pub fn global_service() -> Router { Router::new() @@ -768,3 +1000,34 @@ mod tests { assert_eq!(trim_to_top_score(vec![0.42f32], 0.05, |s| *s), vec![0.42]); } } + +#[cfg(test)] +mod text_match_tests { + use super::text_match_score; + + // A query names the service, which a type's name only contains ("postgres" in + // `postgresql`): an exact comparison finds nothing for "postgres database". + #[test] + fn ranks_a_name_match_above_a_description_match() { + let words = vec!["postgres".to_string(), "database".to_string()]; + assert_eq!( + text_match_score(&words, "PostgreSQL", "PostgreSQL connection"), + 2.0 + ); + assert_eq!( + text_match_score(&words, "mysql", "MySQL database credentials"), + 1.0 + ); + assert_eq!(text_match_score(&words, "slack", "Slack bot token"), 0.0); + } + + // Many services go by a short name; matching it inside other names would rank `ms365` + // with `s3`. + #[test] + fn matches_a_short_word_only_as_a_whole_name_part() { + let words = vec!["s3".to_string()]; + assert_eq!(text_match_score(&words, "s3", "Amazon S3 bucket"), 2.0); + assert_eq!(text_match_score(&words, "aws_s3", ""), 2.0); + assert_eq!(text_match_score(&words, "ms365", "mentions s3"), 0.0); + } +} diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 1007cfd5b1..eab73bc015 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -20,7 +20,8 @@ use windmill_api_auth::{ require_owner_of_path, ApiAuthed, }; use windmill_common::workspaces::{ - check_deploy_rules, check_operator_can_build_flows, operator_can_build_flows, RuleCheckResult, + check_deploy_rules, check_operator_can_build, operator_builder_rights, BuilderKind, + RuleCheckResult, }; use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, @@ -243,7 +244,7 @@ async fn list_flows( // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && (!authed.is_operator || operator_can_build_flows(&db, &w_id).await?) + && (!authed.is_operator || operator_builder_rights(&db, &w_id).await?.flows) && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() @@ -731,7 +732,14 @@ async fn create_flow( Path(w_id): Path, Json(mut nf): Json, ) -> Result<(StatusCode, String)> { - check_operator_can_build_flows(&db, &w_id, authed.is_operator, "create flows").await?; + check_operator_can_build( + &db, + &w_id, + authed.is_operator, + BuilderKind::Flows, + "create flows", + ) + .await?; check_scopes(&authed, || format!("flows:write:{}", nf.path))?; // A `<= 0` flow timeout is "unset", not a 0-second limit that kills every run instantly. @@ -1344,7 +1352,14 @@ async fn update_flow( Path((w_id, flow_path)): Path<(String, StripPath)>, Json(ef): Json, ) -> Result { - check_operator_can_build_flows(&db, &w_id, authed.is_operator, "update flows").await?; + check_operator_can_build( + &db, + &w_id, + authed.is_operator, + BuilderKind::Flows, + "update flows", + ) + .await?; let flow_path = flow_path.to_path(); // The URL identifies the flow being updated; the body path is only needed to rename. let mut nf = ef.into_new_flow(flow_path); @@ -2038,7 +2053,14 @@ async fn archive_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Json(archived): Json, ) -> Result { - check_operator_can_build_flows(&db, &w_id, authed.is_operator, "archive flows").await?; + check_operator_can_build( + &db, + &w_id, + authed.is_operator, + BuilderKind::Flows, + "archive flows", + ) + .await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( @@ -2179,7 +2201,14 @@ async fn delete_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Query(query): Query, ) -> Result { - check_operator_can_build_flows(&db, &w_id, authed.is_operator, "delete flows").await?; + check_operator_can_build( + &db, + &w_id, + authed.is_operator, + BuilderKind::Flows, + "delete flows", + ) + .await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index 0209af8585..d9550d2f5b 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -470,26 +470,16 @@ async fn update_igroup( require_super_admin(&db, &authed).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; - let exists_opt = sqlx::query("SELECT 1 FROM instance_group WHERE name = $1") - .bind(name.clone()) - .fetch_optional(&mut *tx) - .await?; - not_found_if_none(exists_opt, "instance_group", name.clone())?; + let old_role_opt = sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE", + &name + ) + .fetch_optional(&mut *tx) + .await?; + let old_role = not_found_if_none(old_role_opt, "instance_group", name.clone())?; let validated_role = validate_instance_role(&igroup_update.instance_role)?; - // Fetch old role before updating so we can detect changes - let old_role = if igroup_update.instance_role.is_some() { - sqlx::query_scalar!( - "SELECT instance_role FROM instance_group WHERE name = $1", - &name - ) - .fetch_one(&mut *tx) - .await? - } else { - None - }; - sqlx::query("UPDATE instance_group SET summary = $1, instance_role = $2 WHERE name = $3") .bind(igroup_update.new_summary) .bind(&validated_role) @@ -497,8 +487,9 @@ async fn update_igroup( .execute(&mut *tx) .await?; - // If instance_role actually changed, propagate to all group members - if igroup_update.instance_role.is_some() && old_role != validated_role { + // An omitted or null instance_role is stored as NULL above, so it revokes like any other + // change: members must be recomputed whenever the stored role differs. + if old_role != validated_role { propagate_instance_group_roles(&name, &mut tx).await?; } @@ -664,21 +655,19 @@ async fn delete_igroup( // FOR UPDATE: the group row is the group-level mutex, taken before the workspace // advisory locks (see reconcile_workspace_instance_groups). - let group_role = sqlx::query_scalar!( + sqlx::query_scalar!( "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE", &name ) .fetch_optional(&mut *tx) - .await? - .flatten(); + .await?; - let affected_members: Vec = if group_role.is_some() { + // Collected whatever the group's current role: a member can still hold a role this group + // granted before it was cleared. + let affected_members: Vec = sqlx::query_scalar!("SELECT email FROM email_to_igroup WHERE igroup = $1", &name) .fetch_all(&mut *tx) - .await? - } else { - vec![] - }; + .await?; // Captured and advisory-locked before the settings update strips the group from them. #[cfg(feature = "private")] diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index ddb94f8c2b..0acba27c41 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -417,6 +417,89 @@ async fn concurrent_role_creations_both_survive(db: Pool) -> anyhow::R outcome } +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_existing_role_is_taken_over_only_when_asked_and_never_with_members( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let plain = format!("wmtest_plain_{suffix}"); + let group = format!("wmtest_group_{suffix}"); + let member = format!("wmtest_member_{suffix}"); + for sql in [ + // An expired password and no connection slot: kept as is, the login Windmill sets is dead. + format!( + "CREATE ROLE \"{plain}\" LOGIN PASSWORD 'theirs' VALID UNTIL '2000-01-01' \ + CONNECTION LIMIT 0" + ), + format!("CREATE ROLE \"{group}\" NOLOGIN"), + format!("CREATE ROLE \"{member}\" LOGIN"), + format!("GRANT \"{group}\" TO \"{member}\""), + ] { + sqlx::query(&sql).execute(&db).await?; + } + + let create = |name: String, take_over: bool| async move { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/settings/datatable_roles" + )), + "SECRET_TOKEN", + ) + .json(&json!({ "name": name, "take_over": take_over })) + .send() + .await?; + Ok::<_, anyhow::Error>((resp.status().as_u16(), resp.text().await?)) + }; + let outcome = async { + let (status, body) = create(plain.clone(), false).await?; + assert_eq!( + status, 409, + "an existing role is not taken without asking: {body}" + ); + + // The group's member would inherit every grant the data table role gets. + let (status, body) = create(group.clone(), true).await?; + assert_eq!(status, 400, "{body}"); + assert!( + body.contains(&member), + "the refusal names the member: {body}" + ); + + let (status, body) = create(plain.clone(), true).await?; + assert_eq!(status, 200, "{body}"); + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; + let taken = catalog + .values() + .find(|r| r.name == plain) + .ok_or_else(|| anyhow::anyhow!("{plain} is not in the catalog: {catalog:?}"))?; + let options = (*db.connect_options()) + .clone() + .username(&plain) + .password(taken.pwd.as_deref().unwrap_or_default()); + let mut login = ::connect_with(&options).await?; + sqlx::query("SELECT 1").execute(&mut login).await?; + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide and outlive this test's database, whatever happened above. + for name in [&member, &group, &plain] { + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + } + outcome +} + #[cfg(all(feature = "private", feature = "enterprise"))] #[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled( @@ -1764,3 +1847,242 @@ async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database( assert_eq!(users, vec!["test-workspace".to_string()]); Ok(()) } + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_connection_naming_no_role_holds_the_default_one_and_its_identitys( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::workspaces::{ + get_datatable_resource_from_db, DatatableAccess, DatatableConnectAs, + }; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let names = [ + format!("wmtest_floor_{suffix}"), + format!("wmtest_emea_{suffix}"), + format!("wmtest_amer_{suffix}"), + ]; + let roles_url = format!("http://localhost:{port}/api/settings/datatable_roles"); + const MEMBER_LOGINS: &str = "SELECT m.rolname::text FROM pg_auth_members am + JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member + WHERE r.rolname = $1 AND m.rolname LIKE 'wm\\_set\\_%'"; + + let connect_job = |owner: &'static str, role: Option<&'static str>, connect_as| { + let (permissioned_as, email) = match owner { + "test-user" => ("u/test-user", "test@windmill.dev"), + "test-user-2" => ("u/test-user-2", "test2@windmill.dev"), + _ => ("u/test-user-3", "test3@windmill.dev"), + }; + get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + role, + DatatableAccess::PermissionedAs { permissioned_as, email, connect_as }, + ) + }; + // An app published by the workspace admin `test-user`, run by `viewer`, whose runnable + // connects as its viewer. + let connect = |role: Option<&'static str>, viewer: Option<&'static str>| { + connect_job("test-user", role, DatatableConnectAs::Viewer(viewer)) + }; + let set_permissions = |default_role: String, roles: Value| { + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables,main,permissions}', $1) + WHERE workspace_id = 'test-workspace'", + ) + .bind(json!({ "default_role": default_role, "roles": roles })) + .execute(&db) + }; + + let outcome = async { + let mut ids = vec![]; + for name in &names { + let created: Value = authed(client().post(&roles_url), "SECRET_TOKEN") + .json(&json!({ "name": name })) + .send() + .await? + .error_for_status()? + .json() + .await?; + ids.push(created["id"].as_str().unwrap().to_string()); + } + // `test-user-2` is named by both regional roles, one of them through a group, and by + // `admin`; `test-user-3` by one; the workspace admin `test-user` by neither. + let roles = json!({ + "admin": {"tenants": ["u/test-user-2"]}, + (ids[0].as_str()): {"tenants": ["*"]}, + (ids[1].as_str()): {"tenants": ["u/test-user-2", "u/test-user-3"]}, + (ids[2].as_str()): {"tenants": ["g/analysts"]}, + }); + set_permissions(ids[0].clone(), roles.clone()).await?; + sqlx::query( + "INSERT INTO usr_to_group (workspace_id, usr, group_) + VALUES ('test-workspace', 'test-user-2', 'analysts')", + ) + .execute(&db) + .await?; + let member_of = |login: String| async { + let mut roles: Vec = sqlx::query_scalar( + "SELECT r.rolname::text FROM pg_auth_members am + JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member + WHERE m.rolname = $1", + ) + .bind(login) + .fetch_all(&db) + .await?; + roles.sort(); + Ok::<_, anyhow::Error>(roles) + }; + + let all = connect(None, Some("test2@windmill.dev")).await?; + let login = all["user"].as_str().unwrap().to_string(); + assert!(login.starts_with("wm_set_"), "{login}"); + let mut expected = names.to_vec(); + expected.sort(); + assert_eq!( + member_of(login.clone()).await?, + expected, + "the login holds the default role and the viewer's, and not `admin`" + ); + assert_eq!(connect(None, Some("test2@windmill.dev")).await?, all); + + let two = connect(None, Some("test3@windmill.dev")).await?; + let mut expected = names[..2].to_vec(); + expected.sort(); + assert_eq!( + member_of(two["user"].as_str().unwrap().to_string()).await?, + expected + ); + + // A runnable that does not connect as its viewer holds its own identity's roles, whoever + // views it: here the publisher, an admin, who may use every role but adds only what + // names them, which is nothing. + assert_eq!( + connect_job("test-user", None, DatatableConnectAs::Job).await?["user"], + names[0].as_str(), + "the viewer's roles reached a job that does not connect as its viewer" + ); + // A job with no viewer, a schedule say, holds its owner's roles. + assert_eq!( + connect_job("test-user-2", None, DatatableConnectAs::Job).await?, + all + ); + assert_eq!( + connect(None, Some("test@windmill.dev")).await?["user"], + names[0].as_str() + ); + // Connecting as a viewer it does not have is refused rather than made as the job. + assert!(connect(None, None).await.is_err()); + // A named role is that role alone, whoever is viewing. + let named: &'static str = Box::leak(names[1].clone().into_boxed_str()); + assert_eq!( + connect(Some(named), Some("test2@windmill.dev")).await?["user"], + names[1].as_str() + ); + // A default of `admin` is the connection it always was: the viewer's roles are not added. + set_permissions("admin".to_string(), roles.clone()).await?; + let admin = connect(None, Some("test2@windmill.dev")).await?; + assert_eq!(admin, connect(Some("admin"), None).await?); + assert!(!admin["user"].as_str().unwrap().starts_with("wm_")); + + // With no default role the viewer's roles are all there is, the one open to everyone + // included, and a connection left with none is refused rather than resolved as anything. + set_permissions("$none".to_string(), roles.clone()).await?; + assert_eq!( + connect(None, Some("test3@windmill.dev")).await?, + two, + "no default changes nothing for a viewer the roles name" + ); + assert_eq!( + connect_job("test-user-3", None, DatatableConnectAs::Job).await?, + two + ); + assert_eq!( + connect_job("test-user", None, DatatableConnectAs::Job).await?["user"], + names[0].as_str(), + "the role open to everyone names the admin too" + ); + assert!(connect(None, None).await.is_err()); + // The same holds for a default the job's own identity is not a tenant of. + let mut closed = roles.clone(); + closed[ids[0].as_str()] = json!({"tenants": []}); + set_permissions(ids[0].clone(), closed).await?; + assert_eq!( + connect_job("test-user-3", None, DatatableConnectAs::Job).await?["user"], + names[1].as_str() + ); + assert_eq!( + connect(None, Some("test3@windmill.dev")).await?["user"], + names[1].as_str() + ); + set_permissions(ids[0].clone(), roles).await?; + + // A set login nobody connects with any more is dropped, a recent one is kept, and a + // dropped one is made again for the next viewer who needs it. + let exists = || async { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", + ) + .bind(&login) + .fetch_one(&db) + .await + }; + windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await?; + assert!(exists().await?, "a set login in use was swept"); + sqlx::query("UPDATE datatable_role_set SET last_used_at = now() - interval '31 days'") + .execute(&db) + .await?; + windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await?; + assert!(!exists().await?, "an unused set login was kept"); + assert_eq!( + connect(None, Some("test2@windmill.dev")).await?["user"], + login.as_str() + ); + + // A disabled role must not stay reachable through a login that is a member of it. + authed( + client().post(format!("{roles_url}/{}", ids[2])), + "SECRET_TOKEN", + ) + .json(&json!({ "enabled": false })) + .send() + .await? + .error_for_status()?; + let can_login: bool = + sqlx::query_scalar("SELECT rolcanlogin FROM pg_roles WHERE rolname = $1") + .bind(&login) + .fetch_one(&db) + .await?; + assert!(!can_login, "{login} still logs in with a disabled role"); + assert_eq!( + connect(None, Some("test2@windmill.dev")).await?["user"], + two["user"] + ); + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide, so they outlive this test's throwaway database. + for name in &names { + let logins: Vec = sqlx::query_scalar(MEMBER_LOGINS) + .bind(name) + .fetch_all(&db) + .await + .unwrap_or_default(); + for login in logins { + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{login}\"")) + .execute(&db) + .await; + } + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + } + outcome +} diff --git a/backend/windmill-api-integration-tests/tests/groups.rs b/backend/windmill-api-integration-tests/tests/groups.rs index 26e5caeea1..9928d1c060 100644 --- a/backend/windmill-api-integration-tests/tests/groups.rs +++ b/backend/windmill-api-integration-tests/tests/groups.rs @@ -497,6 +497,72 @@ async fn test_remove_user_from_instance_group_rederives_role( Ok(()) } +/// Clearing an instance group's role, or deleting a group whose role was cleared out from under +/// its members, must take the instance-level role back from them. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_instance_group_role_revoked_on_clear_and_delete( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let global_base = format!("http://localhost:{port}/api/groups"); + let email = "test2@windmill.dev"; + + let is_super_admin = || async { + sqlx::query_scalar::<_, bool>("SELECT super_admin FROM password WHERE email = $1") + .bind(email) + .fetch_one(&db) + .await + }; + + let resp = authed(client().post(format!("{global_base}/create"))) + .json(&json!({ "name": "admins_grp" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "create: {}", resp.text().await?); + let resp = authed(client().post(format!("{global_base}/update/admins_grp"))) + .json(&json!({ "new_summary": "", "instance_role": "superadmin" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "set role: {}", resp.text().await?); + let resp = authed(client().post(format!("{global_base}/adduser/admins_grp"))) + .json(&json!({ "email": email })) + .send() + .await?; + assert_eq!(resp.status(), 200, "adduser: {}", resp.text().await?); + assert!(is_super_admin().await?, "group should grant superadmin"); + + let resp = authed(client().post(format!("{global_base}/update/admins_grp"))) + .json(&json!({ "new_summary": "", "instance_role": null })) + .send() + .await?; + assert_eq!(resp.status(), 200, "update: {}", resp.text().await?); + assert!( + !is_super_admin().await?, + "clearing the group's role must revoke superadmin" + ); + + // A member left elevated while the group's stored role is already NULL. + sqlx::query( + "UPDATE password SET super_admin = true, role_source = 'instance_group' WHERE email = $1", + ) + .bind(email) + .execute(&db) + .await?; + + let resp = authed(client().delete(format!("{global_base}/delete/admins_grp"))) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete: {}", resp.text().await?); + assert!( + !is_super_admin().await?, + "deleting a role-cleared group must revoke superadmin" + ); + + Ok(()) +} + /// An overwrite import that moves a member from a dropped group to a retained one must keep /// their workspace data. /// diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index c3da2f3fb9..2c5d2ee78e 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -702,3 +702,51 @@ async fn test_list_filtered_job_uuids_resolved_excludes_queue( Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_worker_is_draining_follows_the_job_worker(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let job = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag, running, started_at, worker) + VALUES ($1, 'test-workspace', now(), 'deno', true, now(), 'wk-drain-test')", + ) + .bind(job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO worker_ping (worker, worker_instance, ip, worker_group, wm_version) + VALUES ('wk-drain-test', 'host', 'NO IP', 'default', 'v0')", + ) + .execute(&db) + .await?; + + let url = format!("http://localhost:{port}/api/w/test-workspace/jobs/worker_is_draining/{job}"); + let draining = || async { + let resp = authed(client().get(&url)).send().await?; + anyhow::Ok(resp.json::().await?) + }; + assert!(!draining().await?); + + windmill_common::worker::set_worker_draining_query("wk-drain-test", &db).await?; + assert!(draining().await?); + + // A parked job keeps its last worker on the queue row without running on it. + sqlx::query("UPDATE v2_job_queue SET started_at = NULL WHERE id = $1") + .bind(job) + .execute(&db) + .await?; + assert!(!draining().await?); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs b/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs new file mode 100644 index 0000000000..7033d093f2 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs @@ -0,0 +1,570 @@ +use serde_json::json; +use serial_test::serial; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_operator_rights_cache; +use windmill_test_utils::*; + +/// Every test here must be `#[serial]`: the rights cache is process-global and keyed by workspace id +/// alone, so parallel tests under this same id read each other's rights. +const WS: &str = "test-workspace"; + +fn client(token: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() +} + +async fn set_builder(db: &Pool, flows: bool, apps: bool) -> anyhow::Result<()> { + sqlx::query( + "UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2", + ) + .bind(format!( + r#"{{"builder_flows": {flows}, "builder_apps": {apps}}}"# + )) + .bind(WS) + .execute(db) + .await?; + // The rights are read through a process-global 60s cache keyed by workspace id. + invalidate_operator_rights_cache(WS); + Ok(()) +} + +async fn add_script(db: &Pool, hash: i64, path: &str, owner: &str) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, + summary, description, lock, extra_perms) + VALUES ($1, $2, $3, 'x', 'bun', 'script', $4, '{}', '', '', '', '{}')", + ) + .bind(WS) + .bind(hash) + .bind(path) + .bind(owner) + .execute(db) + .await?; + Ok(()) +} + +/// A full-code app is deployed multipart, the way the editor and the CLI use it. +fn raw_app_form(app: serde_json::Value) -> reqwest::multipart::Form { + reqwest::multipart::Form::new() + .part( + "app", + reqwest::multipart::Part::text(app.to_string()) + .mime_str("application/json") + .unwrap(), + ) + .part( + "js", + reqwest::multipart::Part::text("console.log(1)").file_name("app.js"), + ) +} + +fn raw_app(path: &str, mode: &str, runnable_path: &str) -> reqwest::multipart::Form { + raw_app_form(json!({ + "path": path, + "summary": "", + "value": {"files": {}, "runnables": {"r": { + "name": "r", "type": "runnableByPath", "runType": "script", + "path": runnable_path + }}}, + "policy": {"execution_mode": mode, "triggerables_v2": {}} + })) +} + +fn composition_flow(path: &str) -> serde_json::Value { + json!({ + "path": path, "summary": "", "description": "", "schema": {}, + "value": {"modules": [{ + "id": "a", + "value": {"type": "script", "path": "u/operator/some_script", "input_transforms": {}} + }]} + }) +} + +/// The app half of the builder boundary, plus the proof that the two rights are independent: a +/// gate reading "either" would authorize the kind its workspace never granted, which is the whole +/// point of splitting them. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_operator_builder_apps_boundary(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + let c = client("OPERATOR_TOKEN_1"); + + add_script(&db, 4241, "u/operator/some_script", "operator").await?; + // `permissions_test` gives the operator fixture no rights on `u/alice/**`. + add_script(&db, 4243, "u/alice/private", "alice").await?; + // A version shared with the operator, then archived, and a newer one at the same path that is + // not: the newer one is what runs. + add_script(&db, 4244, "u/alice/reused", "alice").await?; + sqlx::query( + "UPDATE script SET archived = true, extra_perms = '{\"u/operator\": false}', + created_at = now() - interval '1 day' WHERE hash = 4244", + ) + .execute(&db) + .await?; + add_script(&db, 4245, "u/alice/reused", "alice").await?; + + set_builder(&db, false, false).await?; + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app( + "u/operator/a0", + "publisher", + "u/operator/some_script", + )) + .send() + .await?; + assert!( + !resp.status().is_success(), + "an operator without the builder right must not create a full-code app" + ); + + set_builder(&db, true, true).await?; + + // `*_raw_source` compiles caller-supplied sources with a bundler job on a worker. It sits + // beside `create_app_raw`, which builders MAY use, so it is the gate most likely to be opened + // by mistake later. + let resp = c + .post(format!("{api}/apps/create_raw_source")) + .json(&json!({ + "path": "u/operator/a1", "summary": "", + "value": {"files": {"index.ts": "console.log(1)"}, "runnables": {}}, + "policy": {"execution_mode": "publisher"} + })) + .send() + .await?; + assert!( + !resp.status().is_success(), + "a builder must not compile app sources on a worker" + ); + + // Low-code apps carry inline scripts, so they stay shut too. + let resp = c + .post(format!("{api}/apps/create")) + .json(&json!({ + "path": "u/operator/a2", "summary": "", + "value": {"grid": []}, "policy": {"execution_mode": "publisher"} + })) + .send() + .await?; + assert!( + !resp.status().is_success(), + "a builder must not create a low-code app" + ); + + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app("u/operator/a3", "publisher", "u/alice/private")) + .send() + .await?; + assert!( + !resp.status().is_success(), + "a builder must not deploy an app referencing a runnable it cannot read" + ); + + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app("u/operator/a4", "viewer", "u/operator/some_script")) + .send() + .await?; + assert!( + !resp.status().is_success(), + "a builder must not deploy a viewer-mode app: the policy stops bounding what it can invoke" + ); + + // Storage strips a NUL, turning this key into `inlineScript`. + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app_form(json!({ + "path": "u/operator/a_nul", "summary": "", + "value": {"files": {}, "runnables": {"r": { + "type": "inline", + "inline\u{0}Script": {"content": "x", "language": "bun"} + }}}, + "policy": {"execution_mode": "publisher", "triggerables_v2": {}} + }))) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a NUL in a key must not hide an inline script: {}", + resp.text().await? + ); + + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app( + "u/operator/a5", + "publisher", + "u/operator/some_script", + )) + .send() + .await?; + assert!( + resp.status().is_success(), + "a builder must be able to deploy a full-code app over readable runnables: {}", + resp.text().await? + ); + let sandbox: Option = sqlx::query_scalar( + "SELECT (policy->>'sandbox')::boolean FROM app WHERE workspace_id = $1 AND path = $2", + ) + .bind(WS) + .bind("u/operator/a5") + .fetch_one(&db) + .await?; + assert_eq!( + sandbox, + Some(true), + "a builder-authored app must be stored sandboxed" + ); + + // `execute_component` looks up `:` with an unrestricted component string, so + // a key with a second colon still resolves at run time and must not slip past validation. + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app_form(json!({ + "path": "u/operator/a6", "summary": "", + "value": {"files": {}, "runnables": {}}, + "policy": {"execution_mode": "publisher", "triggerables_v2": { + "x:y:script/u/alice/private": {"static_inputs": {}, "one_of_inputs": {}} + }} + }))) + .send() + .await?; + assert!( + !resp.status().is_success(), + "a multi-colon triggerable key must not hide an unreadable runnable from validation" + ); + + // The two rights are independent. + set_builder(&db, true, false).await?; + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow("u/operator/flows_only")) + .send() + .await?; + assert!( + resp.status().is_success(), + "flows-only must still create a flow: {}", + resp.text().await? + ); + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app( + "u/operator/flows_only_app", + "publisher", + "u/operator/some_script", + )) + .send() + .await?; + assert!( + !resp.status().is_success(), + "flows-only must not deploy a full-code app" + ); + + set_builder(&db, false, true).await?; + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow("u/operator/apps_only")) + .send() + .await?; + assert!( + !resp.status().is_success(), + "apps-only must not create a flow" + ); + let resp = c + .post(format!("{api}/apps/create_raw")) + .multipart(raw_app( + "u/operator/apps_only_app", + "publisher", + "u/operator/some_script", + )) + .send() + .await?; + assert!( + resp.status().is_success(), + "apps-only must still deploy a full-code app: {}", + resp.text().await? + ); + + // The deploy panel saves access and frontend scopes through the settings-only endpoint. + for (body, expected) in [ + ( + json!({"policy": {"execution_mode": "anonymous", "triggerables_v2": {}}}), + 200, + ), + ( + json!({"policy": {"execution_mode": "publisher", "sandbox": false, "triggerables_v2": {}}}), + 403, + ), + ( + json!({"value": {"files": {}, "runnables": {}}, "policy": {"execution_mode": "publisher"}}), + 403, + ), + ] { + let resp = c + .post(format!("{api}/apps/update/u/operator/apps_only_app")) + .json(&body) + .send() + .await?; + let status = resp.status(); + assert_eq!( + status, + expected, + "settings update {body}: {}", + resp.text().await? + ); + } + let sandbox: Option = sqlx::query_scalar( + "SELECT (policy->>'sandbox')::boolean FROM app WHERE workspace_id = $1 AND path = $2", + ) + .bind(WS) + .bind("u/operator/apps_only_app") + .fetch_one(&db) + .await?; + assert_eq!( + sandbox, + Some(true), + "a settings update must not turn a builder app's sandbox off" + ); + + // The editor runs the runnables a draft names as whoever opens it, and its frontend code + // with their session unless the preview is sandboxed. + for (runnables, policy, expected) in [ + (json!({}), json!({"sandbox": true}), 200), + ( + json!({"a": {"type": "inline", "inlineScript": {"content": "x", "language": "bun"}}}), + json!({"sandbox": true}), + 403, + ), + (json!({}), json!({"sandbox": false}), 403), + ( + json!({}), + json!({"sandbox": true, "execution_mode": "viewer"}), + 403, + ), + (json!({}), json!(null), 403), + ( + json!({}), + json!({"sandbox": true, "on_behalf_of": "u/alice", "on_behalf_of_email": "alice@windmill.dev"}), + 403, + ), + ( + json!({}), + json!({"sandbox": true, "on_behalf_of": "u/operator"}), + 200, + ), + ( + json!({}), + json!({"sandbox": true, "triggerables": { + format!("{}script/u/operator/some_script", "a:".repeat(300)): {} + }}), + 400, + ), + ( + json!({"r": {"type": "runnableByPath", "runType": "script", "path": "u/operator/some_script"}}), + json!({"sandbox": true}), + 200, + ), + ( + json!({"r": {"type": "runnableByPath", "runType": "script", "path": "u/alice/private"}}), + json!({"sandbox": true}), + 403, + ), + ( + json!({"r": {"type": "runnableByPath", "runType": "script", "path": "u/alice/reused"}}), + json!({"sandbox": true}), + 403, + ), + ( + json!({"r": {"type": "path", "runType": "hubscript", "path": "hub/1/x/y"}}), + json!({"sandbox": true}), + 403, + ), + ( + json!({"r": {"type": "path", "runType": "script/u/alice", "path": "u/operator/some_script"}}), + json!({"sandbox": true}), + 400, + ), + ( + json!({"r": {"type": "path", "runType": "script", "path": ["u/alice/private"]}}), + json!({"sandbox": true}), + 400, + ), + ( + json!({"r": {"type": ["path"], "runType": "script", "path": "u/alice/private"}}), + json!({"sandbox": true}), + 400, + ), + ( + json!([{"type": "path", "runType": "script", "path": "u/alice/private"}]), + json!({"sandbox": true}), + 400, + ), + ( + json!({"r": {"type": "path", "runType": "script", "path": "u/operator/some_script", + "fields": {"x": {"type": "static", "value": {"type": "path", "path": "/srv/out"}}}}}), + json!({"sandbox": true}), + 200, + ), + ( + json!({}), + json!({"sandbox": true, "triggerables": {"x:rawscript/abc": {}}}), + 403, + ), + ( + json!({}), + json!({"sandbox": true, "triggerables": {"x:script/u/alice/private": {}}}), + 403, + ), + ( + json!({}), + json!({"sandbox": true, "triggerables_v2": { + "x:script/u/alice/private": {"static_inputs": {}, "one_of_inputs": {}}, + "y:rawscript/abc": {"static_inputs": {}, "one_of_inputs": {}} + }}), + 200, + ), + ] { + let resp = c + .post(format!("{api}/drafts/update/raw_app/u/operator/d1")) + .json(&json!({ + "value": {"files": {}, "runnables": runnables, "summary": "", "policy": policy}, + "force": true + })) + .send() + .await?; + let status = resp.status(); + assert_eq!( + status, + expected, + "raw app draft {runnables} {policy}: {}", + resp.text().await? + ); + } + + // The component preview runs a deployed runnable for a builder; inline code and hub + // scripts are unreviewed code. + for (preview, expected) in [ + (json!({"path": "script/u/operator/some_script"}), 200), + (json!({"path": "script/hub/1/x/y"}), 403), + ( + json!({"raw_code": {"content": "x", "language": "bun"}}), + 403, + ), + ] { + let mut body = json!({"component": "c", "args": {}, "force_viewer_static_fields": {}}); + body.as_object_mut() + .unwrap() + .extend(preview.as_object().unwrap().clone()); + let resp = c + .post(format!( + "{api}/apps_u/execute_component/u/operator/apps_only_app" + )) + .json(&body) + .send() + .await?; + let status = resp.status(); + assert_eq!( + status, + expected, + "component preview {preview}: {}", + resp.text().await? + ); + } + + // Delete and redeploy each serve both kinds; builder rights cover only full-code apps. + let resp = client("SECRET_TOKEN") + .post(format!("{api}/apps/create")) + .json(&json!({ + "path": "u/operator/low_code", "summary": "", + "value": {"grid": []}, "policy": {"execution_mode": "publisher"} + })) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + let resp = c + .post(format!("{api}/apps/update_raw/u/operator/low_code")) + .multipart(raw_app_form(json!({ + "policy": {"execution_mode": "publisher", "triggerables_v2": {}} + }))) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "policy-only update of a low-code app: {}", + resp.text().await? + ); + // Deployment history restores a full-code version with allow_kind_change set. + let resp = c + .post(format!("{api}/apps/update_raw/u/operator/apps_only_app")) + .multipart(raw_app_form(json!({ + "value": {"files": {}, "runnables": {}}, + "policy": {"execution_mode": "publisher", "triggerables_v2": {}}, + "allow_kind_change": true + }))) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "restore of a full-code version: {}", + resp.text().await? + ); + for (path, expected) in [("low_code", 403), ("apps_only_app", 200)] { + let resp = c + .delete(format!("{api}/apps/delete/u/operator/{path}")) + .send() + .await?; + let status = resp.status(); + assert_eq!(status, expected, "delete {path}: {}", resp.text().await?); + } + + Ok(()) +} + +/// The readability check must not hold its RLS transaction while it takes a second connection, +/// or a one-connection pool (`DATABASE_CONNECTIONS=1`) stalls every builder draft naming a script. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_operator_builder_app_check_fits_one_connection( + pool_opts: sqlx::postgres::PgPoolOptions, + connect_opts: sqlx::postgres::PgConnectOptions, +) -> anyhow::Result<()> { + initialize_tracing().await; + let db = pool_opts + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(5)) + .connect_with(connect_opts) + .await?; + let server = ApiServer::start(db.clone()).await?; + let api = format!("http://localhost:{}/api/w/{WS}", server.addr.port()); + add_script(&db, 4241, "u/operator/some_script", "operator").await?; + set_builder(&db, false, true).await?; + + let resp = client("OPERATOR_TOKEN_1") + .post(format!("{api}/drafts/update/raw_app/u/operator/d1")) + .json(&json!({ + "value": {"files": {}, "summary": "", "runnables": {"r": { + "name": "r", "type": "runnableByPath", "runType": "script", + "path": "u/operator/some_script" + }}, "policy": {"sandbox": true, "triggerables_v2": {}}}, + "force": true + })) + .send() + .await?; + let status = resp.status(); + assert_eq!(status, 200, "{}", resp.text().await?); + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 9c167ef70c..823c415818 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -751,6 +751,65 @@ async fn test_resource_cache_handles_job_context(db: Pool) -> anyhow:: Ok(()) } +/// Without an embeddings index the resource type search reads the table, so it has to apply the +/// same scope as the index: the workspace's own types and the global ones in `admins`, never +/// another workspace's. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_resource_type_search_without_embeddings(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')") + .execute(&db) + .await?; + for (workspace, name, description) in [ + ( + "test-workspace", + "c_acme_billing", + "Acme internal billing API", + ), + ( + "other-workspace", + "c_other_billing", + "Another workspace's billing API", + ), + ] { + sqlx::query( + "INSERT INTO resource_type (workspace_id, name, schema, description, created_by) VALUES ($1, $2, '{}'::jsonb, $3, 'test-user')", + ) + .bind(workspace) + .bind(name) + .bind(description) + .execute(&db) + .await?; + } + + let search = |text: &str| { + let url = format!( + "http://localhost:{port}/api/w/test-workspace/embeddings/query_resource_types?text={text}" + ); + async move { + let resp = authed(client().get(url)).send().await.unwrap(); + assert_eq!(resp.status(), 200); + resp.json::>() + .await + .unwrap() + .into_iter() + .map(|rt| rt["name"].as_str().unwrap().to_string()) + .collect::>() + } + }; + + let billing = search("billing").await; + assert_eq!(billing.first().map(String::as_str), Some("c_acme_billing")); + assert!(!billing.contains(&"c_other_billing".to_string())); + // `ai_skill` is seeded in `admins` by the migrations. + assert!(search("skill").await.contains(&"ai_skill".to_string())); + + Ok(()) +} + #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))] async fn test_mcp_tools(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 5e08949138..330e96c2fb 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -33,6 +33,7 @@ use windmill_common::{ flow_conversations::{ add_message_to_conversation_tx, message_attachments, MessageExtras, MessageType, }, + flows::Retry, get_latest_flow_version_info_for_path, jobs::{ check_tag_available_for_workspace_internal, format_result, script_path_to_payload, @@ -126,6 +127,71 @@ pub async fn drop_unclaimable_run_lineage( Ok(()) } +/// Applies the run's `retry` policy by handing `push` a one-step-flow request, which it +/// materializes as a native retryable `Script`. +pub fn with_run_retry( + run_query: &RunJobQuery, + payload: JobPayload, + args: &PushArgs<'_>, + tag: &Option, +) -> error::Result { + let Some(retry) = run_query.retry.as_deref() else { + return Ok(payload); + }; + // Retry attempts are always pushed visible to the owner, which would leak a private run. + if run_query.invisible_to_owner.unwrap_or(false) { + return Err(Error::BadRequest( + "retry cannot be combined with invisible_to_owner".to_string(), + )); + } + let retry: Retry = serde_json::from_str(retry) + .map_err(|e| Error::BadRequest(format!("invalid retry policy: {e}")))?; + let JobPayload::ScriptHash { + hash, + path, + cache_ttl, + cache_ignore_s3_path, + language, + priority, + apply_preprocessor, + debouncing_settings, + concurrency_settings, + .. + } = payload + else { + return Err(Error::BadRequest( + "retry is only supported for workspace scripts".to_string(), + )); + }; + // A failed attempt keeps its raw args and the retry re-push skips the preprocessor, so + // every retry would call `main` with un-preprocessed input. + if apply_preprocessor { + return Err(Error::BadRequest( + "retry is not supported for a script with a preprocessor unless skip_preprocessor is set" + .to_string(), + )); + } + Ok(JobPayload::SingleStepFlow { + path, + hash: Some(hash), + flow_version: None, + language: Some(language), + args: HashMap::from(args), + retry: Some(retry), + error_handler_path: None, + error_handler_args: None, + skip_handler: None, + cache_ttl, + cache_ignore_s3_path, + priority, + tag_override: tag.clone(), + trigger_path: None, + apply_preprocessor, + concurrency_settings, + debouncing_settings, + }) +} + /// 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. A restricted job token never gets the admin @@ -179,6 +245,43 @@ pub async fn unclaimable_run_lineage( Ok(referenced) } +/// `WM_END_USER_EMAIL` of a run started through a `/jobs/run*` route. `WM_EMAIL` names the +/// runnable's `on_behalf_of` identity when it has one, so this is the only place the caller's +/// address reaches the job. A job's own `WM_TOKEN` passes on the end user of that job, and a run +/// a trigger fired has no end user. +pub async fn run_end_user_email( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + trigger: Option<&TriggerMetadata>, +) -> error::Result> { + if trigger.is_some() { + return Ok(None); + } + match authed.job_id { + Some(job_id) => Ok(windmill_common::auth::get_job_perms(db, &job_id, w_id) + .await? + .and_then(|p| p.end_user_email)), + None => Ok(Some(authed.email.clone())), + } +} + +/// [`run_end_user_email`] of an inline run of the caller's own code, and whether its data table +/// connections are made as that end user: they are when the calling job's are, since the code is +/// that job's own. +pub async fn run_inline_preview_end_user( + db: &DB, + w_id: &str, + authed: &ApiAuthed, +) -> error::Result<(Option, bool)> { + match authed.job_id { + Some(job_id) => Ok(windmill_common::auth::get_job_perms(db, &job_id, w_id) + .await? + .map_or((None, false), |p| (p.end_user_email, p.viewer_data_access))), + None => Ok((Some(authed.email.clone()), false)), + } +} + #[cfg(feature = "enterprise")] pub async fn check_license_key_valid() -> error::Result<()> { use windmill_common::ee_oss::LICENSE_KEY_VALID; @@ -947,6 +1050,7 @@ pub async fn run_flow<'c>( ) }; + let end_user_email = run_end_user_email(db, w_id, authed, trigger.as_ref()).await?; let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, mut tx) = push( &db, @@ -982,7 +1086,7 @@ pub async fn run_flow<'c>( None, push_authed.as_ref(), false, - None, + end_user_email, authed.trigger_or_fallback(trigger), run_query.suspended_mode, scope_ceiling.as_deref(), @@ -1151,6 +1255,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( 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 job_payload = with_run_retry(&run_query, job_payload, &push_args, &tag)?; let return_tx = tx_o.is_some(); @@ -1177,6 +1282,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( ) }; + let end_user_email = run_end_user_email(&db, &w_id, &authed, trigger.as_ref()).await?; let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, @@ -1210,7 +1316,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( }, push_authed.as_ref(), false, - None, + end_user_email, authed.trigger_or_fallback(trigger), run_query.suspended_mode, scope_ceiling.as_deref(), diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index f37040bd11..f50cb61451 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -52,6 +52,8 @@ pub struct RunJobQuery { pub trigger_external_id: Option, pub service_name: Option, pub suspended_mode: Option, + /// A JSON-encoded `Retry` policy for a script run; see `with_run_retry`. + pub retry: Option, } impl RunJobQuery { diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 855f687431..b8ee9ded91 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -1492,7 +1492,8 @@ async fn set_default_error_handler( } else { windmill_api_settings::delete_global_setting(&db, key.as_str()).await?; } - windmill_api_settings::audit_setting_write(&db, &authed, &key, value.as_ref()).await?; + windmill_audit::instance_config::audit_setting_write(&db, &authed, &key, value.as_ref(), None) + .await?; if payload.override_existing { // The rewrite and its history rows go in one transaction: on separate diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index f622c4bb91..4f4c962759 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -4266,12 +4266,6 @@ async fn delete_script_by_path( check_scopes(&authed, || format!("scripts:write:{}", path))?; - if path == "u/admin/hub_sync" && w_id == "admins" { - return Err(Error::BadRequest( - "Cannot delete the global setup app".to_string(), - )); - } - if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), @@ -4445,12 +4439,6 @@ async fn delete_scripts_bulk( require_admin(authed.is_admin, &authed.username)?; - if request.paths.contains(&"u/admin/hub_sync".to_string()) && w_id == "admins" { - return Err(Error::BadRequest( - "Cannot delete the global setup app".to_string(), - )); - } - if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index c79b596617..f9ba1ff86f 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -29,7 +29,12 @@ mod log_cleanup; mod storage_usage; use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed}; -use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_audit::{ + audit_oss::audit_log, + instance_config::{audit_setting_write, InstanceConfigAuditor}, + ActionKind, +}; +use windmill_common::hub_resource_types::{self, SyncSource}; use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT; use windmill_common::DB; @@ -58,10 +63,10 @@ use windmill_common::{ error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ - 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, + ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, API_BASE_URL_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, @@ -127,6 +132,15 @@ async fn get_instance_ui( )) } +/// Whether email notifications can go out, for the workspace admins who configure email +/// handlers and cannot read `smtp_settings` itself (it holds the password). Asks the same +/// resolver the senders use, so SMTP set through `SMTP_*` env vars counts too. +async fn get_smtp_configured(Extension(db): Extension, _authed: ApiAuthed) -> JsonResult { + Ok(Json( + SMTP_ENABLED && windmill_common::server::is_smtp_configured(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -147,6 +161,7 @@ pub fn global_service() -> Router { ) .route("/instance_config/yaml", get(get_instance_config_yaml)) .route("/test_smtp", post(test_email)) + .route("/smtp_configured", get(get_smtp_configured)) .route("/test_license_key", post(test_license_key)) .route("/send_stats", post(send_stats)) .route("/get_stats", get(get_stats)) @@ -832,50 +847,7 @@ pub async fn set_global_setting( require_super_admin(&db, &authed).await?; let value = value.value.unwrap_or(serde_json::Value::Null); set_global_setting_internal(&db, key.clone(), value.clone()).await?; - audit_setting_write(&db, &authed, &key, Some(&value)).await -} - -/// A setting is recorded by name, with its value only when that is a boolean or a number. -/// Any other shape can carry a credential (a registry URL, a webhook, a header map), and no -/// list of secret-bearing settings stays complete as settings are added. -/// -/// AUTHORIZATION: records a write, checks nothing. The caller must have established that -/// `authed` may change instance settings before performing the write this records. -pub async fn audit_setting_write( - db: &DB, - authed: &ApiAuthed, - key: &str, - value: Option<&serde_json::Value>, -) -> error::Result<()> { - let deleted = match value { - None | Some(serde_json::Value::Null) => true, - Some(serde_json::Value::String(s)) => s.trim().is_empty(), - Some(_) => false, - }; - let recorded = match value { - Some(v @ (serde_json::Value::Bool(_) | serde_json::Value::Number(_))) => { - Some(v.to_string()) - } - _ => None, - }; - audit_log( - db, - authed, - if deleted { - "global_settings.delete" - } else { - "global_settings.update" - }, - if deleted { - ActionKind::Delete - } else { - ActionKind::Update - }, - "global", - Some(key), - recorded.as_deref().map(|v| [("value", v)].into()), - ) - .await + audit_setting_write(&db, &authed, &key, Some(&value), None).await } async fn audit_instance_action( @@ -1238,26 +1210,22 @@ async fn run_setting_pre_write_hook( } } } - GITHUB_APP_WEBHOOK_BASE_URL_SETTING => { - // A bad value here yields a webhook GitHub can never deliver to, and the + GITHUB_APP_WEBHOOK_BASE_URL_SETTING | API_BASE_URL_SETTING => { + // A bad webhook base yields a webhook GitHub can never deliver to, and the // failure only shows up much later as "falling back to polling" on a - // repository — so reject it at the boundary instead. + // repository; a bad API base puts an unreachable URL in front of every user. + // So reject both at the boundary instead. match value { // Clearing (delete row) is handled by the caller; allow it through. serde_json::Value::Null => {} serde_json::Value::String(s) if s.trim().is_empty() => {} serde_json::Value::String(s) => { - windmill_common::global_settings::validate_webhook_base_url(s).map_err( - |e| { - error::Error::BadRequest(format!( - "{GITHUB_APP_WEBHOOK_BASE_URL_SETTING}: {e}" - )) - }, - )?; + windmill_common::global_settings::validate_webhook_base_url(s) + .map_err(|e| error::Error::BadRequest(format!("{key}: {e}")))?; } _ => { return Err(error::Error::BadRequest(format!( - "{GITHUB_APP_WEBHOOK_BASE_URL_SETTING} must be a URL string" + "{key} must be a URL string" ))); } } @@ -1370,61 +1338,17 @@ async fn set_instance_config( run_setting_pre_write_hook(&db, key, value).await?; } } + let auditor = InstanceConfigAuditor { db: &db, author: &authed, source: None }; // The external-instance write removes its own key from the diff. - let external = settings_diff - .upserts - .get(EXTERNAL_INSTANCE_PG_SETTING) - .cloned() - .map(Some) - .or_else(|| { - settings_diff - .deletes - .iter() - .any(|k| k == EXTERNAL_INSTANCE_PG_SETTING) - .then_some(None) - }); windmill_common::external_instance_pg::write_external_instance_pg_from_diff( &db, &mut settings_diff, + &auditor, ) .await?; - if let Some(value) = external { - audit_setting_write(&db, &authed, EXTERNAL_INSTANCE_PG_SETTING, value.as_ref()).await?; - } - - // Applied and audited one setting at a time: the writes are not transactional, so a - // batch that fails midway must not leave the settings it did write unrecorded. - let instance_config::SettingsDiff { upserts, deletes, mut previous_values, unchanged_count } = - settings_diff; - let steps = upserts - .into_iter() - .map(|(k, v)| (k, Some(v))) - .chain(deletes.into_iter().map(|k| (k, None))) - .map(Some) - .chain(std::iter::once(None)); - for step in steps { - let mut diff = instance_config::SettingsDiff::default(); - match &step { - Some((key, value)) => { - if let Some(previous) = previous_values.remove(key) { - diff.previous_values.insert(key.clone(), previous); - } - match value { - Some(value) => { - diff.upserts.insert(key.clone(), value.clone()); - } - None => diff.deletes.push(key.clone()), - } - } - None => diff.unchanged_count = unchanged_count, - } - instance_config::apply_settings_diff(&db, &diff) - .await - .map_err(|e| error::Error::internal_err(e.to_string()))?; - if let Some((key, value)) = &step { - audit_setting_write(&db, &authed, key, value.as_ref()).await?; - } - } + instance_config::apply_settings_diff(&db, &settings_diff, &auditor) + .await + .map_err(|e| error::Error::internal_err(e.to_string()))?; if ai_config_changed { bump_instance_ai_config_revision(); @@ -1454,37 +1378,13 @@ async fn set_instance_config( .collect(); let configs_diff = instance_config::diff_worker_configs(¤t_wc, &desired_wc, ApplyMode::Merge); - let steps = configs_diff - .upserts - .into_iter() - .map(|(g, c)| (g, Some(c))) - .chain(configs_diff.deletes.into_iter().map(|g| (g, None))); - for (group, config) in steps { - let mut diff = instance_config::ConfigsDiff::default(); - let (operation, action_kind) = match config { - Some(config) => { - diff.upserts.insert(group.clone(), config); - ("worker_config.update", ActionKind::Update) - } - None => { - diff.deletes.push(group.clone()); - ("worker_config.delete", ActionKind::Delete) - } - }; - instance_config::apply_configs_diff(&db, &diff) - .await - .map_err(|e| error::Error::internal_err(e.to_string()))?; - audit_log( - &db, - &authed, - operation, - action_kind, - "global", - Some(&format!("worker__{group}")), - None, - ) - .await?; - } + instance_config::apply_configs_diff( + &db, + &configs_diff, + &InstanceConfigAuditor { db: &db, author: &authed, source: None }, + ) + .await + .map_err(|e| error::Error::internal_err(e.to_string()))?; } Ok(()) @@ -2576,95 +2476,6 @@ pub async fn get_jwks() -> JsonResult { Ok(Json(JwksResponse { keys: vec![] })) } -#[derive(serde::Deserialize, serde::Serialize)] -struct CachedResourceType { - #[allow(dead_code)] - id: i64, - name: String, - schema: Option, - #[allow(dead_code)] - app: String, - description: Option, - /// Doubly optional, and read through a wrapping deserializer: this struct also - /// decodes the on-disk cache, where an absent key means "written before the - /// column, leave the stored extension alone" and an explicit null means the hub - /// dropped it. Plain serde folds both into `None`. - #[serde( - default, - deserialize_with = "windmill_common::more_serde::double_option" - )] - format_extension: Option>, - /// Doubly optional like `format_extension`: no key leaves the stored name alone, an explicit - /// null (the hub naming nothing) clears it. - #[serde( - default, - deserialize_with = "windmill_common::more_serde::double_option" - )] - display_name: Option>, -} - -#[derive(serde::Deserialize)] -struct HubResourceTypeRaw { - id: i64, - name: String, - schema: Option, - app: String, - description: Option, - #[serde(default)] - format_extension: Option, - #[serde( - default, - deserialize_with = "windmill_common::more_serde::double_option" - )] - display_name: Option>, -} - -async fn fetch_resource_types_from_hub() -> error::Result> { - let response = HTTP_CLIENT - .get(format!( - "{}/resource_types/list", - windmill_common::DEFAULT_HUB_BASE_URL - )) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?; - - if !response.status().is_success() { - return Err(error::Error::InternalErr(format!( - "Hub returned status {}", - response.status() - ))); - } - - let raw_types: Vec = response - .json() - .await - .map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?; - - Ok(raw_types - .into_iter() - .filter_map(|rt| { - let schema = match rt.schema { - Some(s) => match serde_json::from_str(&s) { - Ok(v) => Some(v), - Err(_) => return None, - }, - None => None, - }; - Some(CachedResourceType { - id: rt.id, - name: rt.name, - schema, - app: rt.app, - description: rt.description, - format_extension: Some(rt.format_extension), - display_name: rt.display_name, - }) - }) - .collect()) -} - #[derive(serde::Deserialize)] struct SyncResourceTypesQuery { name: Option, @@ -2677,118 +2488,45 @@ async fn sync_cached_resource_types( ) -> error::Result { require_super_admin(&db, &authed).await?; - use windmill_common::worker::HUB_RT_CACHE_DIR; - let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR); + let outcome = hub_resource_types::sync(&db).await?; - // Manual sync is hub-first so it lands newly-published hub types on demand. The - // on-disk cache is only a fallback for when the hub is unreachable (airgapped - // installs / network error); refreshing it is left to the daily cache-rt cron and - // the startup sync in main.rs, which own the offline path. - let (resource_types, from_hub) = match fetch_resource_types_from_hub().await { - Ok(types) => { - tracing::info!("Fetched {} resource types live from the hub", types.len()); - (types, true) - } - Err(hub_err) => { - tracing::warn!( - "Live hub fetch failed ({hub_err}), falling back to on-disk cache at {cache_path}" - ); - match tokio::fs::read_to_string(&cache_path).await { - Ok(content) => { - let parsed = serde_json::from_str::>(&content) - .map_err(|e| { - error::Error::InternalErr(format!( - "Failed to parse cached resource types: {}", - e - )) - })?; - (parsed, false) - } - Err(_) => return Err(hub_err), - } - } - }; - - let mut synced_count = 0; - - for rt in &resource_types { - // A name too long for the column counts as absent, leaving the stored one alone: one bad - // entry must not fail the upsert and end the rest of the sync. - let display_name = match &rt.display_name { - Some(Some(name)) if name.chars().count() > 100 => None, - other => other.clone(), + if !outcome.changed.is_empty() { + let source = match outcome.source { + SyncSource::Hub => "hub", + SyncSource::ImageCache => "image_cache", }; - let exists: Option = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))", - &rt.name, - rt.schema.as_ref(), - rt.description.as_deref(), - rt.format_extension.clone().flatten(), - rt.format_extension.is_some(), - display_name.clone().flatten(), - display_name.is_some(), + let changed = outcome.changed.join(", "); + audit_log( + &db, + &authed, + "resource_types.sync", + ActionKind::Update, + "admins", + None, + Some([("source", source), ("changed", changed.as_str())].into()), ) - .fetch_one(&db) .await?; - - if exists.unwrap_or(false) { - continue; - } - - sqlx::query!( - // Whether the payload carried the key at all is what decides: present - // (even as null) is authoritative and may clear, absent means a cache - // written before the column and must leave the stored value alone. - "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at) - VALUES ('admins', $1, $2, $3, $4, $6, now()) - ON CONFLICT (workspace_id, name) DO UPDATE - SET schema = EXCLUDED.schema, description = EXCLUDED.description, - -- A fileset is a set of files, so it cannot also be one file. - -- Create and update reject the pair; this writer bypasses both, so - -- it declines the extension rather than persisting the forbidden - -- combination onto a same-named local fileset. - format_extension = CASE - WHEN resource_type.is_fileset THEN NULL - WHEN $5 THEN EXCLUDED.format_extension - ELSE resource_type.format_extension END, - display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END, - edited_at = now()", - &rt.name, - rt.schema.as_ref(), - rt.description.as_deref(), - rt.format_extension.clone().flatten(), - rt.format_extension.is_some(), - display_name.clone().flatten(), - display_name.is_some(), - ) - .execute(&db) - .await?; - - synced_count += 1; } // If a specific type was requested and is still absent after syncing, surface an // explicit not-found instead of a silent "Synced 0". Word it by source so the // cache-fallback path does not claim it checked the hub. if let Some(name) = name.as_deref() { - if !resource_types.iter().any(|rt| rt.name == name) { - let source = if from_hub { - "on the hub" - } else { - "in the cached resource types (hub unreachable)" + if !outcome.listed.iter().any(|listed| listed == name) { + let source = match outcome.source { + SyncSource::Hub => "on the hub", + SyncSource::ImageCache => "in the cached resource types (hub unavailable)", }; return Err(error::Error::NotFound(format!( - "resource type '{}' not found {}", - name, source + "resource type '{name}' not found {source}" ))); } } - Ok(format!( - "Synced {} resource types ({} unchanged)", - synced_count, - resource_types.len() - synced_count - )) + Ok(match outcome.source { + SyncSource::Hub => outcome.summary(), + SyncSource::ImageCache => format!("{}, the hub being unavailable", outcome.summary()), + }) } #[cfg(test)] diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index ac9defcd53..46bd6825e8 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -490,7 +490,7 @@ async fn list_user_usage( " SELECT usr.email, usage.executions FROM usr, LATERAL ( - SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions + SELECT COALESCE(SUM(COALESCE((c.extras->>'wac_last_segment_ms')::bigint, c.duration_ms) + 1000)/1000 , 0)::BIGINT executions FROM v2_job_completed c JOIN v2_job j USING (id) WHERE j.workspace_id = $1 AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index 0102f1b033..8ebee2a967 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -41,6 +41,7 @@ pub fn global_service() -> Router { .route("/queue_metrics", get(get_queue_metrics)) .route("/queue_metrics_series", get(get_queue_metrics_series)) .route("/queue_status", get(get_queue_status)) + .route("/runnable_stats", get(get_runnable_stats)) .route("/queue_counts", get(get_queue_counts)) .route("/queue_running_counts", get(get_queue_running_counts)) .route( @@ -86,6 +87,7 @@ struct WorkerPing { job_isolation: Option, #[serde(skip_serializing_if = "Option::is_none")] native_mode: Option, + draining: bool, } // #[derive(Serialize, Deserialize)] @@ -118,7 +120,7 @@ async fn list_worker_pings( WorkerPing, "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed, CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id, - custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode + custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining FROM worker_ping WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval) ORDER BY ping_at desc LIMIT $2 OFFSET $3", @@ -298,6 +300,81 @@ async fn get_queue_metrics( Ok(Json(queue_metrics)) } +#[derive(Deserialize)] +struct RunnableStatsQuery { + window_secs: Option, + worker_group: Option, + workspace: Option, + order_by: Option, + limit: Option, +} + +#[derive(Serialize)] +struct RunnableStats { + workspace_id: String, + runnable_path: String, + worker_group: String, + job_count: i64, + total_duration_ms: i64, + max_memory_peak: i32, + sum_memory_peak: i64, + memory_sample_count: i64, + total_cpu_ms: i64, +} + +async fn get_runnable_stats( + authed: ApiAuthed, + Extension(db): Extension, + Query(query): Query, +) -> JsonResult> { + require_devops_role(&db, &authed).await?; + + let window = query.window_secs.unwrap_or(24 * 3600).clamp( + 3600, + windmill_common::runnable_job_stats::RETENTION_DAYS * 24 * 3600, + ); + // Rows are hourly buckets keyed by their start, so the bucket the window begins in + // is included whole. + let from_hour = (chrono::Utc::now().timestamp() - window) / 3600 * 3600; + let order_by = match query.order_by.as_deref() { + Some("max_memory") => "max_memory", + Some("cpu") => "cpu", + _ => "total_duration", + }; + + let rows = sqlx::query_as!( + RunnableStats, + r#" + SELECT workspace_id, runnable_path, worker_group, + SUM(job_count)::bigint AS "job_count!", + SUM(total_duration_ms)::bigint AS "total_duration_ms!", + MAX(max_memory_peak) AS "max_memory_peak!", + SUM(sum_memory_peak)::bigint AS "sum_memory_peak!", + SUM(memory_sample_count)::bigint AS "memory_sample_count!", + SUM(total_cpu_ms)::bigint AS "total_cpu_ms!" + FROM runnable_job_stats + WHERE hour >= $1 + AND ($2::text IS NULL OR worker_group = $2) + AND ($3::text IS NULL OR workspace_id = $3) + GROUP BY workspace_id, runnable_path, worker_group + ORDER BY CASE $4::text + WHEN 'max_memory' THEN MAX(max_memory_peak)::numeric + WHEN 'cpu' THEN SUM(total_cpu_ms) + ELSE SUM(total_duration_ms) + END DESC, workspace_id, runnable_path, worker_group + LIMIT $5 + "#, + from_hour, + query.worker_group, + query.workspace, + order_by, + query.limit.unwrap_or(100).clamp(1, 1000), + ) + .fetch_all(&db) + .await?; + Ok(Json(rows)) +} + #[derive(Deserialize)] struct QueueMetricsSeriesQuery { window_secs: Option, diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs index ebab6214ce..efaa712481 100644 --- a/backend/windmill-api-workspaces/src/datatable_acl.rs +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -683,6 +683,9 @@ pub(crate) struct CatalogFacts { pub(crate) former_owner: Option, /// For a revoke: each grant it takes back. pub(crate) revoked_grants: Vec, + /// For a grant on what a schema holds: the role cannot reach the schema yet, so the grant + /// would give it nothing it can use. + pub(crate) lacks_schema_usage: bool, } /// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the @@ -1526,7 +1529,24 @@ async fn build_plan( ))); } } - let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants }; + let lacks_schema_usage = match (&change, target) { + (AclChange::Grant { scope, .. }, AclTarget::Schema { schema }) + if !matches!(scope, GrantScope::Target) => + { + !has_schema_usage(client, schema, &pg_role).await? + } + (AclChange::Grant { .. }, AclTarget::Table { schema, .. }) => { + !has_schema_usage(client, schema, &pg_role).await? + } + _ => false, + }; + let facts = CatalogFacts { + other_pg_roles, + existing_objects, + former_owner, + revoked_grants, + lacks_schema_usage, + }; let mut plan = crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?; if matches!(change, AclChange::SetOwner { .. }) { @@ -1540,6 +1560,27 @@ async fn build_plan( Ok(plan) } +/// Whether `pg_role` may look into `schema`, directly or through a role it inherits from. +async fn has_schema_usage( + client: &tokio_postgres::Client, + schema: &str, + pg_role: &str, +) -> Result { + Ok(client + .query_one( + "SELECT has_schema_privilege($1::name, $2::text, 'USAGE')", + &[&pg_role, &schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the role's privileges on schema {schema}: {}", + pg_error_message(&e) + )) + })? + .get(0)) +} + /// Postgres only hands an object to a role that could have created it: a table to one with /// `CREATE` on its schema, a schema to one with `CREATE` on the database. async fn missing_owner_privilege( diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index e63e69d4d5..21e460359e 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -880,7 +880,11 @@ struct DatatableMigrationsStatusResult { /// Whether the SQL-migrations feature is enabled for a data table. Honors the /// explicit `migrations_enabled` flag; when unset (data tables predating the /// feature) it is considered enabled only if migrations already exist. -async fn datatable_migrations_enabled(db: &DB, w_id: &str, datatable_name: &str) -> Result { +pub(crate) async fn datatable_migrations_enabled( + db: &DB, + w_id: &str, + datatable_name: &str, +) -> Result { let flag: Option = sqlx::query_scalar!( "SELECT (ws.datatable->'datatables'->$2->>'migrations_enabled')::boolean \ FROM workspace_settings ws WHERE ws.workspace_id = $1", diff --git a/backend/windmill-api-workspaces/src/remote_deploy.rs b/backend/windmill-api-workspaces/src/remote_deploy.rs index 3e701cbf14..a0f922a607 100644 --- a/backend/windmill-api-workspaces/src/remote_deploy.rs +++ b/backend/windmill-api-workspaces/src/remote_deploy.rs @@ -25,11 +25,11 @@ use axum::{ }; use chrono::{DateTime, Utc}; use lazy_static::lazy_static; -use magic_crypt::MagicCrypt256; use regex::Regex; use serde::{Deserialize, Serialize}; use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened}; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::variables::WorkspaceCrypt; use windmill_common::{ auth::hash_token, error::{error_source_chain, Error, Result}, @@ -676,8 +676,8 @@ async fn proxy( pub(crate) async fn reencrypt_tokens( conn: &mut sqlx::PgConnection, w_id: &str, - old: &MagicCrypt256, - new: &MagicCrypt256, + old: &WorkspaceCrypt, + new: &WorkspaceCrypt, ) -> Result<()> { let rows = sqlx::query!( "SELECT email, token FROM remote_deploy_token diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index eef8ccaf43..f34ad74977 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2325,6 +2325,9 @@ struct DataTableTables { /// What the role the listing connected as may create. can_create_schema: bool, creatable_schemas: Vec, + /// Whether its schema is kept as versioned migrations, which a change made with plain SQL + /// would bypass. + migrations_enabled: bool, } #[derive(Deserialize)] @@ -2570,8 +2573,17 @@ async fn list_one_datatable_tables( default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(), can_create_schema: false, creatable_schemas: vec![], + migrations_enabled: false, }; let result: Result<()> = async { + // Read from the workspace settings alone, so it is known even when the database is not + // reachable. + entry.migrations_enabled = crate::datatable_migrations::datatable_migrations_enabled( + db, + w_id, + &entry.datatable_name, + ) + .await?; let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?; entry.instance = governing.role_cluster().is_some(); let usable = @@ -6317,8 +6329,8 @@ async fn set_encryption_key( async fn reencrypt_git_sync_secrets( conn: &mut sqlx::PgConnection, w_id: &str, - old: &magic_crypt::MagicCrypt256, - new: &magic_crypt::MagicCrypt256, + old: &windmill_common::variables::WorkspaceCrypt, + new: &windmill_common::variables::WorkspaceCrypt, ) -> Result<()> { let Some((mut credentials, mut git_sync)) = sqlx::query_as::<_, (serde_json::Value, Option)>( @@ -11569,10 +11581,13 @@ struct ChangeOperatorSettings { /// withdraw or grant the right on every push. /// /// `builder_flows` lets every operator of this workspace compose flows out of already-deployed - /// runnables, and makes each of them consume a full author seat instead of half of one. + /// runnables, `builder_apps` full-code apps. Either makes each operator consume a full author + /// seat instead of half of one. #[serde(default, skip_serializing_if = "Option::is_none")] builder_flows: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + builder_apps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] manage_schedules: Option, #[serde(default, skip_serializing_if = "Option::is_none")] manage_triggers: Option, @@ -11587,9 +11602,10 @@ async fn update_operator_settings( require_admin(authed.is_admin, &authed.username)?; // Every operator of the workspace turns into a full seat, which an offline license may not - // cover. It is a no-op delta when the right is already on. + // cover. Either right costs the same seat, so this prices "any", and it is a no-op delta when + // one of them is already on. #[cfg(feature = "enterprise")] - if settings.builder_flows == Some(true) { + if settings.builder_flows == Some(true) || settings.builder_apps == Some(true) { if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await? { diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 50833bfe06..2f0d4deb13 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -1299,6 +1299,13 @@ pub(crate) async fn delete_workspace( .execute(&mut *tx) .await?; + sqlx::query!( + "DELETE FROM runnable_job_stats WHERE workspace_id = $1", + &w_id + ) + .execute(&mut *tx) + .await?; + // If this workspace is itself a dev workspace, deleting it dissolves the pairing, so also drop // the parent prod's reserved dev_workspace_lock (mirrors detach_dev_workspace) — otherwise prod // stays locked against direct deploy/forking with no dev workspace left to make changes in. diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index ad7f12453c..b3aea64f72 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -20,7 +20,7 @@ benchmark = [] embedding = ["windmill-api-embeddings/embedding"] parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-indexer?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "windmill-api-npm-proxy/parquet", "dep:aws-sigv4", "dep:aws-sdk-config", "dep:quick-xml"] prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus", "windmill-api-scripts/prometheus"] -openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"] +openidconnect = ["dep:openidconnect", "dep:rsa", "dep:p256", "dep:tokio-rustls", "windmill-common/openidconnect", "windmill-store/openidconnect"] # The service log search handler reads the columnar store, so tantivy alone is # not enough for this crate to build on its own. tantivy = ["dep:windmill-indexer", "parquet"] @@ -141,6 +141,8 @@ samael = { workspace = true, optional = true } libxml = { workspace = true, optional = true } async-recursion.workspace = true rsa = { workspace = true, optional = true} +p256 = { workspace = true, optional = true } +tokio-rustls = { workspace = true, optional = true } uuid.workspace = true datafusion = { workspace = true, optional = true} openidconnect = { workspace = true, optional = true} @@ -189,4 +191,7 @@ flate2.workspace = true strum = { workspace = true, optional = true } dashmap.workspace = true +[dev-dependencies] +rcgen.workspace = true + [build-dependencies] diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 8dae1bafcd..4016222d30 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -34993,7 +34993,7 @@ }, "exponential": { "type": "object", - "description": "Retry with exponential backoff (delay doubles each time)", + "description": "Retry with exponential backoff: the n-th retry waits multiplier × seconds^n", "properties": { "attempts": { "type": "integer", @@ -35006,7 +35006,7 @@ "seconds": { "type": "integer", "minimum": 1, - "description": "Initial delay in seconds" + "description": "Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too" }, "random_factor": { "type": "integer", @@ -47930,7 +47930,7 @@ }, "exponential": { "type": "object", - "description": "Retry with exponential backoff (delay doubles each time)", + "description": "Retry with exponential backoff: the n-th retry waits multiplier × seconds^n", "properties": { "attempts": { "type": "integer", @@ -47943,7 +47943,7 @@ "seconds": { "type": "integer", "minimum": 1, - "description": "Initial delay in seconds" + "description": "Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too" }, "random_factor": { "type": "integer", diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 439e4d11a0..d541144231 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -11725,9 +11725,7 @@ paths: description: Seconds to wait between retries exponential: type: object - description: >- - Retry with exponential backoff (delay - doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier × seconds^n' properties: attempts: type: integer @@ -11738,7 +11736,7 @@ paths: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -23272,7 +23270,7 @@ paths: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier × seconds^n' properties: attempts: type: integer @@ -23283,7 +23281,7 @@ paths: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2839d8b3be..b214f78dd4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.826.0 + version: 1.833.0 title: Windmill API contact: @@ -1765,6 +1765,12 @@ paths: type: string cluster: $ref: "#/components/schemas/DatatableRoleCluster" + take_over: + type: boolean + description: > + manage a Postgres role of that name that already exists on the cluster, + replacing its password, instead of refusing it. A role with cluster-wide + attributes or memberships is refused either way responses: "200": description: the created role @@ -1772,6 +1778,8 @@ paths: application/json: schema: $ref: "#/components/schemas/InstanceDatatableRole" + "409": + description: a Postgres role of that name already exists on the cluster and take_over was not set /settings/datatable_roles/{id}: post: @@ -1911,7 +1919,7 @@ paths: /settings/instance_ui: get: - summary: get the instance-wide announcement banner and accent color + summary: get the instance-wide announcement banner, accent color and API base url description: | Readable by any authenticated user; every page load reads it. Each value is the raw stored setting, or null when unset. @@ -1920,7 +1928,7 @@ paths: - setting responses: "200": - description: the instance_banner and accent_color settings + description: the instance_banner, accent_color and api_base_url settings content: application/json: schema: @@ -1928,6 +1936,7 @@ paths: properties: instance_banner: {} accent_color: {} + api_base_url: {} /settings_u/ruff_config: get: @@ -1962,6 +1971,24 @@ paths: application/json: schema: {} + /settings/smtp_configured: + get: + summary: whether instance SMTP is configured + description: | + Readable by any authenticated user. True when this build can send email + and SMTP is set, either in the smtp_settings instance setting or through + the SMTP_* environment variables. + operationId: getSmtpConfigured + tags: + - setting + responses: + "200": + description: whether email notifications can be sent + content: + application/json: + schema: + type: boolean + /settings/test_smtp: post: summary: test smtp @@ -5559,6 +5586,9 @@ paths: type: boolean default_role: type: string + description: >- + The id of one of `roles`, `admin` (the default), or `$none` for no default + role: a connection that names no role then holds its viewer's roles only. roles: type: array items: @@ -7427,6 +7457,72 @@ paths: items: $ref: "#/components/schemas/TruncatedToken" + /oidc/signing_status: + get: + summary: what signs OIDC tokens and the keys the JWKS serves (ee only, superadmin) + operationId: getOidcSigningStatus + tags: + - oidc + responses: + "200": + description: OIDC signing status + content: + application/json: + schema: + type: object + required: + - mode + - keys + properties: + mode: + type: string + enum: [database, file, external] + keys: + type: array + items: + type: object + required: + - alg + - state + - source + properties: + kid: + type: string + alg: + type: string + state: + type: string + enum: [pending, active, retired, published] + source: + type: string + enum: [signer, database, file, publish_dir] + signer: + type: object + description: set when an external signer is configured + required: + - breaker + - max_rps + - current_rps + properties: + last_refresh_success: + type: string + format: date-time + last_refresh_error: + type: string + breaker: + type: string + enum: [closed, open, half_open] + max_rps: + type: number + current_rps: + type: number + keys_from_store: + type: boolean + description: the keys served are those stored by the last accepted listing, the signer has not answered this server yet + tls_files_error: + type: string + description: why the mTLS files on disk are not the ones in use + /w/{workspace}/oidc/token/{audience}: post: summary: get OIDC token (ee only) @@ -10916,6 +11012,37 @@ paths: description: "`saved` only, upsert or delete: where the write landed. Differs from the URL path when the item had moved away from it; the editor follows it there. Absent when a delete found nothing to remove and the caller cannot read the path it moved to." required: [status, current_timestamp] + /w/{workspace}/drafts/delete_for_user/{kind}/{path}: + post: + summary: delete another user's draft, or every draft at a path (admin only) + description: Delete the draft a given user holds at a path, or, without `username`, every draft at that path (the legacy ownerless one and the caller's own included). Workspace admins / superadmins only, and only for script, flow, app and raw_app drafts. Unlike a discard of one's own draft through `drafts/update`, this returns 404 when nothing was deleted. + operationId: deleteDraftForUser + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + $ref: "#/components/schemas/UserDraftItemKind" + - $ref: "#/components/parameters/ScriptPath" + - name: username + in: query + required: false + description: Workspace username of the draft owner. Omit to delete every user's draft at the path. + schema: + type: string + responses: + "200": + description: draft(s) deleted + content: + text/plain: + schema: + type: string + "404": + description: no matching draft at that path + /w/{workspace}/drafts/move/{kind}/{path}: post: summary: move the current user's draft-only item to another path @@ -12051,6 +12178,7 @@ paths: - $ref: "#/components/parameters/WorkerTag" - $ref: "#/components/parameters/CacheTtl" - $ref: "#/components/parameters/NewJobId" + - $ref: "#/components/parameters/RunRetry" - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -16055,6 +16183,7 @@ paths: - $ref: "#/components/parameters/WorkerTag" - $ref: "#/components/parameters/CacheTtl" - $ref: "#/components/parameters/NewJobId" + - $ref: "#/components/parameters/RunRetry" - $ref: "#/components/parameters/IncludeHeader" - name: invisible_to_owner description: make the run invisible to the the script owner (default false) @@ -17359,6 +17488,27 @@ paths: schema: type: string + /w/{workspace}/jobs/worker_is_draining/{id}: + get: + summary: whether the worker running this job is draining + description: | + True once the worker running the job has received its shutdown signal. The worker + still waits for the job to finish, so a long-running job can poll this to exit early. + False when the job is not currently running on a worker. + operationId: workerIsDraining + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: whether the worker running this job is draining + content: + application/json: + schema: + type: boolean + /w/{workspace}/jobs_u/get_logs/{id}: get: summary: get job logs @@ -23539,6 +23689,54 @@ paths: - id - values + /workers/runnable_stats: + get: + summary: get resource usage per runnable and worker group over a time window (require devops role) + operationId: getRunnableStats + tags: + - worker + parameters: + - name: window_secs + in: query + required: false + description: how far back to read, in seconds (defaults to one day, capped at the 30-day retention) + schema: + type: integer + - name: worker_group + in: query + required: false + description: only the jobs run by this worker group + schema: + type: string + - name: workspace + in: query + required: false + description: only the jobs of this workspace + schema: + type: string + - name: order_by + in: query + required: false + description: "what the rows are ranked by, descending (default: total_duration)" + schema: + type: string + enum: [total_duration, max_memory, cpu] + - name: limit + in: query + required: false + description: number of rows to return (default 100, at most 1000) + schema: + type: integer + responses: + "200": + description: resource usage per runnable and worker group, heaviest first + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RunnableStats" + /workers/queue_metrics_series: get: summary: get the queue metrics of a time window, as a bounded line per tag @@ -25246,9 +25444,12 @@ paths: in: query schema: type: string + - $ref: "#/components/parameters/TablePreviewS3ResourcePath" + - $ref: "#/components/parameters/TablePreviewFilters" responses: "200": - description: Parquet Preview + description: > + Parquet Preview: `columns`, `column_types` (Arrow type names) and `rows`. content: application/json: {} @@ -25273,6 +25474,13 @@ paths: in: query schema: type: string + - $ref: "#/components/parameters/TablePreviewS3ResourcePath" + - name: csv_separator + in: query + schema: + type: string + - $ref: "#/components/parameters/TablePreviewCsvHasHeader" + - $ref: "#/components/parameters/TablePreviewFilters" responses: "200": description: Table count @@ -25321,13 +25529,20 @@ paths: in: query schema: type: string + - $ref: "#/components/parameters/TablePreviewS3ResourcePath" - name: csv_separator in: query schema: type: string + - $ref: "#/components/parameters/TablePreviewCsvHasHeader" + - $ref: "#/components/parameters/TablePreviewFilters" responses: "200": - description: Csv Preview + description: > + Csv Preview: `columns`, `column_types` (Arrow type names), `rows`, and the + `csv_separator` the file was read with, guessed from its first bytes when + none is given. + A `.jsonl` or `.ndjson` file is read as newline-delimited JSON. content: application/json: {} @@ -27687,6 +27902,28 @@ components: required: true schema: type: string + TablePreviewS3ResourcePath: + name: s3_resource_path + description: Read the file from this object storage resource instead of the workspace storage. + in: query + schema: + type: string + TablePreviewCsvHasHeader: + name: csv_has_header + description: Whether the first CSV line names the columns. Defaults to true. + in: query + schema: + type: boolean + TablePreviewFilters: + name: filters + description: > + JSON object, column name to filter. A number column takes an optional comparison + operator and a number (`>=10`), a boolean column a boolean, and any other column a + case-insensitive substring, or `=value` for an exact match. A `search_term` without + a `search_col` matches any column, case-insensitively. + in: query + schema: + type: string HubPublishFolder: name: folder in: query @@ -27869,6 +28106,12 @@ components: in: query schema: type: string + RunRetry: + name: retry + description: JSON-encoded retry policy (same shape as a schedule's retry) re-running the job when it fails. The returned id is the first attempt's; each retry is a job of its own, recorded as a child of the first attempt. Not supported for hub scripts, with invisible_to_owner, or for a script whose preprocessor would run. + in: query + schema: + type: string NewJobId: name: job_id description: @@ -31750,6 +31993,7 @@ components: - "resource_types.create" - "resource_types.update" - "resource_types.delete" + - "resource_types.sync" - "schedule.create" - "schedule.setenabled" - "schedule.edit" @@ -34581,6 +34825,11 @@ components: type: boolean cluster: $ref: "#/components/schemas/DatatableRoleCluster" + in_use: + type: boolean + description: >- + Whether any data table, in any workspace, lets someone connect as this role. Only set + in a data table's available_roles. DatatableRoleTenants: type: object @@ -34610,6 +34859,7 @@ components: type: boolean default_role: type: string + description: A role id, `admin`, or `$none` when the data table has no default role. roles: type: array items: @@ -35888,6 +36138,45 @@ components: `u/`, `g/`, or an email that exists in this workspace. + RunnableStats: + type: object + description: > + Resource usage of the jobs of one runnable on one worker group. Memory and CPU are + only known for jobs run as a subprocess; memory is the peak of the job's main process. + properties: + workspace_id: + type: string + runnable_path: + type: string + description: path of the script or flow step; `` gathers previews and other runs without a stable path + worker_group: + type: string + job_count: + type: integer + total_duration_ms: + type: integer + max_memory_peak: + type: integer + description: highest peak memory of a job, in kB + sum_memory_peak: + type: integer + description: sum of the peak memory of the jobs that have one, in kB + memory_sample_count: + type: integer + description: number of jobs that have a peak memory reading + total_cpu_ms: + type: integer + required: + - workspace_id + - runnable_path + - worker_group + - job_count + - total_duration_ms + - max_memory_peak + - sum_memory_peak + - memory_sample_count + - total_cpu_ms + WorkerPing: type: object properties: @@ -35936,6 +36225,9 @@ components: type: string native_mode: type: boolean + draining: + type: boolean + description: the worker received its shutdown signal and exits once its current job is done required: - worker - worker_instance @@ -37118,6 +37410,7 @@ components: default_role, can_create_schema, creatable_schemas, + migrations_enabled, ] properties: datatable_name: @@ -37151,6 +37444,9 @@ components: description: the schemas the role the listing connected as may create in items: type: string + migrations_enabled: + type: boolean + description: whether its schema is kept as versioned migrations, which a change made with plain SQL would bypass DataTableTableSchema: type: object @@ -37898,6 +38194,9 @@ components: builder_flows: type: boolean description: Whether operators can compose flows out of existing runnables (consumes a full seat). Omitting the field leaves the stored value unchanged. + builder_apps: + type: boolean + description: Whether operators can compose full-code apps out of existing runnables (consumes a full seat). Omitting the field leaves the stored value unchanged. manage_schedules: type: boolean description: Whether operators can create, edit and delete schedules. Granted unless withdrawn; omitting the field leaves the stored value unchanged. diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index c401568868..cf7447ceba 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -40,6 +40,7 @@ use windmill_ai::proxy::{ fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, ProxyRequest, }; +use windmill_ai::retry::send_with_retries; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; use windmill_common::error::{to_anyhow, Error, Result}; @@ -1188,15 +1189,11 @@ async fn global_proxy( } }; - let response = request.send().await.map_err(to_anyhow)?; + let response = send_with_retries(request).await; audit_global_ai_request(&db, &authed).await?; - if response.error_for_status_ref().is_err() { - let err_msg = response.text().await.unwrap_or("".to_string()); - return Err(Error::AIError(err_msg)); - } - + let response = response?; let status_code = response.status(); let headers = response.headers().clone(); let stream = response.bytes_stream(); @@ -1576,7 +1573,7 @@ async fn proxy( } }; - let response = request.send().await.map_err(to_anyhow)?; + let response = send_with_retries(request).await; let mut tx = db.begin().await?; @@ -1592,11 +1589,7 @@ async fn proxy( .await?; tx.commit().await?; - if response.error_for_status_ref().is_err() { - let err_msg = response.text().await.unwrap_or("".to_string()); - return Err(Error::AIError(err_msg)); - } - + let response = response?; let status_code = response.status(); let headers = response.headers().clone(); let is_sse = is_sse_response(&headers); diff --git a/backend/windmill-api/src/ai_sessions.rs b/backend/windmill-api/src/ai_sessions.rs index 65a8137c4e..097e5c4862 100644 --- a/backend/windmill-api/src/ai_sessions.rs +++ b/backend/windmill-api/src/ai_sessions.rs @@ -24,7 +24,6 @@ use axum::{ Extension, Json, Router, }; use futures::{StreamExt, TryStreamExt}; -use magic_crypt::{MagicCrypt256, MagicCryptTrait}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::sync::Arc; @@ -33,9 +32,11 @@ use windmill_api_workspaces::ai_session_backups::{ fallback_store, generation_prefix, primary_store, storage_id, MAX_OBJECT_BYTES, }; use windmill_api_workspaces::workspaces::sessions_retention_days; +use windmill_common::db::{process_uptime, PREVIOUS_RELEASE_ROLLOUT_UPTIME}; use windmill_common::error::{Error, JsonResult, Result}; use windmill_common::utils::calculate_hash; -use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key}; +use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key, WorkspaceCrypt}; +use windmill_common::workspace_crypt::{CipherFormat, MAX_BYTES_OVERHEAD}; use windmill_object_store::object_store_reexports::{ ObjectStore, ObjectStoreError, Path as ObjectPath, PutPayload, }; @@ -47,9 +48,9 @@ const PULL_BODY_LIMIT: usize = 64 * 1024; /// A pull answer larger than this hands the remaining ids back as `deferred`. const PULL_RESPONSE_BUDGET: usize = 32 * 1024 * 1024; const MAX_HEAD_BYTES: usize = 1024 * 1024; -/// What the cipher adds to a plaintext at most (a block of padding): an object stored at a -/// cap is that much larger than the cap when read back. -const CIPHER_PADDING: usize = 16; +/// What the cipher adds to a plaintext at most: an object stored at a cap is that much larger +/// than the cap when read back. +const CIPHER_PADDING: usize = MAX_BYTES_OVERHEAD; /// The browser bounds an image to a 1568 px edge and re-encodes past 700 KB; this is /// well above what that produces. const MAX_IMAGE_BYTES: usize = 4 * 1024 * 1024; @@ -120,6 +121,15 @@ pub fn workspaced_service() -> Router { ) } +fn backup_format(current: CipherFormat, uptime: std::time::Duration) -> CipherFormat { + // A previous-release server reads only CBC here: GCM waits for it to be rolled out. + if current == CipherFormat::V2 && uptime >= PREVIOUS_RELEASE_ROLLOUT_UPTIME { + CipherFormat::V2 + } else { + CipherFormat::LegacyCbc + } +} + /// What reading an object yields. `Gone`: not there (deleted since the listing, or never /// pushed). `Grown`: larger than expected, so replaced since the listing (or planted), and /// left unread; a pull answers with a page ending before it rather than without it. @@ -135,7 +145,7 @@ enum Read { /// The user's prefix in the workspace storage, plus what reads and writes it. struct Backend { store: Arc, - mc: MagicCrypt256, + mc: WorkspaceCrypt, prefix: String, /// Name the storage and the generation the objects are under, for the browser's sync /// state: a row recorded against another storage or generation is stale, a removal is @@ -214,7 +224,8 @@ impl Backend { } fn seal(&self, plaintext: &[u8]) -> Vec { - self.mc.encrypt_bytes_to_bytes(plaintext) + let format = backup_format(CipherFormat::current(), process_uptime()); + self.mc.encrypt_bytes_in(plaintext, format) } /// Bytes written. @@ -255,7 +266,7 @@ impl Backend { // apart beyond the cipher's padding check, which a wrong key passes now and then. match self .mc - .decrypt_bytes_to_bytes(&bytes) + .decrypt_bytes(&bytes) .ok() .and_then(|plaintext| String::from_utf8(plaintext).ok()) { @@ -1632,3 +1643,32 @@ async fn push( results, })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backups_are_written_in_gcm_only_once_the_previous_release_is_rolled_out() { + let just_short = PREVIOUS_RELEASE_ROLLOUT_UPTIME - std::time::Duration::from_secs(1); + for (current, uptime, expected) in [ + ( + CipherFormat::V2, + PREVIOUS_RELEASE_ROLLOUT_UPTIME, + CipherFormat::V2, + ), + (CipherFormat::V2, just_short, CipherFormat::LegacyCbc), + ( + CipherFormat::LegacyCbc, + PREVIOUS_RELEASE_ROLLOUT_UPTIME * 4, + CipherFormat::LegacyCbc, + ), + ] { + assert_eq!( + backup_format(current, uptime), + expected, + "{current:?} {uptime:?}" + ); + } + } +} diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index f276e6d143..4b67f865f9 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -56,7 +56,10 @@ use std::str; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::{ - apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE}, + apps::{ + app_value_has_inline_script, app_value_runnable_paths, AppScriptId, ListAppQuery, + APP_WORKSPACED_ROUTE, + }, auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN}, cache::{self, future::FutureCachedExt}, db::{DbWithOptAuthed, UserDB}, @@ -75,7 +78,8 @@ use windmill_common::{ variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, worker::{to_raw_value, CLOUD_HOSTED}, workspaces::{ - check_deploy_rules, check_user_against_rule, ProtectionRuleKind, RuleCheckResult, + check_deploy_rules, check_operator_can_build, check_user_against_rule, + operator_builder_rights, BuilderKind, ProtectionRuleKind, RuleCheckResult, }, HUB_BASE_URL, }; @@ -477,6 +481,12 @@ pub struct PolicyTriggerableInputs { sensitive_inputs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] tag: Option, + /// The runnable reads data as the app's viewer rather than as the app's identity: data table + /// connections that name no role, and object storage advanced permission rules. Honored for + /// inline runnables only: their code is deployed with the app, so it is the publisher who + /// chose to read with the viewer's rights. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + viewer_data_access: bool, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -749,7 +759,7 @@ async fn list_apps( // Append the authed user's `app`/`raw_app` drafts at paths with no deployed app; // see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator + && (!authed.is_operator || operator_builder_rights(&db, &w_id).await?.apps) && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() @@ -979,7 +989,14 @@ async fn get_raw_app_data( // the opaque, sandboxed wrapper (incl. under a cross-origin-isolated // / COEP `require-corp` embedder). .header("X-Content-Type-Options", "nosniff") - .header("Cross-Origin-Resource-Policy", "cross-origin"); + .header("Cross-Origin-Resource-Policy", "cross-origin") + // The secret names one app version, whose bundle is written once at deploy, + // so the URL's content never changes. `private`: the URL is a capability, so + // shared caches must not keep serving it after the app is deleted. + .header( + http::header::CACHE_CONTROL, + "private, max-age=31536000, immutable", + ); Ok(res.body(body).unwrap()) } else { return Err(Error::NotFound("File not found".to_string())); @@ -998,7 +1015,6 @@ fn raw_app_wrapper_html(secret: &str) -> String { -App @@ -331,8 +331,8 @@ custom integrations. ## For AI coding agents When asked to add a chat over a Windmill flow: the flow must be deployed with chat mode -on. Pick the entry point from the table at the top (`useChat` → `windmill-chat/ai-sdk`, -assistant-ui → `windmill-chat/assistant-ui`, otherwise `windmill-chat/react`). Inside a +on. Pick the entry point from the table at the top (`useChat` → `@windmill-labs/chat-sdk/ai-sdk`, +assistant-ui → `@windmill-labs/chat-sdk/assistant-ui`, otherwise `@windmill-labs/chat-sdk/react`). Inside a Windmill raw app pass only `flowPath`. Elsewhere pass `baseUrl`, `workspace` and a `token`; for a public page use a token scoped to `jobs:run:flows:` and leave `history` at its default. Render `role`, `content`, `pending`, `success` and diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index fcc48449cf..7b7da8884e 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { - "name": "windmill-chat", - "version": "1.826.0", + "name": "@windmill-labs/chat-sdk", + "version": "1.833.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "windmill-chat", - "version": "1.826.0", + "name": "@windmill-labs/chat-sdk", + "version": "1.833.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 33b6c43fa3..29488b7e47 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { - "name": "windmill-chat", + "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.826.0", + "version": "1.833.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/bun.lock b/cli/bun.lock index 0e0885f563..db6fd253bf 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -19,14 +19,14 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.56.8", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", - "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-py": "1.830.0", + "windmill-parser-wasm-py-imports": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", @@ -291,7 +291,7 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.753.0", "", {}, "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg=="], + "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.830.0", "", {}, "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw=="], "windmill-parser-wasm-csharp": ["windmill-parser-wasm-csharp@1.510.1", "", {}, "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ=="], @@ -303,9 +303,9 @@ "windmill-parser-wasm-php": ["windmill-parser-wasm-php@1.647.1", "", {}, "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="], - "windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.782.0", "", {}, "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ=="], + "windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.830.0", "", {}, "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ=="], - "windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.693.1", "", {}, "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="], + "windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.830.0", "", {}, "sha512-Pi9gHStNFTEqfcDrjSxK5SnI/1dTG4xKcVUD3BHlR1aQ7E/VxkTD/D0INJm7mNC2usKb2bO21NMdKmGh+RAXEw=="], "windmill-parser-wasm-r": ["windmill-parser-wasm-r@1.668.1", "", {}, "sha512-5YNeUibxpNBvYrxCgQcz1PxGhTFx2CyEpg2udtIhq7bx0d4gF/KDZVupMeQmAObmrEtTSFGUWNRJ4zXSWNrSpQ=="], diff --git a/cli/package.json b/cli/package.json index 5e7623a7ab..5efe8a2499 100644 --- a/cli/package.json +++ b/cli/package.json @@ -31,14 +31,14 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.56.8", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", - "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-py": "1.830.0", + "windmill-parser-wasm-py-imports": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 0d4ad634c8..0060c2c422 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -32,6 +32,8 @@ export interface AppFile { guests?: boolean; value: any; public?: boolean; + sandbox?: boolean; + frontend_api?: string[]; summary: string; policy: Policy; // Mirrors granular ACLs on the app path. Omitted from app.yaml when no @@ -113,6 +115,14 @@ export function replaceInlineScripts( } return []; } +/** `parent_version` is the editor's stale-draft base: a version id that changes on + * every deploy and means nothing in another workspace, so it is kept out of the + * tracked file and out of what a push deploys. */ +export function stripEditorState(app: any) { + if (app?.value && typeof app.value == "object") { + delete app.value.parent_version; + } +} export function isExecutionModeAnonymous(app: any) { return app?.["policy"]?.["execution_mode"] == "anonymous"; } @@ -160,6 +170,100 @@ export function executionModeForPush( } return deployedPolicy?.execution_mode === "viewer" ? "viewer" : "publisher"; } +/** Mirrors `FRONTEND_SDK_ALLOWED_SCOPES` in the backend's `apps.rs`, which stays + * the authority: this copy only fails a push before it bundles. */ +export const FRONTEND_API_SCOPES = [ + "jobs:run", + "jobs:read", + "users:read", + "resources:read", + "variables:read", + "flow_conversations:read", + "flow_conversations:write", +]; + +export type AppIsolation = { sandbox: boolean; frontendApi: string[] }; + +/** Write `sandbox` and `frontend_api` on a pulled app, only when they are on, so + * an app using neither keeps the file it had. Scopes of an unsandboxed app are + * inert and stay out: a file stating them without `sandbox` does not push. */ +export function markIsolationFromPolicy(app: any, rawApp: boolean) { + if (app?.["policy"]?.["sandbox"] !== true) { + return; + } + app.sandbox = true; + const scopes = app.policy.frontend_sdk_scopes; + if (rawApp && Array.isArray(scopes) && scopes.length > 0) { + app.frontend_api = scopes; + } +} + +/** The isolation the tracked file states, or `undefined` when it has no `sandbox` + * key and the deployed policy carries over. A file that has the key states both + * fields: `sandbox: false` turns isolation off, and a missing `frontend_api` + * means no scopes. */ +export function statedIsolation( + localApp: any, + rawApp: boolean, +): AppIsolation | undefined { + const sandbox = localApp?.["sandbox"]; + const scopes = localApp?.["frontend_api"] ?? []; + if (sandbox !== undefined && typeof sandbox !== "boolean") { + throw new Error("'sandbox' must be true or false"); + } + if ( + !Array.isArray(scopes) || + scopes.some((s: unknown) => typeof s !== "string") + ) { + throw new Error("'frontend_api' must be a list of scopes"); + } + if (scopes.length > 0 && !rawApp) { + throw new Error("'frontend_api' is only available to full-code apps"); + } + const seen = new Set(); + for (const scope of scopes) { + if (!FRONTEND_API_SCOPES.includes(scope)) { + throw new Error( + `Invalid frontend_api scope '${scope}'. Allowed scopes: ${FRONTEND_API_SCOPES.join(", ")}`, + ); + } + if (seen.has(scope)) { + throw new Error(`Duplicate frontend_api scope '${scope}'`); + } + seen.add(scope); + } + if (scopes.length > 0 && sandbox !== true) { + throw new Error( + "'frontend_api' requires 'sandbox: true': an unsandboxed app gets no frontend API token", + ); + } + return sandbox === undefined ? undefined : { sandbox, frontendApi: scopes }; +} + +/** Whether the deployed policy already has the stated isolation. The file's two + * keys are kept out of the superset comparison against the deployed app, which + * could not see a key the file dropped. */ +export function isolationUpToDate( + isolation: AppIsolation | undefined, + deployedPolicy: Policy | undefined, + rawApp: boolean, +): boolean { + if (!isolation) { + return true; + } + if ((deployedPolicy?.sandbox === true) !== isolation.sandbox) { + return false; + } + if (!rawApp) { + return true; + } + const deployed = deployedPolicy?.frontend_sdk_scopes ?? []; + return ( + deployed.length === isolation.frontendApi.length && + deployed.every((s, i) => s === isolation.frontendApi[i]) + ); +} + export async function pushApp( workspace: string, remotePath: string, @@ -198,6 +302,8 @@ export async function pushApp( } const path = localPath + "app.yaml"; const localApp = (await yamlParseFile(path)) as AppFile; + stripEditorState(localApp); + const isolation = statedIsolation(localApp, false); replaceInlineScripts(localApp.value, localPath, true); // On create the backend applies folder defaults, so there is nothing to preserve. @@ -210,17 +316,26 @@ export async function pushApp( localApp, remotePath, executionModeForPush(localApp, deployedPolicy), - basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of) + basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of), + isolation ); // extra_perms goes through /acls/* — strip from the body so a perms-only // edit never bumps the app version (see applyExtraPermsDiff for details). - const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & { + const { + extra_perms: localPerms, + sandbox: _sandbox, + frontend_api: _frontendApi, + ...localAppBody + } = localApp as AppFile & { extra_perms?: Record; }; if (app) { - if (isSuperset(localAppBody, app)) { + if ( + isSuperset(localAppBody, app) && + isolationUpToDate(isolation, deployedPolicy, false) + ) { log.info(colors.green(`App ${remotePath} is up to date`)); } else { log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); @@ -269,12 +384,13 @@ export async function generatingPolicy( app: any, path: string, executionMode: AppExecutionMode, - base: Policy | undefined + base: Policy | undefined, + isolation?: AppIsolation ) { log.info(colors.gray(`Generating fresh policy for app ${path}...`)); try { app.policy = await windmillUtils.updatePolicy(app.value, base); - finalizeDerivedPolicy(app.policy, executionMode); + finalizeDerivedPolicy(app.policy, executionMode, isolation); } catch (e) { log.error(colors.red(`Error generating policy for app ${path}: ${e}`)); throw e; @@ -332,10 +448,21 @@ export function preserveOnBehalfOfFields( * deployed app would keep being able to run runnables this push removed. */ export function finalizeDerivedPolicy( policy: Policy, - executionMode: AppExecutionMode + executionMode: AppExecutionMode, + isolation?: AppIsolation, + rawApp: boolean = false ) { policy.triggerables = undefined; policy.execution_mode = executionMode; + if (isolation) { + // `false` is sent rather than left out: the server reads an omitted value as + // "no opinion" and may sandbox the app anyway, hiding that the file was refused. + policy.sandbox = isolation.sandbox; + if (rawApp) { + policy.frontend_sdk_scopes = + isolation.frontendApi.length > 0 ? isolation.frontendApi : undefined; + } + } } async function list(opts: GlobalOptions & { includeDraftOnly?: boolean; json?: boolean }) { diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 286e878cfb..287d01ebed 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -58,10 +58,10 @@ function conditionsFor(svelte: boolean): string[] { /** * Detects which frontend frameworks are present in package.json */ -export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolean } { +export function detectFrameworks(appDir: string): { svelte: boolean } { const packageJsonPath = path.join(appDir, "package.json"); if (!fs.existsSync(packageJsonPath)) { - return { svelte: false, vue: false }; + return { svelte: false }; } try { @@ -73,10 +73,9 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea return { svelte: "svelte" in allDeps, - vue: "vue" in allDeps, }; } catch { - return { svelte: false, vue: false }; + return { svelte: false }; } } @@ -256,17 +255,6 @@ export async function createFrameworkPlugins(appDir: string): Promise { plugins.push(createSveltePlugin(appDir)); } - if (frameworks.vue) { - log.info(colors.blue("🔧 Vue detected, adding vue plugin...")); - throw new Error("Vue plugin not supported yet"); - // try { - // const esbuildPluginVue = await import("esbuild-plugin-vue3"); - // plugins.push(esbuildPluginVue.default()); - // } catch (error: any) { - // log.warn(colors.yellow(`Failed to load vue plugin: ${error.message}`)); - // } - } - return plugins; } @@ -313,7 +301,7 @@ export async function createBundle( // Use the entryPoint's directory if provided, otherwise fall back to cwd. const appDir = options.entryPoint ? path.dirname(options.entryPoint) : process.cwd(); const frameworks = detectFrameworks(appDir); - const defaultEntry = (frameworks.svelte || frameworks.vue) ? "index.ts" : "index.tsx"; + const defaultEntry = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = options.entryPoint ?? defaultEntry; const outDir = options.outDir ?? "dist"; @@ -331,7 +319,7 @@ export async function createBundle( // Ensure node_modules exists in the app directory await ensureNodeModules(appDir); - // Load framework-specific plugins (svelte, vue) based on package.json + // Load framework-specific plugins (svelte) based on package.json const frameworkPlugins = await createFrameworkPlugins(appDir); // Ensure output directory exists diff --git a/cli/src/commands/app/bundle_command.ts b/cli/src/commands/app/bundle_command.ts index bef13f4268..c4119aafb0 100644 --- a/cli/src/commands/app/bundle_command.ts +++ b/cli/src/commands/app/bundle_command.ts @@ -12,10 +12,10 @@ interface BundleOptions { } /** Every entry point a raw app may have, most specific first, with the - * framework's preferred one promoted — Svelte and Vue mount from a `.ts`. */ + * framework's preferred one promoted — Svelte mounts from a `.ts`. */ function entryPointFor(appDir: string): string { const frameworks = detectFrameworks(appDir); - const candidates = frameworks.svelte || frameworks.vue + const candidates = frameworks.svelte ? ["index.ts", "index.tsx", "index.js"] : ["index.tsx", "index.ts", "index.js"]; const entry = candidates.find((c) => fs.existsSync(path.join(appDir, c))); diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 2dac8639e2..fdf26fa7da 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -481,7 +481,7 @@ async function dev(opts: DevOptions, appFolder?: string) { // Detect frameworks to determine default entry point const frameworks = detectFrameworks(process.cwd()); - const defaultEntry = frameworks.svelte || frameworks.vue + const defaultEntry = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = opts.entry ?? defaultEntry; @@ -577,7 +577,7 @@ async function dev(opts: DevOptions, appFolder?: string) { const buildOptions = getDevBuildOptions(entryPoint, frameworks.svelte); - // Load framework-specific plugins (svelte, vue) based on package.json + // Load framework-specific plugins (svelte) based on package.json const frameworkPlugins = await createFrameworkPlugins(appDir); const wmillPlugin = { @@ -1602,7 +1602,7 @@ const command = new Command() }) .option( "--entry ", - "Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)", + "Entry point file (default: index.ts for Svelte, index.tsx otherwise)", ) .option("--no-open", "Don't automatically open the browser") .option( diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index 9356b5dd3c..c90a22105d 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -9,6 +9,8 @@ import { GlobalOptions } from "../../types.ts"; import { createBundle, detectFrameworks } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; import { loadRunnablesFromBackend } from "./raw_apps.ts"; +import { inferSchema } from "../../utils/metadata.ts"; +import { statedIsolation } from "./app.ts"; import { getFolderSuffix, hasFolderSuffix, @@ -42,12 +44,62 @@ function validateRawAppYaml(appData: any): { errors.push("Field 'summary' must be a string"); } + try { + statedIsolation(appData, true); + } catch (error: any) { + errors.push(error.message); + } + // Note: 'runnables' is no longer required in raw_app.yaml // Runnables can be stored in separate files in the backend folder return { errors, warnings }; } +const SQL_LANGUAGES = new Set([ + "postgresql", + "mysql", + "bigquery", + "snowflake", + "mssql", + "oracledb", + "duckdb", +]); + +// The viewer properties a `ctx` field can supply. The Inputs tab of the app +// editor hints on the same names (RawAppInlineScriptRunnable.svelte). +const VIEWER_IDENTITY_ARGS = ["username", "email", "groups"]; + +/** + * A SQL runnable argument named after a viewer property is most often a + * per-viewer filter. Left as a plain input it takes whatever the browser + * sends, so it has to be a `ctx` field for the server to fill it. + */ +export async function viewerIdentityInputWarnings( + runnables: Record, +): Promise { + const warnings: string[] = []; + for (const [id, runnable] of Object.entries(runnables)) { + const { language, content } = runnable?.inlineScript ?? {}; + if (!SQL_LANGUAGES.has(language) || typeof content !== "string") { + continue; + } + const { schema } = await inferSchema(language, content, {}, id); + for (const arg of VIEWER_IDENTITY_ARGS) { + const field = runnable.fields?.[arg]; + const serverFilled = field?.ctx !== undefined || + field?.value !== undefined; + if (arg in (schema?.properties ?? {}) && !serverFilled) { + warnings.push( + `Runnable '${id}': argument '${arg}' is a frontend input, so a viewer can send any value. ` + + `If it identifies the viewer, set 'fields: { ${arg}: { ctx: ${arg} } }' in backend/${id}.yaml (next to 'type: inline') so the server fills it.`, + ); + } + } + } + return warnings; +} + /** * Validates that runnables exist either in backend/*.yaml files or in raw_app.yaml */ @@ -77,6 +129,7 @@ async function validateRunnables( " - Or a 'runnables' field in raw_app.yaml (legacy format)", ); } else if (hasBackendRunnables) { + warnings.push(...await viewerIdentityInputWarnings(runnablesFromBackend)); log.info( colors.gray( ` Found ${ @@ -114,7 +167,7 @@ async function validateBuild( // Try to create a bundle - this will validate that all dependencies are in place const frameworks = detectFrameworks(appDir); - const entryFile = frameworks.svelte || frameworks.vue ? "index.ts" : "index.tsx"; + const entryFile = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = path.join(appDir, entryFile); await createBundle({ entryPoint, diff --git a/cli/src/commands/app/new.ts b/cli/src/commands/app/new.ts index b011965ae8..40cc2a4416 100644 --- a/cli/src/commands/app/new.ts +++ b/cli/src/commands/app/new.ts @@ -85,21 +85,6 @@ const app = mount(App, { target: document.getElementById("root")! }); export default app; `; -const appVue = ` - -`; - -const indexVue = `import { createApp } from 'vue' -import App from './App.vue' -import "./index.css"; - -createApp(App).mount('#root')`; - const indexCss = `body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; @@ -171,21 +156,6 @@ const templates: Record = { "svelte": "^5.55.5", "windmill-client": "^1" } -}`, - }, - }, - vue: { - name: "Vue 3", - files: { - "/index.ts": indexVue, - "/App.vue": appVue, - "/index.css": indexCss, - "/package.json": `{ - "dependencies": { - "core-js": "3.26.1", - "vue": "3.5.13", - "windmill-client": "^1" - } }`, }, }, @@ -251,7 +221,7 @@ interface DataConfig { schema?: string; } -type FrameworkKey = "react19" | "react18" | "svelte5" | "vue"; +type FrameworkKey = "react19" | "react18" | "svelte5"; interface NewAppOptions extends GlobalOptions { /** App summary (short description). Skips the prompt when provided. */ @@ -384,7 +354,7 @@ async function newApp(opts: NewAppOptions) { } // Ask for framework (skipped if --framework is provided) - const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5", "vue"]; + const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5"]; let framework: string; if (opts.framework !== undefined) { if (!VALID_FRAMEWORKS.includes(opts.framework)) { @@ -403,7 +373,6 @@ async function newApp(opts: NewAppOptions) { { name: "React 19 (Recommended)", value: "react19" }, { name: "React 18", value: "react18" }, { name: "Svelte 5", value: "svelte5" }, - { name: "Vue 3", value: "vue" }, ], }); } @@ -900,7 +869,7 @@ const command = new Command() ) .option( "--framework ", - "Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode." + "Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode." ) .option( "--datatable ", diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 36c8260e25..e77e1aed45 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -20,13 +20,16 @@ import { deepEqual, readTextFile } from "../../utils/utils.ts"; import { type AppExecutionMode, + type AppIsolation, basePolicy, executionModeForPush, finalizeDerivedPolicy, + isolationUpToDate, markAccessFromPolicy, preserveOnBehalfOfFields, replaceInlineScripts, repopulateFields, + statedIsolation, } from "./app.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; import { buildPermissionedAsContext } from "../../core/permissioned_as.ts"; @@ -49,6 +52,8 @@ export interface AppFile { runnables?: any; custom_path?: string; public?: boolean; + sandbox?: boolean; + frontend_api?: string[]; summary: string; policy: Policy; data?: { @@ -402,6 +407,7 @@ export async function pushRawApp( } const appFilePath = localPath + "raw_app.yaml"; const localApp = (await yamlParseFile(appFilePath)) as AppFile; + const isolation = statedIsolation(localApp, true); // Load runnables from separate YAML files in the backend folder // Falls back to reading from raw_app.yaml if no separate files exist (backward compat) @@ -454,6 +460,7 @@ export async function pushRawApp( deployedPolicy, !!preserveFields.preserve_on_behalf_of, ), + isolation, ); const files = await collectAppFiles(localPath); @@ -461,7 +468,7 @@ export async function pushRawApp( log.info(colors.yellow.bold(`Creating raw app ${remotePath} bundle...`)); // Detect frameworks to determine entry point const frameworks = detectFrameworks(localPath); - const entryFile = frameworks.svelte || frameworks.vue + const entryFile = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = localPath + entryFile; @@ -485,14 +492,21 @@ export async function pushRawApp( // to `.raw_app.json`, not `.app.json`. The backend granular_acls // handler routes "raw_app" to the `app` table (where v2 raw apps actually // live) while still dispatching DeployedObject::RawApp for git-sync. - const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & { + const { + extra_perms: localPerms, + sandbox: _sandbox, + frontend_api: _frontendApi, + ...localAppNoPerms + } = localApp as AppFile & { extra_perms?: Record; }; if (app) { // Check both metadata/runnables AND files for changes // Files need separate comparison because isSuperset only checks if local keys exist in remote - const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app); + const metadataUpToDate = + isSuperset({ ...localAppNoPerms, runnables }, app) && + isolationUpToDate(isolation, deployedPolicy, true); const filesUpToDate = deepEqual(files, app.value?.files); if (metadataUpToDate && filesUpToDate) { log.info(colors.green(`App ${remotePath} is up to date`)); @@ -555,16 +569,38 @@ export async function pushRawApp( ); } +/** + * `@windmill-labs/shared-utils` up to 1.0.13 derives the policy without `viewer_data_access`, + * which would silently deploy an inline runnable that asks to connect as its viewer as one + * connecting as the app. The server honors the flag on inline runnables only. + */ +export function carryViewerDataAccess( + runnables: Record | undefined, + policy: Policy | undefined, +) { + const triggerables = policy?.triggerables_v2 as Record | undefined; + if (!runnables || !triggerables) return; + for (const [id, runnable] of Object.entries(runnables)) { + if (runnable?.viewer_data_access !== true) continue; + if (runnable.type !== "inline" && runnable.type !== "runnableByName") continue; + for (const [key, triggerable] of Object.entries(triggerables)) { + if (key.startsWith(`${id}:rawscript/`)) triggerable.viewer_data_access = true; + } + } +} + export async function generatingPolicy( app: any, path: string, executionMode: AppExecutionMode, base: Policy | undefined, + isolation?: AppIsolation, ) { log.info(colors.gray(`Generating fresh policy for app ${path}...`)); try { app.policy = await windmillUtils.updateRawAppPolicy(app.runnables, base); - finalizeDerivedPolicy(app.policy, executionMode); + carryViewerDataAccess(app.runnables, app.policy); + finalizeDerivedPolicy(app.policy, executionMode, isolation, true); } catch (e) { log.error(colors.red(`Error generating policy for app ${path}: ${e}`)); throw e; diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 8a433d69d4..540d8c7978 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -77,8 +77,8 @@ const WORKSPACE_CONFIG_SCHEMA = { */ export const CONFIG_REFERENCE: ConfigOption[] = [ // ── Core ────────────────────────────────────────────────────────────── - { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts", - inlineComment: "bun or deno" }, + { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "TypeScript runtime of a plain .ts script (bun when absent)", + skipInTemplate: true }, { name: "includes", type: "array", items: { type: "string" }, default: '["f/**"]', description: "Glob patterns for files to include in sync", templateValue: '\n - "f/**"', inlineComment: 'use ** for all, f/** for folder-scoped' }, { name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in workspace overrides)", diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 54887028f0..5ba8888332 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -149,7 +149,11 @@ import { extractCurrentMapping, } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; import { generateFlowLockInternal } from "../flow/flow_metadata.ts"; -import { markAccessFromPolicy } from "../app/app.ts"; +import { + markAccessFromPolicy, + markIsolationFromPolicy, + stripEditorState, +} from "../app/app.ts"; import { APP_BACKEND_FOLDER, generateAppLocksInternal, @@ -1329,6 +1333,7 @@ export function ZipFSElement( log.error(`Failed to parse app.yaml at path: ${p}`); throw error; } + stripEditorState(app); let inlineScripts; try { inlineScripts = extractInlineScriptsForApps( @@ -1359,6 +1364,7 @@ export function ZipFSElement( } markAccessFromPolicy(app); + markIsolationFromPolicy(app, false); app.policy = undefined; yield { isDirectory: false, @@ -1377,6 +1383,7 @@ export function ZipFSElement( throw error; } markAccessFromPolicy(rawApp); + markIsolationFromPolicy(rawApp, true); // console.log("rawApp", rawApp); rawApp.policy = undefined; // custom_path is derived from the file path, don't store it @@ -2747,12 +2754,18 @@ export async function compareDynFSElement( } return o; } else { + let o: any; try { - return yamlParseContent(k, v); + o = yamlParseContent(k, v); } catch (error) { log.error(`Failed to parse YAML content at path: ${k}`); throw error; } + // A file tracked before the field was dropped still carries it. + if (path.basename(k) == "app.yaml") { + stripEditorState(o); + } + return o; } } diff --git a/cli/src/commands/user/user.ts b/cli/src/commands/user/user.ts index b5ed0e3196..99bea072a8 100644 --- a/cli/src/commands/user/user.ts +++ b/cli/src/commands/user/user.ts @@ -1,6 +1,8 @@ import { writeFile } from "node:fs/promises"; import { requireLogin } from "../../core/auth.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { mergeConfigWithConfigFile } from "../../core/conf.ts"; import { GlobalOptions, isSuperset, @@ -69,6 +71,32 @@ async function list(opts: GlobalOptions) { .render(); } +async function listWorkspaceUsers(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); + opts = await mergeConfigWithConfigFile(opts); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const users = await wmill.listUsers({ workspace: workspace.workspaceId }); + + if (opts.json) { + console.log(JSON.stringify(users)); + } else { + new Table() + .header(["username", "email", "role"]) + .padding(2) + .border(true) + .body( + users.map((u) => [ + u.username, + u.email, + u.is_admin ? "admin" : u.operator ? "operator" : "developer", + ]) + ) + .render(); + } +} + function rdString() { return Math.random().toString(36).slice(2, 7); } @@ -518,6 +546,9 @@ export async function pushInstanceGroups( const command = new Command() .description("user related commands") .action(list as any) + .command("list", "List the users of the workspace") + .option("--json", "Output as JSON (for piping to jq)") + .action(listWorkspaceUsers as any) .command("add", "Create a user") .arguments(" [password:string]") .option("--superadmin", "Specify to make the new user superadmin.") diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 8963f376d2..88ad108a1a 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -107,7 +107,8 @@ export interface VariableFile { * plaintext value a user authored by hand. * * Mirrors the server guard (windmill-store/src/variables.rs): workspace - * ciphertext (AES-256-CBC, base64) is standard base64 decoding to a non-zero + * ciphertext (AES-256-GCM or legacy AES-256-CBC, base64) is standard base64 + * decoding to a non-zero * multiple of the 16-byte block size. External secret-backend markers * ($vault:/$aws_sm:/$azure_kv:) are stored verbatim too, so they count as * already-encrypted. This is a shape check only — it never decrypts. diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 455e876292..1ad0b69c1b 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -320,12 +320,6 @@ export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promis delete (conf as any)?.environments; delete conf?.git_branches; - if (conf?.defaultTs == undefined) { - log.warn( - "No defaultTs defined in your wmill.yaml. Using 'bun' as default." - ); - } - // Initialize global nonDottedPaths setting from config setNonDottedPaths(conf?.nonDottedPaths ?? false); @@ -376,7 +370,6 @@ export const DEFAULT_SYNC_OPTIONS: Readonly< Required< Pick< SyncOptions, - | "defaultTs" | "includes" | "excludes" | "codebases" @@ -401,7 +394,6 @@ export const DEFAULT_SYNC_OPTIONS: Readonly< > > > = { - defaultTs: "bun", includes: ["f/**"], excludes: [], codebases: [], diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 6b59042168..fe72d56a01 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.826.0"; +export const VERSION = "1.833.0"; diff --git a/cli/src/core/permissioned_as.ts b/cli/src/core/permissioned_as.ts index d0301373da..87ee37a8cb 100644 --- a/cli/src/core/permissioned_as.ts +++ b/cli/src/core/permissioned_as.ts @@ -34,10 +34,14 @@ export async function buildPermissionedAsContext( return undefined; } const user = await wmill.whoami({ workspace }); + // A superadmin acts as admin in every workspace, so the backend preserves + // ownership for them even when their own `usr` row there is not an admin. const userIsAdminOrDeployer = - user.is_admin || (user.groups ?? []).includes("wm_deployers"); + user.is_admin || + user.is_super_admin || + (user.groups ?? []).includes("wm_deployers"); log.debug( - `permissioned_as: user=${user.email}, is_admin=${user.is_admin}, groups=${JSON.stringify(user.groups)}, isAdminOrDeployer=${userIsAdminOrDeployer}` + `permissioned_as: user=${user.email}, is_admin=${user.is_admin}, is_super_admin=${user.is_super_admin}, groups=${JSON.stringify(user.groups)}, isAdminOrDeployer=${userIsAdminOrDeployer}` ); return { userCache: new Map(), diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 57e89f5ded..75dfa3b8da 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -413,7 +413,7 @@ export async function pushWorkspaceSettings( const localOperatorSettings = localSettings.operator_settings && { ...localSettings.operator_settings, }; - for (const key of ["builder_flows", "manage_schedules", "manage_triggers"] as const) { + for (const key of ["builder_flows", "builder_apps", "manage_schedules", "manage_triggers"] as const) { const remote = settings.operator_settings?.[key]; if (localOperatorSettings && localOperatorSettings[key] === undefined && remote !== undefined) { localOperatorSettings[key] = remote; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 4aaf6d7e3c..7cc8f230ad 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -34,6 +34,7 @@ export const SKILLS: SkillMetadata[] = [ { 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: "datatables", description: "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions." }, { 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." }, ]; @@ -767,21 +768,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise +async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise +async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise /** * Run a flow asynchronously by its path @@ -837,6 +840,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -1096,6 +1107,9 @@ async sleep(seconds: number): Promise * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -1600,21 +1614,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise +async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise +async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise /** * Run a flow asynchronously by its path @@ -1670,6 +1686,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -1929,6 +1953,9 @@ async sleep(seconds: number): Promise * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -2539,21 +2566,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise +async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise +async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise /** * Run a flow asynchronously by its path @@ -2609,6 +2638,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -2868,6 +2905,9 @@ async sleep(seconds: number): Promise * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -4434,10 +4474,14 @@ def post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response def create_token(duration = dt.timedelta(days=1)) -> str # Create a script job by path and return its job id. -def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# +# \`\`retry\`\` re-runs the job when it fails, with the policy a schedule takes, +# e.g. \`\`{"constant": {"attempts": 3, "seconds": 10}}\`\`. The returned id is +# the first attempt's; each retry is a job of its own, recorded as a child of it. +def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str -# Create a script job by hash and return its job id. -def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# Create a script job by hash and return its job id. See \`\`run_script_by_path_async\`\` for \`\`retry\`\`. +def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str # Create a flow job and return its job id. def run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str @@ -4610,6 +4654,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None @@ -5027,7 +5083,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -5113,12 +5171,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. @@ -5565,6 +5626,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` +## Array parameters + +Declare a list with a \`[]\` suffix on the element type (\`(varchar[])\`, +\`(int[])\`), or \`(array)\` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +\`PARSE_JSON(?)\`, and cast to \`ARRAY\` where a function expects one. + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +\`\`\` + +Or join on the elements with \`FLATTEN\`: + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +\`\`\` + ## Receiving an S3Object as a script parameter Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for @@ -5969,6 +6051,10 @@ can call one, not an agent used as a tool. - \`results.step_id.property\` - Access specific property from a previous step output only when that step result is in scope - \`flow_input.iter.value\` - Current iteration value inside a \`forloopflow\`; in a \`whileloopflow\` it is just the iteration index (a plain number, same as \`flow_input.iter.index\`) - \`flow_input.iter.index\` - Current loop index when inside a loop (\`forloopflow\` or \`whileloopflow\`) +- After a suspend step (approval), the next step's javascript expressions also get: + - \`resume\` - the last resume payload (the resume form values); with \`continue_on_disapprove_timeout\`, a disapproval or timeout yields \`{ error: { name: "SuspendedDisapproved" | "SuspendedTimedOut", message } }\` instead + - \`resumes\` - every resume payload, in arrival order + - \`approvers\` - a plain array of strings, one per resume: for a resume through the \`resume\`/\`cancel\` URL from \`getResumeUrls(approver)\`, the \`approver\` passed there if any; otherwise (including the \`approvalPage\` link) the resuming user's username, else \`"anonymous"\`, e.g. \`approvers[0]\`. It is NOT the \`{ resume_id, approver }\` objects stored in the flow status, so \`approvers[0].approver\` is always undefined ## Loop Structure Rules @@ -6280,7 +6366,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"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 @@ -6301,7 +6387,7 @@ You need three values to run the command: 1. **summary** — a short description of the app 2. **path** — the windmill path, e.g. \`f/folder/my_app\` or \`u/username/my_app\` -3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\`, \`vue\` +3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\` **summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths. @@ -6373,7 +6459,7 @@ my_app{{RAW_APP_SUFFIX}}/ ├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh) ├── raw_app.yaml # App configuration (summary, path, data settings) ├── index.tsx # Frontend entry point -├── App.tsx # Main React/Svelte/Vue component +├── App.tsx # Main React/Svelte component ├── index.css # Styles ├── package.json # Frontend dependencies ├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT) @@ -6436,6 +6522,8 @@ fields: value: "default_user" \`\`\` +\`viewer_data_access: true\` makes an inline runnable read data tables and object storage as the person using the app instead of as the app's identity (see "Reading data as the viewer"). + ### Referencing existing scripts To use an existing Windmill script instead of inline code: @@ -6472,7 +6560,7 @@ data: - \`/
\` — Specific table in public schema - \`/:
\` — Table in specific schema -**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role. +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of the app's identity, or of whoever is using the app for a runnable with \`viewer_data_access: true\`, unless that default is \`admin\`. ## SQL Migrations (sql_to_apply/) @@ -6531,7 +6619,7 @@ For the rest, tell the user which command fits their intent and let them run it # Windmill Raw Apps -Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. +Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables. ## App shape @@ -6547,7 +6635,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t ### Framework -Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for. +Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead. ### Data setup @@ -6577,7 +6665,7 @@ Then build what was chosen: the data table and schema become the app's \`data.da ### Entrypoint -The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`
\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` / \`App.vue\` and keep the entrypoint as the mount shim. +The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`
\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` and keep the entrypoint as the mount shim. React (\`index.tsx\`): @@ -6589,10 +6677,12 @@ 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')\`. +Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`. **Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`. +**Set the browser tab title with \`document.title = '...'\`** (or a \`\` the framework renders into \`<head>\`). The app has no \`index.html\` of its own, so a \`<title>\` written there is never served; the deployed app's page shows whatever \`document.title\` holds, and follows it when it changes. + **Always begin every React file (\`.tsx\`/\`.jsx\`) that uses JSX with \`import React from 'react'\`.** esbuild uses the classic JSX transform, so \`React\` must be in scope wherever JSX appears — a missing import compiles fine but throws \`React is not defined\` at runtime, leaving a blank screen. ### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`) @@ -6662,16 +6752,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`windmill-chat\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`@windmill-labs/chat-sdk\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. \`\`\`tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() \`\`\` -\`windmill-chat/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`windmill-chat/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. +\`@windmill-labs/chat-sdk/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`@windmill-labs/chat-sdk/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -6755,6 +6845,23 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen 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. +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +In \`raw_app.yaml\`: + +\`\`\`yaml +sandbox: true +frontend_api: [jobs:run, users:read] +\`\`\` + +A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise. + +## Reading data as the viewer + +A backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features. +Set it with \`viewer_data_access: true\` in the runnable's \`backend/<id>.yaml\`. + ## Data Tables 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. @@ -6766,6 +6873,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 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\`. +6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see "Reading data as the viewer"). Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no \`data.roles\` entry. Data table roles are an Enterprise Edition feature, self-hosted only. \`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\`. @@ -6819,7 +6927,7 @@ def main(user_id: str): 7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. `, "triggers": `--- name: triggers @@ -7386,6 +7494,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or \`step()\` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. \`sleep()\` there waits in the job, and \`taskScript()\` / \`taskFlow()\` / \`waitForApproval()\` (\`task_script()\` / \`task_flow()\` / \`wait_for_approval()\`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: \`\`\`typescript @@ -7551,8 +7661,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a \`workflow()\`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a \`Date\` comes back as a string, a @@ -7596,6 +7707,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> @@ -7694,7 +7808,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -7780,12 +7896,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. @@ -7865,10 +7984,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step 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://<name>/<table>\` (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. +- \`bun\` / \`python3\` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — 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. +## Ingesting from Python / TypeScript + +A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with \`wmill.ducklake()\`, or into a data table with \`wmill.datatable()\`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table. + +Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema: + +\`\`\`python +# pipeline +# on schedule +import wmill + +def main(): + rows = fetch_charges() # list[dict] + lake = wmill.ducklake() + lake.query(""" + CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true) + """, rows=rows).fetch() +\`\`\` + +\`\`\`ts +// pipeline +// on schedule +import * as wmill from "windmill-client" + +export async function main() { + const rows = await fetchCharges() + const lake = wmill.ducklake() + await lake\`CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json(\${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)\`.fetch() +} +\`\`\` + +- Use \`INSERT INTO <table> SELECT ...\` instead of \`CREATE OR REPLACE\` to append (an incremental pull), with a one-time \`CREATE TABLE IF NOT EXISTS\` first. +- Assign the client to a variable (\`lake = wmill.ducklake()\`) and keep the SQL a **string literal** (Python) or a template without \`sql.raw\` (TS): that is what lets the graph record the node's write to \`ducklake://main/<table>\`. A chained \`wmill.ducklake().query(...)\` or SQL built in a variable records no output, so the downstream edge never forms. +- \`wmill.ducklake("<name>")\` targets a non-default catalog; \`wmill.datatable("<name>")\` writes a Postgres data table the same way (Python \`db.query(sql, *args)\` with \`$1\` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above). +- The downstream node triggers on the table itself: \`-- on ducklake://main/stripe_charges\`. + ## 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. @@ -7912,7 +8069,7 @@ The key must be a **string literal** — the graph parser is static and cannot f ## Materialize (the managed output) -> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). 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. +> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). 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 table itself with \`wmill.ducklake()\` (see "Ingesting from Python / TypeScript") and let the output 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://<warehouse>/<schema>/<name>\`, where \`<warehouse>\` 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://<warehouse>/<schema>/<name>\` fires when the ingestion node completes. @@ -7976,6 +8133,53 @@ Node \`f/sales/orders_daily\` (runs when \`orders\` is produced, writes a rollup SELECT date_trunc('day', ts) AS day, count(*) AS n FROM ducklake.main.orders GROUP BY 1 \`\`\` +`, + "datatables": `--- +name: datatables +description: MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions. +--- + +# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, \`main\` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an \`up\` and an optional \`down\`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in \`BEGIN; ... END;\` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as \`CREATE INDEX CONCURRENTLY\`: such a migration can fail half applied. +- Give it a \`down\` that undoes the \`up\` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + +\`wmill datatable migrate new <name>\` scaffolds \`migrations/datatable/<datatable>/<timestamp>_<name>.up.sql\` and \`.down.sql\`, \`wmill datatable migrate up\` pushes the local files and applies what is pending, \`wmill datatable migrate down\` rolls back the most recent one and \`wmill datatable migrate status\` lists them. All take \`-d <datatable>\` and default to \`main\`. + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (\`CREATE SCHEMA IF NOT EXISTS\`), and point what you are building at it. A raw app names its schema in its \`data\` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. \`admin\` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (\`wmill.datatable('main', { role: 'analyst' })\`, \`role='analyst'\` in Python, \`datatable://main?role=analyst\`) and holds that role alone, or names none and is resolved from the data table's default role; +- \`permission denied\` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to \`admin\` to get past it; +- a migration runs as the role its SQL declares on its first line, \`-- role <name>\`, the same one in its \`down\`. One that declares none runs as \`admin\`, which only those who may use \`admin\` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. `, "cli-commands": `--- name: cli-commands @@ -8014,7 +8218,7 @@ app related commands - \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement - \`--port <port:number>\` - Port to run the dev server on (will find next available port if occupied) - \`--host <host:string>\` - Host to bind the dev server to - - \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) + - \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte, index.tsx otherwise) - \`--no-open\` - Don't automatically open the browser - \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability @@ -8025,7 +8229,7 @@ app related commands - \`app new\` - create a new raw app from a template - \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. - - \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode. + - \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode. - \`--datatable <datatable:string>\` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured. - \`--schema <schema:string>\` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it. - \`--overwrite\` - Overwrite the target directory if it already exists, without prompting. @@ -8711,6 +8915,8 @@ user related commands **Subcommands:** +- \`user list\` - List the users of the workspace + - \`--json\` - Output as JSON (for piping to jq) - \`user add <email:string> [password:string]\` - Create a user - \`--superadmin\` - Specify to make the new user superadmin. - \`--company <company:string>\` - Specify to set the company of the new user. @@ -9061,7 +9267,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9072,7 +9279,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -9160,7 +9368,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9171,7 +9380,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -9244,7 +9454,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9255,7 +9466,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -9365,7 +9577,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9376,7 +9589,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -9528,7 +9742,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9539,7 +9754,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -9697,7 +9913,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9708,7 +9925,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -9823,7 +10041,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9834,7 +10053,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -9921,7 +10141,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -9932,7 +10153,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -10013,7 +10235,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -10024,7 +10247,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -10111,7 +10335,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -10122,7 +10347,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -10262,7 +10488,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -10273,7 +10500,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 @@ -10463,7 +10691,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -10474,7 +10703,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/cli/src/utils/script_common.ts b/cli/src/utils/script_common.ts index 611268c27c..e3deabb6a7 100644 --- a/cli/src/utils/script_common.ts +++ b/cli/src/utils/script_common.ts @@ -189,6 +189,8 @@ const LANG_ANNOTATIONS: Partial<Record<ScriptLanguage, string[]>> = { "py311", "py312", "py313", + "py314", + "py315", "sandbox", ], bun: ["npm", "nodejs", "native", "nobundling", "sandbox"], @@ -208,7 +210,7 @@ const LANG_ANNOTATIONS: Partial<Record<ScriptLanguage, string[]>> = { * * `# py: <specifier>` is the exception the macro does not cover: the python * import parser reads it directly (`windmill-parser-py-imports`, alongside the - * `py310`..`py313` flags) to pick the interpreter, which changes what resolves. + * `py310`..`py315` flags) to pick the interpreter, which changes what resolves. */ export function hasLockAffectingAnnotation( scriptContent: string, diff --git a/cli/test/app_isolation_unit.test.ts b/cli/test/app_isolation_unit.test.ts new file mode 100644 index 0000000000..201c4a52a4 --- /dev/null +++ b/cli/test/app_isolation_unit.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { + finalizeDerivedPolicy, + isolationUpToDate, + markIsolationFromPolicy, + statedIsolation, +} from "../src/commands/app/app.ts"; + +function pushed(file: any, deployed: any, rawApp = true) { + const policy = { ...deployed }; + finalizeDerivedPolicy(policy, "publisher", statedIsolation(file, rawApp), rawApp); + return policy; +} + +test("a pull writes isolation only when it is on, and a push deploys what was pulled", () => { + const off: any = { policy: { sandbox: false, frontend_sdk_scopes: ["jobs:run"] } }; + markIsolationFromPolicy(off, true); + expect("sandbox" in off).toBe(false); + expect("frontend_api" in off).toBe(false); + + const on: any = { policy: { sandbox: true, frontend_sdk_scopes: ["jobs:run"] } }; + markIsolationFromPolicy(on, true); + expect(on.sandbox).toBe(true); + expect(on.frontend_api).toEqual(["jobs:run"]); + // Into a workspace where the app does not exist yet. + const fresh = pushed(on, undefined); + expect(fresh.sandbox).toBe(true); + expect(fresh.frontend_sdk_scopes).toEqual(["jobs:run"]); +}); + +test("the file wins when it states sandbox, the deployed policy carries over when it does not", () => { + const deployed = { sandbox: true, frontend_sdk_scopes: ["jobs:run"] }; + + const silent = pushed({}, deployed); + expect(silent.sandbox).toBe(true); + expect(silent.frontend_sdk_scopes).toEqual(["jobs:run"]); + expect(isolationUpToDate(statedIsolation({}, true), deployed, true)).toBe(true); + + // A file with the key states the scopes too: none here. + const narrowed = pushed({ sandbox: true }, deployed); + expect(narrowed.sandbox).toBe(true); + expect(narrowed.frontend_sdk_scopes).toBeUndefined(); + expect(isolationUpToDate(statedIsolation({ sandbox: true }, true), deployed, true)).toBe(false); + + const opened = pushed({ sandbox: false }, deployed); + expect(opened.sandbox).toBe(false); + expect(opened.frontend_sdk_scopes).toBeUndefined(); +}); + +test("scopes are refused without sandbox, outside the allowlist, or repeated", () => { + expect(() => statedIsolation({ frontend_api: ["jobs:run"] }, true)).toThrow("requires 'sandbox: true'"); + expect(() => statedIsolation({ sandbox: false, frontend_api: ["jobs:run"] }, true)).toThrow( + "requires 'sandbox: true'", + ); + expect(() => statedIsolation({ sandbox: true, frontend_api: ["apps:write"] }, true)).toThrow("Invalid"); + expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run", "jobs:run"] }, true)).toThrow( + "Duplicate", + ); + expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run"] }, false)).toThrow("full-code"); +}); diff --git a/cli/test/app_lint_viewer_identity_unit.test.ts b/cli/test/app_lint_viewer_identity_unit.test.ts new file mode 100644 index 0000000000..f328ffe4e2 --- /dev/null +++ b/cli/test/app_lint_viewer_identity_unit.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +// app.ts first: it is the entry the CLI loads lint.ts through, and loading +// lint.ts ahead of it trips their import cycle. +import "../src/commands/app/app.ts"; +import { viewerIdentityInputWarnings } from "../src/commands/app/lint.ts"; + +const sql = (content: string, fields?: Record<string, any>) => ({ + type: "inline", + inlineScript: { language: "duckdb", content }, + ...(fields ? { fields } : {}), +}); + +test("app lint warns on a SQL viewer-identity argument the server does not fill", async () => { + const content = + "-- $username (text)\n-- $limit (int)\nSELECT * FROM orders WHERE owner = $username LIMIT $limit"; + + const warnings = await viewerIdentityInputWarnings({ + open: sql(content), + filled: sql(content, { username: { ctx: "username" } }), + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Runnable 'open': argument 'username'"); +}); diff --git a/cli/test/init_no_git_sync.test.ts b/cli/test/init_no_git_sync.test.ts index d16c9e771f..189e82562f 100644 --- a/cli/test/init_no_git_sync.test.ts +++ b/cli/test/init_no_git_sync.test.ts @@ -56,8 +56,6 @@ test("Init: createWorkspaceProfile includes defaults when no repositories exist" expect(Array.isArray(workspaceProfile.excludes)).toBeTruthy(); expect(workspaceProfile.excludes.length).toEqual(0); - expect(workspaceProfile.defaultTs).toEqual('bun'); - console.log('Workspace profile correctly includes default sync settings when no repositories exist'); }); @@ -74,8 +72,6 @@ test("Init: verify DEFAULT_SYNC_OPTIONS has expected values", () => { expect(Array.isArray(DEFAULT_SYNC_OPTIONS.excludes)).toBeTruthy(); expect(DEFAULT_SYNC_OPTIONS.excludes.length).toEqual(0); - expect(DEFAULT_SYNC_OPTIONS.defaultTs).toEqual('bun'); - console.log('DEFAULT_SYNC_OPTIONS has expected values'); }); @@ -178,7 +174,7 @@ test.skipIf(shouldSkipOnCI())("Init: --use-default bypasses backend settings che // Should have default settings, not backend settings expect(wmillYaml).toContain("includes:\n - f/**"); - expect(wmillYaml).toContain("defaultTs: bun"); + expect(wmillYaml).not.toContain("defaultTs"); // Should NOT have backend-specific settings expect(wmillYaml.includes("f/should-be-ignored/**")).toEqual(false); diff --git a/cli/test/init_template_unit.test.ts b/cli/test/init_template_unit.test.ts index 227fc271a2..7070de63c8 100644 --- a/cli/test/init_template_unit.test.ts +++ b/cli/test/init_template_unit.test.ts @@ -90,7 +90,6 @@ describe("generateCommentedTemplate", () => { test("default values match expected defaults", () => { const config = parse(generateCommentedTemplate("main")); - expect(config.defaultTs).toBe("bun"); expect(config.skipSecrets).toBe(true); expect(config.nonDottedPaths).toBe(true); expect(config.codebases).toEqual([]); diff --git a/cli/test/permissioned_as_context_unit.test.ts b/cli/test/permissioned_as_context_unit.test.ts new file mode 100644 index 0000000000..33a24bf3a1 --- /dev/null +++ b/cli/test/permissioned_as_context_unit.test.ts @@ -0,0 +1,27 @@ +/** + * Regression guard: a superadmin whose `usr` row in the workspace is not an + * admin still preserves ownership on the backend, so the CLI must not refuse + * their push as if it would reassign every item to them. + */ + +import { expect, test } from "bun:test"; +import { mockServices } from "./mock_services.ts"; + +mockServices({ + whoami: async () => ({ + email: "super@windmill.dev", + username: "super", + is_admin: false, + is_super_admin: true, + groups: [], + }), +}); + +const { buildPermissionedAsContext } = await import( + "../src/core/permissioned_as.ts" +); + +test("a superadmin with a non-admin membership counts as able to preserve", async () => { + const ctx = await buildPermissionedAsContext("w", "v1"); + expect(ctx?.userIsAdminOrDeployer).toBe(true); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json index 022156e5c9..145a75fd26 100644 --- a/cli/wmill.schema.json +++ b/cli/wmill.schema.json @@ -10,7 +10,7 @@ "bun", "deno" ], - "description": "Default TypeScript runtime for new scripts" + "description": "TypeScript runtime of a plain .ts script (bun when absent)" }, "includes": { "type": "array", diff --git a/debugger/README.md b/debugger/README.md index 1dba49d977..040fb4ef67 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -139,7 +139,7 @@ the debug service. Where two names are listed the first wins; a worker reads the | `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | | `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - | | `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - | -| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | +| `PY_NATIVE_CERT` / `UV_SYSTEM_CERTS` | `true` to also trust the platform certificate store (`--system-certs`) | false | | `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | | `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 | @@ -172,7 +172,7 @@ and the container still boots. `INIT_SCRIPT` remains the hook for anything more the CA update it aborts startup when it fails. Note what the system store does *not* cover, which is most of what a debug session installs with: -uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_NATIVE_TLS` is `true`, Bun and Node read +uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_SYSTEM_CERTS` is `true`, Bun and Node read only `NODE_EXTRA_CA_CERTS`, and `requests` carries certifi. Registering the CA fixes Python's stdlib `ssl`, `curl` and `git`; the rest still needs the variables above. diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index ace382528b..1d8d0003da 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -71,7 +71,7 @@ ENV XDG_CACHE_HOME=/pyls/.cache RUN uv pip install --system --break-system-packages pipenv tornado python-lsp-jsonrpc ruff==0.16.0 Cython # Install Node-based language servers -RUN npm install -g diagnostic-languageserver pyright +RUN npm install -g pyright # Setup LSP working directory WORKDIR /pyls diff --git a/docker/DockerfileFull b/docker/DockerfileFull index 8ef6be78bb..c3a82c8312 100644 --- a/docker/DockerfileFull +++ b/docker/DockerfileFull @@ -1,6 +1,9 @@ ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill:dev FROM ${BASE_IMAGE} +# Deno +COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno + # Rust COPY --from=rust:1.97.0 /usr/local/cargo /usr/local/cargo COPY --from=rust:1.97.0 /usr/local/rustup /usr/local/rustup @@ -12,7 +15,9 @@ RUN RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo /usr/local/cargo/b # ansible there leaves its venv interpreter as a dangling symlink at runtime, so every # ansible-* executable fails with ENOENT ("ansible-galaxy not found"). Pin the tool's # interpreter to a persistent image path so the install stays self-contained. -RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true +# The version is explicit because uv otherwise downloads its newest stable Python, which +# ansible-core may not support yet: the install succeeds and every executable fails on import. +RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install --python 3.14 ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true # C# RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ diff --git a/docker/DockerfileFullEe b/docker/DockerfileFullEe index 88ed619c19..bb83195f62 100644 --- a/docker/DockerfileFullEe +++ b/docker/DockerfileFullEe @@ -21,6 +21,9 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ FROM ${BASE_IMAGE} +# Deno +COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno + # Rust COPY --from=rust:1.97.0 /usr/local/cargo /usr/local/cargo COPY --from=rust:1.97.0 /usr/local/rustup /usr/local/rustup @@ -32,7 +35,9 @@ RUN RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo /usr/local/cargo/b # ansible there leaves its venv interpreter as a dangling symlink at runtime, so every # ansible-* executable fails with ENOENT ("ansible-galaxy not found"). Pin the tool's # interpreter to a persistent image path so the install stays self-contained. -RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true +# The version is explicit because uv otherwise downloads its newest stable Python, which +# ansible-core may not support yet: the install succeeds and every executable fails on import. +RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install --python 3.14 ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true # dotnet SDK RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ && chmod +x dotnet-install.sh \ diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 45986bbd5f..ef976f83d3 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -30,6 +30,7 @@ FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app ARG LATEST_STABLE_PY=3.12.13 +ARG NEW_INSTANCE_PY=3.13.16 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -58,13 +59,14 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run # under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 5ac24f5c1d..67de36eac7 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -30,6 +30,7 @@ FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app ARG LATEST_STABLE_PY=3.12.13 +ARG NEW_INSTANCE_PY=3.13.16 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -58,13 +59,14 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run # under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ diff --git a/docker/test_windmill_extra.ts b/docker/test_windmill_extra.ts index 5fd1a74108..c5884c2dd4 100644 --- a/docker/test_windmill_extra.ts +++ b/docker/test_windmill_extra.ts @@ -121,6 +121,60 @@ async function testLspWebSocket(): Promise<void> { }) } +async function testLspShellDiagnostics(): Promise<void> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error('no shellcheck diagnostics within 10s')) + }, 10000) + + const ws = new WebSocket(`ws://${HOST}:${LSP_PORT}/ws/diagnostic`) + const send = (msg: object) => ws.send(JSON.stringify({ jsonrpc: '2.0', ...msg })) + + ws.onopen = () => { + // Editors still send a linter definition; the server's own configuration + // applies, so the diagnostics below have to come from shellcheck. + send({ + id: 1, + method: 'initialize', + params: { + capabilities: {}, + initializationOptions: { + linters: { shellcheck: { command: 'true', sourceName: 'shellcheck' } }, + filetypes: { shell: 'shellcheck' } + } + } + }) + send({ + method: 'textDocument/didOpen', + params: { + textDocument: { + uri: 'file:///tmp/monaco/test.sh', + languageId: 'shell', + version: 1, + text: '#!/bin/bash\necho "$unassigned"\n' + } + } + }) + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data.toString()) + if (msg.method !== 'textDocument/publishDiagnostics') return + clearTimeout(timeout) + ws.close() + const codes = msg.params.diagnostics.map((d: { code: string }) => d.code) + if (codes.includes('SC2154')) resolve() + else reject(new Error(`expected SC2154, got ${JSON.stringify(msg.params.diagnostics)}`)) + } + + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error('LSP WebSocket connection failed')) + } + }) +} + async function runLspTests(): Promise<boolean> { log('\n=== LSP Service Tests (Port ' + LSP_PORT + ') ===') @@ -132,6 +186,7 @@ async function runLspTests(): Promise<boolean> { let passed = true passed = (await runTest('LSP health check', testLspHealth)) && passed passed = (await runTest('LSP WebSocket connection (pyright)', testLspWebSocket)) && passed + passed = (await runTest('LSP shell diagnostics', testLspShellDiagnostics)) && passed return passed } diff --git a/docs/datatable-viewer-roles.md b/docs/datatable-viewer-roles.md new file mode 100644 index 0000000000..89ae3d9d50 --- /dev/null +++ b/docs/datatable-viewer-roles.md @@ -0,0 +1,104 @@ +# Data table connections and whose roles they hold + +A data table under roles (Enterprise Edition) resolves a connection that names a role to that +role alone. A connection that names none is resolved from the data table's default role and the +roles of one **identity**: + +| Default role | A connection naming no role holds | +|---|---| +| `admin` (until changed) | `admin`, as before identity roles existed | +| another role | that role when the identity may use it, plus **every role the identity is a tenant of** | +| none (`NO_DEFAULT_DATATABLE_ROLE`) | the identity's roles only | + +The identity is the job's own, `permissioned_as`, like everything else a job reaches: the +publisher for a publisher-mode app, the owner of a schedule or trigger, whoever ran a script. It +is the job's **viewer** only for an inline runnable of a full-code app whose policy entry sets +`viewer_data_access` (`DatatableConnectAs::Viewer`). Postgres row-level security then answers per +viewer from a runnable that runs as the publisher, with nothing written in the code. + +A connection left with no role at all is refused. Policies test membership, which is true for a +role's own login too, so one policy serves a named connection and an unnamed one: + +```sql +CREATE POLICY emea ON orders FOR SELECT + USING (region = 'emea' AND pg_has_role(current_user, 'sales_emea', 'member')); +``` + +A policy names its role as text, so renaming the role breaks it. + +## Code + +| Where | What | +|---|---| +| `windmill-common/src/workspaces.rs` | `DatatableAccess`, `DatatableConnectAs`, `get_datatable_resource_from_db` | +| `windmill-common/src/datatable_roles_ee.rs` | `resolve_datatable_role_connection`, `resolve_unnamed_login`, `governing_identity`, `ensure_role_set_login`, `retire_role_set_logins_of`, `sweep_unused_role_set_logins` | +| `windmill-api/src/apps.rs` | `execute_component`, which sets `job_perms.viewer_data_access` for a flagged inline runnable | +| `windmill-api-jobs/src/execution.rs` | `run_end_user_email`, where a run's viewer is recorded, and `run_inline_preview_end_user` | +| `frontend/.../raw_apps/ViewerDataAccessPopup.svelte` | the runnable setting, shown when the code reads a data table or object storage | +| `frontend/.../workspaceSettings/DataTablePermissionsButton.svelte` | the roles drawer, which warns while the default is `admin` | + +## What holds it together + +- **A default of `admin` is not a floor.** `admin` owns the tables and bypasses row-level + security, so nothing could be added to it, and it is the default until someone changes it. + Such a data table resolves exactly as it did before identity roles existed. Nothing ever sets a + default back to `admin`: removing or deleting the default role leaves none. +- **One identity decides both the default and the added roles.** The default is held when that + identity may use it, and the roles added are those whose tenants name that same identity. A + job never mixes its own identity's default with its viewer's roles. +- **Connecting as the viewer is the publisher's choice, made per runnable and stored in the + policy.** It is honored on inline runnables only, whose code is deployed with the app: a path + runnable is someone else's script, which would read with the viewer's roles without its author + choosing to. The server stamps `job_perms.viewer_data_access` in the transaction that pushes + the job. Nested jobs do not inherit it; an inline preview run with the job's token + (`run_inline/preview`, what `wmill.datatable()` uses) does, since that code is the job's own. +- **A job that connects as its viewer and has none is refused**, never resolved as the job: that + would undo what the runnable asked for. An anonymous app viewer is the usual case. +- **No role at all is a refusal, never an empty answer.** There is no login that would read + nothing: what a role sees is up to each table's grants and policies. +- **A set login owns what it creates.** Postgres gives a new object to `current_user`, and + default privileges go by the creating role, so a table created over a connection that holds + several roles belongs to that set's login: no other role or identity reaches it, and it passes + to `custom_instance_user` when the login is swept. Unnamed connections are for rows. To create + an object, name a role or run `SET ROLE <role>` first, which a set login may do for any role it + holds. Migrations are not affected: one that declares no role runs as `admin`. +- **The viewer is `job_perms.end_user_email`, and nothing else.** The server records it when the + run starts: the session behind an app request, the caller of a direct run or preview, and for a + job started with another job's token, that job's viewer. No argument reaches it. +- **A cached result belongs to one identity, one viewer and one mode.** `cached_result_path` + hashes the job's `permissioned_as`, its recorded viewer and `viewer_data_access` into the key, + since a cache hit returns before any connection is resolved. Turning the setting on changes no + code, so without the mode a result read with the app's roles would be served to the viewer. +- **Only a tenant list naming the identity counts.** A workspace admin may *use* every role, + `admin` included. The roles added are those whose tenants name the identity (user, group, + folder, `*`), and never `admin`. +- **One role is that role's login. Several are a login per distinct set.** `wm_set_<hash>` is a + member of exactly those roles, with `INHERIT`; the hash is over their catalog ids, so a rename + moves nothing and a change in someone's groups resolves them to another login instead of + editing one. Its privileges are its memberships, so `SET ROLE` only narrows and `RESET ROLE` + comes back to it. The prefix is reserved in `validate_role_name`. +- **A `datatable_role_set` row with a password names a login that can authenticate with it.** The + row is written after the login exists and removed, in its own commit, before the login is + dropped or made `NOLOGIN`. A login without a row is taken back with a new password on next use, + and so is one whose row has an empty password: that is what a sweep leaves when it could + neither drop a login nor let it back in, so the next sweep still finds it. +- **Set logins are bounded by use, not by the number of role combinations.** One exists only for + a set some identity actually connected with, so there are at most as many as (identity, data + table) pairs. `last_used_at` is recorded at most once a day, and `sweep_unused_role_set_logins`, + run from the monitor loop, drops those unused for 30 days; a dropped one is made again on + demand. The sweep goes by this instance's rows, never by the cluster's `wm_set_*` roles: a + cluster can serve several Windmill instances. +- **Disabling or dropping a role retires every set login holding it.** `NOLOGIN` on a role does + not reach a login that is a member of it. +- **Mixed fleets.** A worker or server older than v1.831 connects as the default role alone, and + refuses a data table with no default role, whose marker it reads as a role the data table + lacks. One from v1.831 or v1.832 adds the recorded viewer's roles to every job that has one, + flagged or not, and the job's own roles to none. + +## Trust + +A job that names no role reads with its own identity's roles, the same trust as the resources and +variables it reaches: every viewer of a publisher-mode app sees what the publisher's roles see. +An inline runnable that connects as its viewer runs its publisher's code with the viewer's reach +into the data table, the same trust as an app in viewer execution mode. Naming a role in the code +pins the connection to that one role either way. diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md index 4db2a69511..8ace42a1e5 100644 --- a/docs/operator-builder-rights.md +++ b/docs/operator-builder-rights.md @@ -1,13 +1,25 @@ # Operator builder rights -`operator_settings.builder_flows` lets every operator of a workspace compose flows out of runnables -that already exist. It does not make them authors: the boundary the operator role draws is -**authoring code and running arbitrary code**, and this does not move it. +Two workspace settings, `operator_settings.builder_flows` and `operator_settings.builder_apps`, +that let every operator of that workspace compose flows and full-code apps out of runnables that +already exist. Neither lets them author backend code or run arbitrary code on a worker, the +boundary the operator role draws. The app right does let them write the app's frontend code, +which runs in the viewer's browser and is why that right rests on sandbox isolation. -It is a write right, unlike the visibility flags beside it, and unlike the withdrawable rights in -`docs/operator-write-rights.md` it is granted on request and costs a seat. Read it with -`windmill_common::workspaces::operator_can_build_flows` (60s cache, shared with the withdrawable -rights) and gate a write with `check_operator_can_build_flows`. +They are granted **independently**, because the two are not equally verifiable. A composed flow is +checked in full server-side (`check_flow_is_composition_only` refuses everything carrying code). +A full-code app is a browser-built bundle no server-side check can read, and rests instead on +forced `sandbox` isolation plus the viewer consent prompt. An admin may reasonably want the first +without the second, so **never gate on "either"**: a gate that does authorizes the kind its +workspace never granted, which is the whole point of the split. `BuilderKind` exists to make each +call site say which one it means. + +These are write rights, unlike the visibility flags beside them, and unlike the withdrawable +rights in `docs/operator-write-rights.md` they are granted on request and cost a seat. Read them +with `windmill_common::workspaces::operator_builder_rights` (60s cache, shared with the +withdrawable rights), which returns an `OperatorBuilderRights { flows, apps }`, and gate a write +with `check_operator_can_build`, naming the `BuilderKind`. `.any()` is only for surfaces that are +genuinely not per-kind: the seat a membership costs, and listing drafts. ## What the check has to cover @@ -49,25 +61,131 @@ Call it on every write **and** every preview: `run_preview_flow_job` and it the way to run what the write path refuses. A flow draft goes through `validate_operator_flow` too: a developer who loads a builder's draft in the editor runs its code as themselves. +The same reasoning applies to a builder-authored app, with one extra step. `execute_component` +resolves the runnable it runs on the root handle, so `validate_operator_composed_app` checks every +referenced path under the caller's RLS and refuses hub ones. For a script it checks the version a +run would pick: grants are per version, and a path reused after its versions were archived starts +with none while the archived ones keep theirs. But it has to check **two** surfaces, +because they are not the same list: the policy's `script/<path>` and `flow/<path>` triggerables, +and the by-path entries of the app value's `runnables` map, which is what the editor resolves a +`runnable_id` against and sends. Read only that map, and refuse an entry whose `type`, `path` or +`runType` is not text. + +Reading a triggerable key is not a `split_once(':')`: `execute_component` looks up +`format!("{component}:{path}")` with an unrestricted component string, so `a:b:script/x` resolves +at run time for `component = "a:b"`. Every colon is a possible split, so the check validates every +suffix that parses as a runnable rather than guessing which one a request will use. + +What makes those checks bind is that **`ExecutionMode::Viewer` is refused for a builder app**. In +Viewer mode `execute_component` falls back to a default triggerable for any `script/`/`flow/` +path, so the policy stops being the list of what the app may invoke, and the job runs as the +*viewer*: an admin who merely opened the app would run anything in the workspace as themselves. +`Publisher`, `Guest` and `Anonymous` have no such fallback; all three run as the publisher. If you ever relax the Viewer refusal, the +deploy-time path checks above stop being an authorization boundary. + +## The two raw-app deploy paths are not equivalent + +`create_app_raw` / `update_app_raw` are multipart: the browser already built the bundle, and +nothing server-side compiles anything. Builders use these. + +`create_app_raw_source` / `update_app_raw_source` push a bundler CLI over caller-supplied `files` +as a job **on a worker**, which is arbitrary code execution and is why they already require +`jobs:run` on top of `apps:write`. They stay closed to operators, builder rights or not. Do not +"tidy" the exception away: it costs builders nothing, because the browser and the CLI both bundle +locally and deploy through the multipart endpoints. + +A builder-authored app is forced to `policy.sandbox = true`. That is what makes it safe to let an +operator publish a bundle nobody reviewed: without it the bundle runs same-origin with each +viewer's Windmill session. The same check refuses a `rawscript/<sha>` key in either triggerables +map, since that key is the deployed app's authorization to run caller-supplied `raw_code` hashing +to it. It reads the value as stored, NUL escapes stripped: a stripped NUL can turn a key it +ignored into `inlineScript`. + +`update_app` takes a builder too, but settings only: the deploy panel saves a deployed full-code +app's access and frontend scopes through it with no value. It reaches the same check, so a builder +can no more switch the sandbox off there than on deploy. + +A builder's **draft** goes through the same `check_operator_composed_app` as its deploy, with +`BuilderAppWrite::Draft`: an admin may open it in the editor or deploy it as it stands, so any +rule added for deploys must hold for drafts too. Where the two differ, `write` decides: a deploy +fills in an omitted sandbox or mode, since it stores the policy it checked, while a draft skips +`triggerables_v2` and must not name another user's run identity. Every frontend writer of a +builder draft must conform the policy first with `conformBuilderAppPolicy`, since a policy loaded +from someone else's app breaks these rules: the raw-app editor component does, which covers both +the standalone editor and the AI Sessions one, and so does the AI chat. + +The raw-app editor previews an app isolated only when its policy has `sandbox`, and otherwise runs +the bundle same-origin with the session of whoever opens it. A builder's draft is therefore +held to the same rule as its deploy: `update_draft` refuses one whose `policy.sandbox` is not +`true`, so whatever an admin opens of an operator's, deployed or drafted, previews isolated. +Turning the sandbox off in their own editor stays that admin's explicit choice. It also refuses +a draft naming a runnable the builder cannot read, or a hub one, like the deploy: the editor +preview runs them as whoever opens it, and admits any path for an admin. That covers the +policy's legacy `triggerables` too, `rawscript/` keys included: every deploy of a draft +carries it through untouched, so an admin deploying the draft would publish it. Its +`triggerables_v2` is not checked: every deploy of a draft rebuilds it from the runnables, the +preview never reads it, and a copy gone stale since the last deploy would otherwise block +autosave. `Viewer` mode is refused in a draft as on deploy: the deploy panel shows it as +members-only, so an admin deploying the draft would publish it unawares. The builder's editor +switches it to `Publisher` on load, since that panel offers no way out of it. A draft naming a +run identity other than its builder is refused for the same reason: a builder's own deploy resets +it, but an admin's one-click draft deploy preserves it without showing it. The builder's editor +drops another user's identity on load, which an app that user deployed carries. A builder in +`wm_deployers` is the exception: like any member, their deploy may keep another identity, which +was judged acceptable for operators in that group (WIN-2536) and is the same for composed flows. + +Reading a triggerable key at every colon copies it once per colon, so the check refuses a key +over 512 bytes before splitting: a quadratic walk otherwise lets a sub-megabyte key allocate +gigabytes. A 255-character path plus a component name stays well under it. + ## Billing -An operator of a builder workspace consumes a full author seat: composing deployable artifacts -makes them an author, and there is no half-author. `consumes_operator_seat` is the seat-role -helper; the EE counting queries share `OPERATOR_SEAT_SQL` so the displayed, enforced and reported -numbers agree. The one exception is `get_user_usage` in `stats_ee.rs`: it is a compile-checked -`query!`, which cannot interpolate the constant, so it spells the predicate out. Change both -together. +An operator of a builder workspace consumes a full author seat. This is the one place that reads +"either right", and deliberately so: either one makes an operator an author of deployable +artifacts, and there is no half-author, so granting both costs no more than granting one. Do not +introduce per-right pricing — it turns one boolean into a capability matrix that billing, the cap +check and the displayed count would each have to agree on. -On cloud, `billable_seats` applies the same rule; see its doc for the out-of-repo invoice it must -match. +`consumes_operator_seat` is the seat-role helper; the EE counting queries share +`OPERATOR_SEAT_SQL` so the displayed, enforced and reported numbers agree. The one exception is +`get_user_usage` in `stats_ee.rs`: it is a compile-checked `query!`, which cannot interpolate the +constant, so it spells the predicate out. Change both together. On cloud, `billable_seats` applies +the same rule; see its doc for the out-of-repo invoice it must match. -Granting the right runs `check_seat_cap_for_operator_builder`, which prices the change by counting -seats twice rather than by counting the workspace's operators: an operator who already authors -elsewhere must not be charged again, so re-saving settings that already have the right on is a -zero delta and never blocks. +Granting the first right runs `check_seat_cap_for_operator_builder`, which prices the change by +counting seats twice rather than by counting the workspace's operators: an operator who already +authors elsewhere must not be charged again. Adding the second right later, or re-saving settings +that already have one on, is a zero delta and never blocks. ## Accepted risks +- A builder raw app may declare `frontend_sdk_scopes`, and `mint_raw_app_sdk_token` mints as the + *viewer*. A consenting admin therefore hands the bundle a 12h admin-identity token within the + curated scope list. The viewer consent prompt is the gate, shown by the editor preview as well + as the deployed app, before any token is minted. The lever, if this is ever revisited, + is dropping `variables:read` / `resources:read` / `jobs:run` from `FRONTEND_SDK_ALLOWED_SCOPES` + for builder apps. +- The raw-app editor preview runs an app's runnables as whoever has the editor open, where the + deployed app runs them as its publisher. An admin opening a builder's app in the editor lets its + frontend code run, with the admin's rights, any runnable the builder can read. Running as the + draft's author instead would need a preview that ignores unsaved edits to the runnable list. +- A builder may make an app public (`ExecutionMode::Anonymous`): anyone with the URL runs the + runnables its policy names, as the builder. Refusing it is the workspace's call, through the + `RestrictAnonymousAppDeployment` protection rule. A `Guest` app is the same with a login in + front: anyone the instance's identity provider admits, member or not, once guest access is + enabled. `RestrictGuestAppDeployment` refuses it. +- A builder raw app may set `allow_user_resources` on a runnable's inputs, and the bundle picks the + `$res:` path it sends. `build_args` resolves that path as the *viewer*, with no prompt, into a + job that runs as the builder. Getting a value out takes a guessable path and a readable script + that returns, logs or sends its input. Developers can do the same; the lever is refusing a + non-empty `allow_user_resources` (and `s3_inputs[].allow_user_resources`) for builders. +- A builder's delete or settings-only update reads the app's kind before the write, not under a + lock held through it. An admin converting that app to low-code in between lets the builder's + request land on the low-code app; the builder already has write access to its path. +- A builder's draft carries no run identity other than the builder's own, and deploying one that + carries none makes the deployer its run identity, as any deploy does. An admin deploying such a + draft, from the editor or Compare & Deploy, therefore publishes the builder's code running as + that admin; the deploy is the admin's explicit act, as it is for a developer's draft. - All-or-nothing per workspace: there is no per-user builder role. - `operator_settings` is git-synced, so a pull can flip every operator's class in a workspace and the billed seat count with it. diff --git a/docs/pipeline-local-dev.md b/docs/pipeline-local-dev.md index e05acec9f0..fb8d49b296 100644 --- a/docs/pipeline-local-dev.md +++ b/docs/pipeline-local-dev.md @@ -69,7 +69,7 @@ makes the client own the whole cascade so the backend dispatcher never double-fi - `frontend/src/lib/components/assets/AssetGraph/cascadeRun.ts` — reusable run primitives (`makeLaunch`, `makeWaitJobTerminal`, `runDownstreamCascade`, `runBoundedCascade`) wrapping `cascadeOrchestrator` + `graphTraversal`. -- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/pipeline_dev/+page.svelte`. +- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/(styled)/pipeline_dev/+page.svelte`. **Modified** - `cli/src/commands/pipeline/pipeline.ts` — `--local` on `show`/`run`; `show` split into graph diff --git a/flake.nix b/flake.nix index f005644e3d..2a1d2699c8 100644 --- a/flake.nix +++ b/flake.nix @@ -78,15 +78,16 @@ ]; # --------------------------------------------------------------- - # Prebuilt V8 binary (must match version in Cargo.toml) + # Prebuilt V8 binary (must match the `v8` pin in backend/Cargo.toml). + # The simdutf variant: deno_core enables that feature of the v8 crate. # --------------------------------------------------------------- rustyV8Archive = let - version = "137.1.0"; + version = "150.4.0"; target = stdenv.hostPlatform.rust.rustcTarget; sha256 = { x86_64-linux = - "sha256-Tiscfy2bzYGR3s0T+SC1IB3xWvTVpVcSEdjq3MCRoRw="; + "sha256-9IdiyhDR8fxgWkQcWuQw7Izh6egPFNePvELLh4wwtHY="; aarch64-linux = lib.fakeHash; x86_64-darwin = lib.fakeHash; aarch64-darwin = lib.fakeHash; @@ -94,7 +95,7 @@ in pkgs.fetchurl { name = "librusty_v8-${version}"; url = - "https://github.com/denoland/rusty_v8/releases/download/v${version}/librusty_v8_release_${target}.a.gz"; + "https://github.com/denoland/rusty_v8/releases/download/v${version}/librusty_v8_simdutf_release_${target}.a.gz"; inherit sha256; }; diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts index db27741a5a..6d10c53d92 100644 --- a/frontend/e2e/global-setup.ts +++ b/frontend/e2e/global-setup.ts @@ -47,18 +47,10 @@ async function globalSetup(config: FullConfig) { await page.waitForURL('**/') if (!process.env.SKIP_HUB_SYNC) { - await page.locator('#home-search-input').waitFor({ state: 'visible' }) - await page.locator('#home-search-input').fill('u/admin/hub_sync') - - const hubSyncBtn = page.locator('text=u/admin/hub_sync').first() - await hubSyncBtn.waitFor({ state: 'visible' }) - await hubSyncBtn.click() - - const runBtn = page.locator('#run-form-run-button') - await runBtn.waitFor({ state: 'visible' }) - await runBtn.click() - await page.waitForURL(/\/run\/.+/) - await page.locator('text=Success').waitFor({ timeout: 30000, state: 'visible' }) + const res = await page.request.post(`${baseURL}/api/settings/sync_cached_resource_types`) + if (!res.ok()) { + throw new Error(`Resource type sync failed: ${await res.text()}`) + } } // Save the authenticated state await context.storageState({ path: './e2e/auth.json' }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8ea3dece4..6b0974c793 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.826.0", + "version": "1.833.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.826.0", + "version": "1.833.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -95,19 +95,19 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", + "windmill-parser-wasm-py": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.695.0", - "windmill-parser-wasm-wac": "1.668.6", + "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "1.8.2", @@ -14644,9 +14644,9 @@ } }, "node_modules/windmill-parser-wasm-asset": { - "version": "1.753.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.753.0.tgz", - "integrity": "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.830.0.tgz", + "integrity": "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw==" }, "node_modules/windmill-parser-wasm-csharp": { "version": "1.510.1", @@ -14674,9 +14674,9 @@ "integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg==" }, "node_modules/windmill-parser-wasm-py": { - "version": "1.782.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.782.0.tgz", - "integrity": "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.830.0.tgz", + "integrity": "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ==" }, "node_modules/windmill-parser-wasm-r": { "version": "1.668.1", @@ -14704,9 +14704,9 @@ "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" }, "node_modules/windmill-parser-wasm-wac": { - "version": "1.668.6", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.668.6.tgz", - "integrity": "sha512-/ovcLWlIO+TQMrnWwcWoXquJI6hTZ+Zpo4POhV8z6e+pb4cVModGysASI9mERn2e9uj8/xSRmYqk54TLG8oUbQ==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.830.0.tgz", + "integrity": "sha512-M67qPkzDnuMNWGrZTj9V4ajDoCQrTsQJ89UMHq308t5CtCdtiX8Z3freL+8SoRAG/dVlqQ8roK6dzncHIEM0hQ==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.770.0", diff --git a/frontend/package.json b/frontend/package.json index 259a83b5da..42b99101dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.826.0", + "version": "1.833.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", @@ -161,19 +161,19 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", + "windmill-parser-wasm-py": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.695.0", - "windmill-parser-wasm-wac": "1.668.6", + "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "1.8.2", diff --git a/frontend/scripts/fix-svelte-module-imports.js b/frontend/scripts/fix-svelte-module-imports.js index 9cec6265a0..fe4fde4959 100644 --- a/frontend/scripts/fix-svelte-module-imports.js +++ b/frontend/scripts/fix-svelte-module-imports.js @@ -16,9 +16,22 @@ // tarball. Copy the registry into the package and rewrite those imports // to point at it (mirrors how package-system-prompts.js bundles // `$system_prompts`). +// +// 3. Chat SDK: the `windmill-chat` alias (svelte.config.js) points at +// `chat-sdk/src/index.ts`, which escapes the package root the same way. +// Compile that entry point into the package and rewrite those imports. +// +// 4. `$lib/utils` from inside `utils/`: svelte-package turns it into `./`, +// which is the `utils/` directory (no index) rather than `utils.js`. +// Point a directory specifier with no index at its sibling module. +// +// A relative import that still escapes the package root, or an +// `import.meta.glob` left on an alias, fails the build: either one +// publishes fine and only breaks in the consumer's bundler. +import { execFileSync } from 'child_process' import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs' import { fileURLToPath } from 'url' -import { dirname, join, relative, resolve } from 'path' +import { dirname, join, relative, resolve, sep } from 'path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -29,8 +42,37 @@ const oauthConnectDest = join(packageDir, 'oauth_connect.json') copyFileSync(oauthConnectSrc, oauthConnectDest) -const IMPORT_SPECIFIER_RE = /(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.\.?\/[^'"]*\.(?:svelte|json))\2/g +const chatSdkSrc = resolve(__dirname, '..', '..', 'chat-sdk', 'src', 'index.ts') +const chatSdkDest = join(packageDir, 'chat-sdk') + +// File arguments make tsc ignore every tsconfig and emit only what the entry +// point reaches, which leaves out the React and AI SDK adapters. +execFileSync( + 'npx', + [ + 'tsc', + chatSdkSrc, + '--outDir', + chatSdkDest, + '--rootDir', + dirname(chatSdkSrc), + '--declaration', + '--target', + 'ES2021', + '--module', + 'ESNext', + '--moduleResolution', + 'bundler', + '--skipLibCheck' + ], + { stdio: 'inherit', cwd: resolve(__dirname, '..') } +) + +const IMPORT_SPECIFIER_RE = /(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.\.?(?:\/[^'"]*)?)\2/g const OAUTH_CONNECT_RE = /^(\.\.\/)+backend\/oauth_connect\.json$/ +const CHAT_SDK_RE = /^(\.\.\/)+chat-sdk\/src\/index\.ts$/ +const DIRECTORY_RE = /(^|\/)\.\.?$|\/$/ +const ALIASED_GLOB_RE = /import\.meta\.glob(?:<[^>]*>)?\(\s*\[?\s*['"]\$/ function* walk(dir) { for (const entry of readdirSync(dir)) { @@ -50,9 +92,13 @@ function relativeSpecifier(fromFile, toFile) { let rewrittenFiles = 0 let rewrittenImports = 0 +const unresolvable = [] for (const file of walk(packageDir)) { const content = readFileSync(file, 'utf-8') + if (ALIASED_GLOB_RE.test(content)) { + unresolvable.push(`${relative(packageDir, file)}: import.meta.glob on an alias`) + } let changed = false const updated = content.replace(IMPORT_SPECIFIER_RE, (match, prefix, quote, specifier) => { if (OAUTH_CONNECT_RE.test(specifier)) { @@ -60,10 +106,27 @@ for (const file of walk(packageDir)) { rewrittenImports++ return `${prefix}${quote}${relativeSpecifier(file, oauthConnectDest)}${quote}` } + if (CHAT_SDK_RE.test(specifier)) { + changed = true + rewrittenImports++ + return `${prefix}${quote}${relativeSpecifier(file, join(chatSdkDest, 'index.js'))}${quote}` + } + const target = resolve(dirname(file), specifier) + if (target !== packageDir && !target.startsWith(packageDir + sep)) { + unresolvable.push(`${relative(packageDir, file)}: ${specifier}`) + return match + } + if (DIRECTORY_RE.test(specifier)) { + if (!existsSync(join(target, 'index.js')) && existsSync(target + '.js')) { + changed = true + rewrittenImports++ + return `${prefix}${quote}${relativeSpecifier(file, target + '.js')}${quote}` + } + return match + } if (!specifier.endsWith('.svelte')) { return match } - const target = resolve(dirname(file), specifier) // A real `.svelte` component file: leave the import alone. if (existsSync(target)) { return match @@ -82,6 +145,14 @@ for (const file of walk(packageDir)) { } } +if (unresolvable.length > 0) { + console.error( + `fix-svelte-module-imports: ${unresolvable.length} import(s) a consumer cannot resolve:\n` + + unresolvable.join('\n') + ) + process.exit(1) +} + console.log( `fix-svelte-module-imports: rewrote ${rewrittenImports} import(s) in ${rewrittenFiles} file(s)` ) diff --git a/frontend/src/app.html b/frontend/src/app.html index aad967fd91..3c9e232e0f 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -12,6 +12,17 @@ fill: #3b82f6; } + /* Public app pages render their shell before app.css loads, if at all. */ + html[data-wm-public-app] #svelte-global-loader { + display: none; + } + html[data-wm-public-app] body { + margin: 0; + } + html[data-wm-public-app='dark'] body { + background-color: #3b4252; + } + /* Background color for dark mode */ @media (prefers-color-scheme: dark) { #mainbg { @@ -123,6 +134,64 @@ } })() </script> + <script> + // Public app pages (`/public/<ws>/<secret>`, `/a/<custom path>`): skip the loading + // screen, and start the API calls the page makes first so they overlap the module + // download instead of following it. `publicAppApi.ts` takes these responses by URL, + // so each URL here must be exactly what it requests. Only the cookie credential is + // prefetched: a share link carrying a JWT, or the opaque viewer frame (`wm_embed`), + // sends its own and fetches normally. The bundle of an unsandboxed raw app is + // requested as soon as its secret is known, for the iframe to find in the HTTP cache. + ;(function () { + var path = location.pathname + var pub = /^\/public\/([^/]+)\/([^/]+)\/?$/.exec(path) + var custom = /^\/a\/(.+?)\/?$/.exec(path) + if (!pub && !custom && !/^\/public\//.test(path)) return + // Dark as PublicApp decides it, painted before any stylesheet so that a dark app + // does not load over a white page. + var dark = false + try { + var mode = localStorage.getItem('dark-mode') + dark = mode ? mode === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches + } catch (_) {} + document.documentElement.setAttribute('data-wm-public-app', dark ? 'dark' : '') + if (new URLSearchParams(location.search).get('wm_embed') === '1') return + var prefetched = (window.__wmPublicAppPrefetch = {}) + function get(url) { + var res = fetch(url) + res.catch(function () {}) + prefetched[url] = res + return res + } + function warmBundle(appRes, workspace) { + appRes + .then(function (r) { + return r.ok ? r.clone().json() : null + }) + .then(function (app) { + if (!app || !app.raw_app || !app.bundle_secret || (app.policy || {}).sandbox) return + var ws = workspace || app.workspace_id + var v = '/api/w/' + ws + '/apps_u/get_data/v/' + app.bundle_secret + fetch(v + '.css').catch(function () {}) + fetch(v + '.js').catch(function () {}) + }) + .catch(function () {}) + } + if (pub) { + var w = '/api/w/' + pub[1] + get(w + '/apps_u/embed_token/' + pub[2]) + warmBundle(get(w + '/apps_u/public_app/' + pub[2]), pub[1]) + get(w + '/apps_u/guest_entry/' + pub[2]) + get(w + '/users/whoami') + } else if (custom && custom[1].indexOf('.') === -1) { + // A `.` marks a trailing JWT segment, which no custom path contains. + get('/api/apps_u/embed_token_by_custom_path/' + custom[1]) + warmBundle(get('/api/apps_u/public_app_by_custom_path/' + custom[1])) + get('/api/apps_u/guest_entry_by_custom_path/' + custom[1]) + } + get('/api/ee_license') + })() + </script> %sveltekit.head% </head> <body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none"> diff --git a/frontend/src/lib/apiBaseUrl.svelte.ts b/frontend/src/lib/apiBaseUrl.svelte.ts new file mode 100644 index 0000000000..242a6aa9e0 --- /dev/null +++ b/frontend/src/lib/apiBaseUrl.svelte.ts @@ -0,0 +1,21 @@ +import { fromStore } from 'svelte/store' +import { instanceUi } from '$lib/instanceUi' + +const ui = fromStore(instanceUi) + +/** + * Base of every URL shown for an external client to call (webhooks, HTTP routes, push + * trigger endpoints, the CLI remote): the `api_base_url` instance setting, else the + * origin being browsed. Append `/api/...` to it. + * + * Not for URLs the editor itself calls or opens (captures, previews, OAuth popups, links + * to pages): those must stay on the browsing origin, where the session cookie lives. + * + * Reactive: the setting lands after page load, so read it inside a `$derived` or the + * template rather than caching the result. + */ +export function apiBaseUrl(): string { + const setting = ui.current?.api_base_url + const configured = typeof setting === 'string' ? setting.trim() : '' + return configured || window.location.origin +} diff --git a/frontend/src/lib/base.ts b/frontend/src/lib/base.ts deleted file mode 100644 index 0e8f2d71f6..0000000000 --- a/frontend/src/lib/base.ts +++ /dev/null @@ -1 +0,0 @@ -export let base: string = import.meta.env.VITE_BASE_URL ?? '' diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte index 7f2a90e621..3d671f46c8 100644 --- a/frontend/src/lib/components/AgentTrace.svelte +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import { ExternalLink, Globe } from 'lucide-svelte' import { JobService, type Job } from '$lib/gen' - import { base } from '$lib/base' import { msToReadableTimeShort } from '$lib/utils' import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte' import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte' @@ -141,7 +140,7 @@ {:else if entry.jobId} <a class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline" - href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $operatingWorkspace}" + href="/run/{entry.jobId}?workspace={workspaceId ?? $operatingWorkspace}" target="_blank" rel="noreferrer" > diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index bea6023073..4dc05972ca 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -25,7 +25,7 @@ import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry' import { createEventDispatcher, onDestroy, tick } from 'svelte' import Path from './Path.svelte' - import { ListRow, RadioCard, Skeleton } from './common' + import { Badge, ListRow, RadioCard, Skeleton } from './common' import { useListHighlight } from './common/listRow/listHighlight.svelte' import ApiConnectForm from './ApiConnectForm.svelte' import SearchItems from './SearchItems.svelte' @@ -33,7 +33,6 @@ import { sendUserToast } from '$lib/toast' import OauthScopes from './OauthScopes.svelte' import autosize from '$lib/autosize' - import { base } from '$lib/base' import Required from './Required.svelte' import Toggle from './Toggle.svelte' import { Pen, Search } from 'lucide-svelte' @@ -323,6 +322,15 @@ (registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false)) ) + /** The provider also has a browser sign-in, unavailable here only because the + * instance holds no OAuth client for it */ + let signInNeedsInstanceClient = $derived.by( + () => + (registryEntry()?.grant_types?.includes('authorization_code') ?? false) && + connectClient != '' && + !(connects?.includes(connectClient) ?? false) + ) + /** Clear CC inputs and scopes so a previous selection never leaks into a new one */ function resetClientCredentialsState() { supportsClientCredentials = false @@ -1400,11 +1408,7 @@ </div> {#if apiTokenApps[resourceType].img} <div class="mt-4 w-full overflow-hidden"> - <img - class="m-auto max-h-60" - alt="connect" - src={base + apiTokenApps[resourceType].img} - /> + <img class="m-auto max-h-60" alt="connect" src={apiTokenApps[resourceType].img} /> </div> {/if} {/if} @@ -1515,14 +1519,14 @@ {#if supportsClientCredentials} <div class="flex flex-col gap-1"> <h3 class="text-sm font-semibold text-emphasis mb-1">Authentication</h3> - {#if ccOnly || ccBringYourOwn} + {#if (ccOnly || ccBringYourOwn) && !signInNeedsInstanceClient} <div class="text-xs text-secondary font-normal mb-2"> {#if useSharedInstanceCreds} {resourceType} connects server-to-server using the credentials configured for this - instance. The token is acquired and refreshed automatically. + instance. {:else} - {resourceType} connects server-to-server. Enter a client ID and secret; the token is - acquired and refreshed automatically. + {resourceType} connects server-to-server with the client credentials grant. Enter a + client ID and secret. {/if} </div> {:else} @@ -1535,17 +1539,24 @@ > <RadioCard label={`Sign in through ${resourceType}`} - description="Opens a browser window to log in and authorize. Connects as you." + description={signInNeedsInstanceClient + ? `Authorization code grant. Needs an OAuth client for ${resourceType} in the instance settings, added by a superadmin.` + : 'Authorization code grant. Opens a browser window to log in and authorize. Connects as you.'} selected={!useClientCredentials} onSelect={selectAuthCodeGrant} + disabled={signInNeedsInstanceClient} + badge={signInNeedsInstanceClient ? setupNeededBadge : undefined} /> + {#snippet setupNeededBadge()} + <Badge verySmall class="px-2">Instance setup needed</Badge> + {/snippet} <RadioCard label={useSharedInstanceCreds ? 'Use the configured instance credentials' : 'Use a client ID and secret'} description={useSharedInstanceCreds - ? "Runs server-to-server with this instance's credentials. No input needed." - : 'Runs server-to-server. Best for automation or service accounts.'} + ? "Client credentials grant. Runs server-to-server with this instance's credentials. No input needed." + : 'Client credentials grant. Runs server-to-server. Best for automation or service accounts.'} selected={useClientCredentials} onSelect={() => enableClientCredentials()} /> diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 7511fc3576..ce64d7d231 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -41,7 +41,6 @@ import MultiSelect from './select/MultiSelect.svelte' import { safeSelectItems } from './select/utils.svelte' import S3ArgInput from './common/fileUpload/S3ArgInput.svelte' - import { base } from '$lib/base' import { getJsonSchemaFromResource } from './schema/jsonSchemaResource.svelte' import AIProviderPicker from './AIProviderPicker.svelte' import AiDecisionQuestionsEditor from './AiDecisionQuestionsEditor.svelte' @@ -609,7 +608,7 @@ {#if value && typeof value == 'string' && value?.startsWith('$res:')} Linked to resource <a target="_blank" - href="{base}/resources#/resource/{value.slice(5)}" + href="/resources#/resource/{value.slice(5)}" class="text-blue-500 underline" >{value.slice(5)} <span class="inline-block -mb-0.5"><ExternalLink size={14} /></span></a > diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index f211650df2..d1dd5fa08b 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -97,7 +97,8 @@ 'docusign', 'salesforce', 'outreach', - 'notion' + 'notion', + 'hubspot' ] // Providers whose registry entry (`backend/oauth_connect.json`) carries a // `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 76dc030c58..1e318ed9b2 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -142,7 +142,13 @@ let hintTimer: ReturnType<typeof setTimeout> | undefined const kindLabel = $derived( - itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script' + itemKind === 'flow' + ? 'flow' + : itemKind === 'app' || itemKind === 'raw_app' + ? 'app' + : itemKind === 'data_pipeline' + ? 'pipeline' + : 'script' ) // Each false → true transition fires a hint (others > loaded). Seeded to diff --git a/frontend/src/lib/components/BreadcrumbItemContent.svelte b/frontend/src/lib/components/BreadcrumbItemContent.svelte new file mode 100644 index 0000000000..9e945586c7 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbItemContent.svelte @@ -0,0 +1,16 @@ +<!-- +@component +The inside of one breadcrumb part: an optional leading icon and the label. + +Every part of the header's breadcrumb renders this, whether its dropdown is a melt menu or a +popover, so they share one look. Separators are the host's job — a part must not contain the +slash before it, or hovering the part would highlight the separator too. +--> +<script lang="ts"> + import type { Snippet } from 'svelte' + + let { label, icon }: { label: string; icon?: Snippet } = $props() +</script> + +{@render icon?.()} +<span class="truncate">{label}</span> diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte index 8a266e4fc4..4d55675f26 100644 --- a/frontend/src/lib/components/BreadcrumbSegment.svelte +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to close siblings. --> <script lang="ts"> - import { ChevronRight } from 'lucide-svelte' + import BreadcrumbItemContent from './BreadcrumbItemContent.svelte' import Popover from '$lib/components/meltComponents/Popover.svelte' import WorkspaceItemDrillPicker, { type Scope @@ -15,7 +15,8 @@ close siblings. interface Props { label: string - withChevron?: boolean + /** Leading glyph, e.g. the folder or user a path scope names. */ + icon?: import('svelte').Snippet extraClass?: string disabled?: boolean /** Where the picker lands when this segment is clicked. */ @@ -33,7 +34,7 @@ close siblings. let { label, - withChevron = false, + icon, extraClass = '', disabled = false, initialScope, @@ -71,8 +72,7 @@ close siblings. {disabled} > {#snippet trigger()} - {#if withChevron}<ChevronRight size={10} class="shrink-0" /><span class="truncate">{label}</span - >{:else}{label}{/if} + <BreadcrumbItemContent {label} {icon} /> {/snippet} {#snippet content()} <WorkspaceItemDrillPicker diff --git a/frontend/src/lib/components/CenteredModal.svelte b/frontend/src/lib/components/CenteredModal.svelte index 08234c25b1..3e922e73c7 100644 --- a/frontend/src/lib/components/CenteredModal.svelte +++ b/frontend/src/lib/components/CenteredModal.svelte @@ -16,6 +16,9 @@ containOverflow?: boolean /** The Windmill version and update notice in the header. */ showVersion?: boolean + /** Set on the pages that render inside the app shell, under the page header: there the + * window is not the card's box, and taking the whole of it pushes 44px past the bottom. */ + inPage?: boolean children?: import('svelte').Snippet } @@ -28,6 +31,7 @@ loading = false, containOverflow = false, showVersion = true, + inPage = false, children }: Props = $props() @@ -37,7 +41,9 @@ </script> <div - class="flex justify-center h-screen p-4 relative bg-surface-secondary {containOverflow + class="flex justify-center {inPage + ? 'h-full' + : 'h-screen'} p-4 relative bg-surface-secondary {containOverflow ? 'overflow-hidden' : 'overflow-auto'}" class:items-center={centerVertically} diff --git a/frontend/src/lib/components/CiTestResults.svelte b/frontend/src/lib/components/CiTestResults.svelte index 82bc81ea19..44888f8a0b 100644 --- a/frontend/src/lib/components/CiTestResults.svelte +++ b/frontend/src/lib/components/CiTestResults.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import { ScriptService, type CiTestResult } from '$lib/gen' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - import { base } from '$lib/base' import { CircleCheck, CircleX, Loader2, FlaskConical } from 'lucide-svelte' import { resource } from 'runed' @@ -57,14 +56,14 @@ <div class="w-3.5 h-3.5 rounded-full border border-tertiary"></div> {/if} <a - href="{base}/scripts/get/{test.test_script_path}?workspace={$operatingWorkspace}" + href="/scripts/get/{test.test_script_path}?workspace={$operatingWorkspace}" class="text-secondary hover:text-primary truncate" > {test.test_script_path} </a> {#if test.job_id} <a - href="{base}/run/{test.job_id}?workspace={$operatingWorkspace}" + href="/run/{test.job_id}?workspace={$operatingWorkspace}" class="text-tertiary hover:text-secondary text-2xs" > view run diff --git a/frontend/src/lib/components/CliHelpBox.svelte b/frontend/src/lib/components/CliHelpBox.svelte index 0bee33825c..e91b039328 100644 --- a/frontend/src/lib/components/CliHelpBox.svelte +++ b/frontend/src/lib/components/CliHelpBox.svelte @@ -2,10 +2,11 @@ import ClipboardPanel from './details/ClipboardPanel.svelte' import Section from './Section.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' const operatingWorkspace = useOperatingWorkspace() - let url = $derived(`${window.location.protocol}//${window.location.hostname}/`) + let url = $derived(`${apiBaseUrl()}/`) </script> <Section label="CLI quick setup"> diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 375e73480e..18c765e13b 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -1,9 +1,9 @@ <script lang="ts"> import { AlertTriangle, - ArrowDownRight, + ArrowDown, ArrowRight, - ArrowUpRight, + ArrowUp, Building, CircleCheck, CircleX, @@ -16,7 +16,7 @@ RefreshCw, UserPlus } from 'lucide-svelte' - import { untrack } from 'svelte' + import { onDestroy, untrack } from 'svelte' import type { CiTestResult } from '$lib/gen' import { Alert, Badge } from './common' import { @@ -65,7 +65,6 @@ import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte' import { userStore } from '$lib/stores' - import { base } from '$lib/base' import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte' import CompareTargetPicker from './CompareTargetPicker.svelte' import { displayDate } from '$lib/utils' @@ -97,6 +96,8 @@ onRetry?: () => void onSelectTarget?: (target: string) => void comparison: WorkspaceComparison | undefined + /** The comparison is still in flight, so an empty list says nothing yet. */ + comparisonLoading?: boolean /** Set when the comparison request failed (e.g. not an admin of the target). */ comparisonError?: string /** Initial merge direction; lets the page restore the chosen direction when @@ -144,6 +145,7 @@ onRetry, onSelectTarget, comparison, + comparisonLoading = false, comparisonError, initialMergeIntoParent = true, deployCount = 0, @@ -486,6 +488,12 @@ // Summary cache: stores summaries from both workspaces type SummaryCache = Record<string, { current?: string; parent?: string; loading?: boolean }> let summaryCache = $state<SummaryCache>({}) + // What has already been asked for. Deliberately not `$state`: the fetchers below run inside an + // effect, and a guard that reads reactive state makes that effect depend on what it writes — + // which is a loop. A plain Set is also what tells "asked, and the answer was nothing" from + // "never asked", which a value map cannot when the value for both is `undefined`. + const summariesRequested = new Set<string>() + const onBehalfOfRequested = new Set<string>() // On-behalf-of tracking for flows and scripts // Source workspace on_behalf_of emails (keyed by workspace/kind:path) @@ -499,51 +507,101 @@ return `${diff.kind}:${diff.path}` } + /** The two things a row needs from an item — the name it shows and who it runs as — are fields + * of the same object, so it is read once per item per workspace and awaited by both loops. + * + * `SHARED_READ_KINDS` is the kinds that carry both. A folder has a summary and no identity; a + * schedule or a trigger has an identity read from its own endpoint, which `getOnBehalfOf` + * dispatches. */ + const SHARED_READ_KINDS = ['script', 'flow', 'app', 'raw_app'] + type ItemRead = { summary?: string; onBehalfOf?: string } + const itemReads = new Map<string, Promise<ItemRead>>() + + function readItem(kind: string, path: string, workspace: string): Promise<ItemRead> { + const key = `${workspace}/${kind}:${path}` + let pending = itemReads.get(key) + if (!pending) { + // A failure is not an answer, so it is not kept: held, it would read as "this item has + // no identity", which is what decides whether the deploy offers to preserve one — and + // an item deployed without it silently becomes the deployer's. Each caller swallows its + // own, as both did before they shared a read. + pending = loadItem(kind, path, workspace).catch((error) => { + itemReads.delete(key) + throw error + }) + itemReads.set(key, pending) + } + return pending + } + + async function loadItem(kind: string, path: string, workspace: string): Promise<ItemRead> { + if (kind === 'script') { + const script = await ScriptService.getScriptByPath({ workspace, path }) + return { summary: script.summary, onBehalfOf: script.on_behalf_of_email } + } else if (kind === 'flow') { + const flow = await FlowService.getFlowByPath({ workspace, path }) + return { summary: flow.summary, onBehalfOf: flow.on_behalf_of_email } + } else if (kind === 'app' || kind === 'raw_app') { + const app = await AppService.getAppByPath({ workspace, path }) + return { summary: app.summary, onBehalfOf: app.policy?.on_behalf_of_email } + } else if (kind === 'folder') { + const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') }) + return { summary: folder.summary } + } + return {} + } + async function fetchSummary( kind: string, path: string, workspace: string ): Promise<string | undefined> { try { - if (kind === 'script') { - const script = await ScriptService.getScriptByPath({ workspace, path }) - return script.summary - } else if (kind === 'flow') { - const flow = await FlowService.getFlowByPath({ workspace, path }) - return flow.summary - } else if (kind === 'app' || kind === 'raw_app') { - const app = await AppService.getAppByPath({ workspace, path }) - return app.summary - } else if (kind === 'folder') { - const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') }) - return folder.summary - } + return (await readItem(kind, path, workspace)).summary } catch (error) { console.error(`Failed to fetch summary for ${kind}:${path}`, error) + return undefined } - return undefined } + // Both loops below run one item at a time and outlive the mount that started them: the page + // remounts this component when either side of the pair changes, and whatever was in flight then + // still resolves. It must not be stored — the summary key is kind and path, which the other + // pair spells the same way — and the loop must not keep asking for a pair nobody is looking at. + let alive = true + onDestroy(() => { + alive = false + }) + async function fetchSummaries(diffs: WorkspaceItemDiff[]) { // Only fetch summaries for scripts, flows, and apps const itemsToFetch = diffs.filter((diff) => ['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind) ) + // Read once, so every request in this pass names the pair the pass was started for. Named + // the way the diff names the two sides: `exists_in_fork` is this workspace, `exists_in_source` + // the one it is compared against. + const forkWs = currentWorkspaceId + const sourceWs = parentWorkspaceId for (const diff of itemsToFetch) { + if (!alive) return const key = getItemKey(diff) - // Skip if already cached or loading - if (summaryCache[key]) continue + // Asked for already — including one still in flight, and one whose answer was nothing. + if (summariesRequested.has(key)) continue + summariesRequested.add(key) - // Mark as loading summaryCache[key] = { loading: true } - // Fetch from both workspaces in parallel + // Only the sides that have the item. An item that is only ahead does not exist in the + // other workspace, so asking it there is a 404 by construction — a guaranteed-failing + // request per one-sided row, and a console error for each. const [currentSummary, parentSummary] = await Promise.all([ - fetchSummary(diff.kind, diff.path, currentWorkspaceId), - fetchSummary(diff.kind, diff.path, parentWorkspaceId) + diff.exists_in_fork ? fetchSummary(diff.kind, diff.path, forkWs) : undefined, + diff.exists_in_source ? fetchSummary(diff.kind, diff.path, sourceWs) : undefined ]) + if (!alive) return summaryCache[key] = { current: currentSummary, @@ -561,18 +619,32 @@ (d) => ['flow', 'script', 'app', 'raw_app'].includes(d.kind) || isTriggerOrScheduleKind(d.kind) ) + const pair = [ + { workspace: currentWorkspaceId, side: 'fork' as const }, + { workspace: parentWorkspaceId, side: 'source' as const } + ] for (const diff of itemsWithOnBehalfOf) { - for (const workspace of [currentWorkspaceId, parentWorkspaceId]) { + for (const { workspace, side } of pair) { + if (!alive) return const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff)) - if (onBehalfOfInfo[workspacedKey] !== undefined) continue + // Marked before the request, not after it answers: a guard keyed on the value it + // stores (`undefined` either way) would ask again on every pass, forever. + if (onBehalfOfRequested.has(workspacedKey)) continue + onBehalfOfRequested.add(workspacedKey) + // The side that does not have the item has nothing to say about who it runs as, and + // asking produces a 404 rather than an answer. + if (!(side === 'fork' ? diff.exists_in_fork : diff.exists_in_source)) continue try { - onBehalfOfInfo[workspacedKey] = await getOnBehalfOf( - diff.kind as Kind, - diff.path, - workspace - ) + // The same read the summary loop does, for the kinds that answer both from one + // object; the rest keep their own endpoint. + const email = SHARED_READ_KINDS.includes(diff.kind) + ? (await readItem(diff.kind, diff.path, workspace)).onBehalfOf + : await getOnBehalfOf(diff.kind as Kind, diff.path, workspace) + if (!alive) return + onBehalfOfInfo[workspacedKey] = email } catch { + if (!alive) return onBehalfOfInfo[workspacedKey] = undefined } } @@ -1004,11 +1076,16 @@ ) ) - // Fetch summaries and on_behalf_of_email when comparison data loads + // Fetch summaries and on_behalf_of_email when comparison data loads. `untrack`: both fetchers + // write the state they also read, and without this the effect depends on their writes and + // re-runs on each one. $effect(() => { - if (comparison?.diffs) { - fetchSummaries(comparison.diffs) - fetchOnBehalfOfInfo(comparison.diffs) + const diffs = comparison?.diffs + if (diffs) { + untrack(() => { + fetchSummaries(diffs) + fetchOnBehalfOfInfo(diffs) + }) } }) @@ -1229,6 +1306,7 @@ onSelectAll={selectAll} onDeselectAll={deselectAll} emptyMessage={emptyDeployMessage} + loading={comparisonLoading} > {#snippet header()} <div class="flex items-center justify-between bg-surface-tertiary"> @@ -1382,7 +1460,7 @@ <span class="text-secondary">{test.test_script_path}</span> {#if test.job_id} <a - href="{base}/run/{test.job_id}?workspace={currentWorkspaceId}" + href="/run/{test.job_id}?workspace={currentWorkspaceId}" class="text-tertiary hover:text-secondary text-2xs" > view run @@ -1650,13 +1728,13 @@ {#if isConflict || existsInBothWorkspaces} {#if diff.ahead > 0} <Badge color="green" size="xs"> - <ArrowUpRight class="w-3 h-3 inline" /> + <ArrowUp class="w-3 h-3 inline" /> {diff.ahead} ahead </Badge> {/if} {#if diff.behind > 0} <Badge color="blue" size="xs"> - <ArrowDownRight class="w-3 h-3 inline" /> + <ArrowDown class="w-3 h-3 inline" /> {diff.behind} behind </Badge> {/if} diff --git a/frontend/src/lib/components/ContextBridge.svelte b/frontend/src/lib/components/ContextBridge.svelte new file mode 100644 index 0000000000..069bec0f42 --- /dev/null +++ b/frontend/src/lib/components/ContextBridge.svelte @@ -0,0 +1,18 @@ +<!-- +@component +Re-exposes contexts from one component tree inside another. + +A snippet handed to the page header renders under the header, not under the page that wrote it, so +any component it creates looks its contexts up in the header's tree and finds nothing. The page +passes the entries its buttons need and this sets them again around the render. +--> +<script lang="ts"> + import { setContext, type Snippet } from 'svelte' + + let { contexts, children }: { contexts?: Map<any, any>; children: Snippet } = $props() + + // setContext only takes effect during init, so this instance is keyed on `contexts` by callers. + if (contexts) for (const [key, value] of contexts) setContext(key, value) +</script> + +{@render children()} diff --git a/frontend/src/lib/components/CronBuilder.svelte b/frontend/src/lib/components/CronBuilder.svelte index bcf8508c12..3bceabb417 100644 --- a/frontend/src/lib/components/CronBuilder.svelte +++ b/frontend/src/lib/components/CronBuilder.svelte @@ -15,7 +15,7 @@ contentClasses="p-4" > {#snippet trigger()} - <Button variant="accent" size="xs" nonCaptureEvent={true} startIcon={{ icon: Clock }}> + <Button variant="default" unifiedSize="sm" nonCaptureEvent={true} startIcon={{ icon: Clock }}> Cron builder </Button> {/snippet} diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 5204a876b9..5b34c66bc5 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -45,7 +45,12 @@ import { favoriteManager } from './sidebar/FavoriteMenu.svelte' import DatatableRoleBadge from './DatatableRoleBadge.svelte' import type { AclTarget, Asset, DataTableTables } from '$lib/gen' - import { ADMIN_DATATABLE_ROLE, datatableNameTakesRole, type DatatableRowAction } from './dbTypes' + import { + ADMIN_DATATABLE_ROLE, + datatableNameTakesRole, + namedDefaultRole, + type DatatableRowAction + } from './dbTypes' import TextInput from './text_input/TextInput.svelte' import Checkbox from './common/checkbox/Checkbox.svelte' import DbSchemaDiagram from './dbdiagram/DbSchemaDiagram.svelte' @@ -375,7 +380,8 @@ } if (!isInstanceDatatable(datatable)) return { - reason: 'Only data tables stored in the instance database have roles and grants.', + reason: + "Roles and grants are not supported on data tables backed by a Postgres resource. Store the data table on Windmill's database or on an external Postgres instance (set up by a superadmin in instance settings) to use them.", ee: false } if (needsAdmin && !canManageDatatable) @@ -416,7 +422,10 @@ if (!entry?.permissioned || !datatableNameTakesRole(datatable)) return undefined const roles = entry.usable_roles if (roles.length === 0) return { kind: 'none' } - const role = (datatable === currentDatatable ? currentRole : undefined) ?? entry.default_role + const role = + (datatable === currentDatatable ? currentRole : undefined) ?? + namedDefaultRole(entry.default_role) ?? + roles[0] if (roles.length === 1 && roles[0] === ADMIN_DATATABLE_ROLE && role === ADMIN_DATATABLE_ROLE) { return undefined } @@ -458,6 +467,11 @@ // default to open — actually be folded; a plain "expanded" set could never // close them, since the default would keep winning. // What the permissions drawer is open on, if anything: a schema, or a table. + let aclEditor = $state<PgAclEditor | undefined>(undefined) + /** The open access editor lists the data table's roles, which the roles drawer can change. */ + export function refreshAcl() { + aclEditor?.refresh() + } let aclDrawer = $state<{ datatable: string | undefined; target: AclTarget } | undefined>( undefined ) @@ -1466,7 +1480,7 @@ <Portal> <Drawer open={!!aclDrawer} size="900px" on:close={() => (aclDrawer = undefined)}> <DrawerContent - title="Access — {aclLabel(aclDrawer?.target)}" + title="Access: {aclLabel(aclDrawer?.target)}" on:close={() => (aclDrawer = undefined)} tooltip="Who owns this, and what each role may do with it." > @@ -1474,7 +1488,18 @@ {@const dt = aclDrawer.datatable ?? currentDatatable} {#if dt} {#key `${dt}~${JSON.stringify(aclDrawer.target)}`} - <PgAclEditor {workspace} datatable={dt} target={aclDrawer.target} /> + <PgAclEditor + bind:this={aclEditor} + {workspace} + datatable={dt} + target={aclDrawer.target} + manageRoles={onDatatableAction + ? { + open: () => onDatatableAction?.(dt, 'roles'), + blocker: accessBlocker(dt, true)?.reason + } + : undefined} + /> {/key} {/if} {/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index bd016544b0..30fc93377d 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -311,6 +311,7 @@ function refreshRoles() { datatables.refetch() usableRoles.refetch() + dbManagerContent?.dbManager()?.refreshAcl() } async function handleExportSchema(explicitSource?: string) { diff --git a/frontend/src/lib/components/DatatablePicker.svelte b/frontend/src/lib/components/DatatablePicker.svelte index 53617acf36..17ad6f3361 100644 --- a/frontend/src/lib/components/DatatablePicker.svelte +++ b/frontend/src/lib/components/DatatablePicker.svelte @@ -17,6 +17,9 @@ selectInputClass?: string class?: string onClear?: () => void + /** Size the input to its content, like Path's folder picker. */ + useContentEditable?: boolean + RightIcon?: any } let { @@ -27,7 +30,9 @@ placeholder = undefined, selectInputClass = '', class: className = '', - onClear = undefined + onClear = undefined, + useContentEditable = false, + RightIcon = undefined }: Props = $props() let datatables = usePromise(() => @@ -47,6 +52,8 @@ {placeholder} inputClass={selectInputClass} {onClear} + {useContentEditable} + {RightIcon} /> {#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'datatable', path: value })} <ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'datatable', path: value }} /> diff --git a/frontend/src/lib/components/DefaultScriptsInner.svelte b/frontend/src/lib/components/DefaultScriptsInner.svelte index b062fb4c49..29136e42f7 100644 --- a/frontend/src/lib/components/DefaultScriptsInner.svelte +++ b/frontend/src/lib/components/DefaultScriptsInner.svelte @@ -16,11 +16,13 @@ let { small = false }: Props = $props() function computeLangs(defaultScripts: WorkspaceDefaultScripts | undefined): Script['language'][] { - const allLangs = Object.keys(defaultScriptLanguages) as Script['language'][] + const allLangs = (Object.keys(defaultScriptLanguages) as Script['language'][]).filter( + (l) => l != 'deno' + ) if (!defaultScripts || defaultScripts.order == undefined) return allLangs return defaultScripts.order ?.concat(allLangs.filter((l) => !defaultScripts.order?.includes(l))) - .filter((x) => x != 'nativets') as Script['language'][] + .filter((x) => x != 'nativets' && x != 'deno') as Script['language'][] } async function changePosition(i: number, up: boolean) { @@ -31,6 +33,10 @@ ;[norder[i], norder[i + 1]] = [norder[i + 1], norder[i]] } defaultScripts.update((s) => ({ ...s, order: norder })) + await persist() + } + + async function persist() { await WorkspaceService.editDefaultScripts({ workspace: $operatingWorkspace!, requestBody: $defaultScripts @@ -76,7 +82,7 @@ size="xs" color="red" checked={$defaultScripts?.hidden?.includes(lang)} - on:change={(e) => { + on:change={async (e) => { let toggled = e.detail if (toggled) { defaultScripts.update((s) => ({ @@ -89,6 +95,7 @@ hidden: (s?.hidden ?? []).filter((h) => h != lang) })) } + await persist() }} /> </div> diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index 53ca12cf03..7913240188 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -5,7 +5,6 @@ aiAgentModuleDependencies, collectTransformRefs } from './deployDependencies' - import { base } from '$lib/base' import { enterpriseLicense, superadmin } from '$lib/stores' import { AppService, @@ -538,7 +537,7 @@ > {:else} <Alert type="info" title="Shareable page" - >Share this <a href="{base}/deploy/{kind}/{initialPath}">link</a> to have another properly permissioned + >Share this <a href="/deploy/{kind}/{initialPath}">link</a> to have another properly permissioned user do the deployment</Alert > @@ -580,7 +579,7 @@ connection={remoteStatus?.connection} returnTo={kind === 'trigger' ? undefined - : `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($operatingWorkspace!)}`} + : `/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($operatingWorkspace!)}`} onChange={loadDestinations} /> </div> diff --git a/frontend/src/lib/components/DevWorkspaceBadge.svelte b/frontend/src/lib/components/DevWorkspaceBadge.svelte new file mode 100644 index 0000000000..e86e4593c8 --- /dev/null +++ b/frontend/src/lib/components/DevWorkspaceBadge.svelte @@ -0,0 +1,56 @@ +<!-- +@component +The badge naming a dev workspace's environment, wherever one is shown. + +A fork that has a colour wears it: the badge is filled with that hue at the lightest shade white +still reads on (`forkAccentStyle`), so a yellow workspace says "dev" in dark yellow rather than in +a blue that belongs to no workspace in particular. A fork with no colour keeps whatever its host +gave it — `fallbackColor` and `fallbackClass`, which is what every dev workspace looked like +before a colour could be picked. +--> +<script lang="ts"> + import { Badge } from '$lib/components/common' + import type { BadgeColor } from '$lib/components/common/badge/model' + import { devBadgeText } from '$lib/utils/devWorkspaceLabel' + import { forkAccentStyle } from '$lib/utils/forkColor' + import { twMerge } from 'tailwind-merge' + + interface Props { + /** The workspace's `dev_workspace_label`; spelled the way `devBadgeText` spells it. Nullable + * because that is what the workspace rows carry. */ + label?: string | null + /** The fork's own colour. Without one — or with an unparsable one — the badge falls back. */ + color?: string | null + /** The badge's colour when the fork has none of its own. */ + fallbackColor?: BadgeColor + /** Extra classes for that same case, for a host that tuned the uncoloured badge. */ + fallbackClass?: string + /** Size and spacing, which the row around the badge decides either way. */ + class?: string + } + + let { + label, + color, + fallbackColor = 'dark-blue', + fallbackClass, + class: className + }: Props = $props() + + // Set on the badge itself rather than read from an ancestor: a host that paints a coloured chip + // around it already defines these, and defining them again costs nothing and makes the badge + // work the same in a row that has no chip. + const accent = $derived(forkAccentStyle(color)) +</script> + +<Badge + color={fallbackColor} + small + style={accent} + class={twMerge( + accent + ? 'bg-[color:var(--fork-accent-badge)] dark:bg-[color:var(--fork-accent-badge)] text-white dark:text-white' + : fallbackClass, + className + )}>{devBadgeText(label)}</Badge +> diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte index e148d90c22..5346cab7b0 100644 --- a/frontend/src/lib/components/DevWorkspaceSetting.svelte +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -1,13 +1,13 @@ <script lang="ts"> import { workspaceStore, userWorkspaces, usersWorkspaceStore, superadmin } from '$lib/stores' import { WorkspaceService, type ProtectionRuleset } from '$lib/gen' - import { Badge, Button } from '$lib/components/common' + import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte' + import { Button } from '$lib/components/common' import Select from '$lib/components/select/Select.svelte' import Toggle from '$lib/components/Toggle.svelte' import { sendUserToast } from '$lib/toast' import { switchWorkspace } from '$lib/storeUtils' import { goto } from '$app/navigation' - import { base } from '$lib/base' import { findCanonicalDevWorkspace, findWorkspaceAncestors, @@ -17,7 +17,6 @@ import { getUserExt } from '$lib/user' import { DEV_WORKSPACE_LABELS, - devBadgeText, devLabelKey, devLabelNoun, type DevWorkspaceLabelKey @@ -239,7 +238,7 @@ // scratch rather than from the current query so no stale `?workspace=<dev>` survives a switch. function rulesetsHref(name?: string): string { const rule = name ? `&rule=${encodeURIComponent(name)}` : '' - return `${base}/workspace_settings?tab=rulesets${rule}` + return `/workspace_settings?tab=rulesets${rule}` } function openRulesets(name?: string) { @@ -531,7 +530,7 @@ <Button variant="default" startIcon={{ icon: GitFork }} - onclick={() => goto(`${base}/user/fork_workspace?dev=true`)} + onclick={() => goto('/user/fork_workspace?dev=true')} > Create a new dev workspace </Button> @@ -554,7 +553,11 @@ <b>{parentId}</b>. Promote changes from the home page banner or the Compare & Deploy page. </p> <div class="text-2xs text-secondary"> - Environment: <Badge color="indigo" small>{devBadgeText(currentLabel)}</Badge> + Environment: <DevWorkspaceBadge + label={currentLabel} + color={currentWs?.color} + fallbackColor="indigo" + /> <span class="ml-1"> Set when the workspace is created or attached. Git sync deploys to the <span class="font-mono">{currentLabel}</span> branch. diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index a49f09bb02..afadcb5741 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -7,7 +7,6 @@ import { parseDbtRun } from '$lib/components/dbt/parseDbtRun' import { copyToClipboard, parseS3Object, roughSizeOfObject } from '$lib/utils' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' - import { base } from '$lib/base' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { appendViewToken } from '$lib/viewToken' import { Badge, Button, Drawer, DrawerContent } from './common' @@ -254,7 +253,7 @@ ) let resultDownloadHref = $derived( resultApiPath - ? `${base}/api${resultApiPath}` + ? `/api${resultApiPath}` : `data:text/json;charset=utf-8,${encodeURIComponent(toJsonStr(result))}` ) let resultDownloadName = $derived(`${filename ?? 'result'}.json`) @@ -856,7 +855,6 @@ {workspaceId} {jobId} {nodeId} - {base} {result} {disableTooltips} on:open-drawer={() => openDrawer()} diff --git a/frontend/src/lib/components/DisplayResultControlBar.svelte b/frontend/src/lib/components/DisplayResultControlBar.svelte index 45011d2db1..6af502a1b8 100644 --- a/frontend/src/lib/components/DisplayResultControlBar.svelte +++ b/frontend/src/lib/components/DisplayResultControlBar.svelte @@ -14,7 +14,6 @@ workspaceId?: string | undefined jobId?: string | undefined nodeId?: string | undefined - base: string result: any disableTooltips?: boolean } @@ -25,7 +24,6 @@ workspaceId = undefined, jobId = undefined, nodeId = undefined, - base, result, disableTooltips = false }: Props = $props() @@ -67,7 +65,7 @@ download={downloadName} class="text-current" href={resultApiPath - ? `${base}/api${resultApiPath}` + ? `/api${resultApiPath}` : `data:text/json;charset=utf-8,${encodeURIComponent(toJsonStr(result))}`} > <Download size={14} /> diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 31fd9426f5..70ed83398d 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -107,9 +107,19 @@ <DropdownSubmenuItem {item} {builders} {meltItem} close={close ?? (() => {})} /> {:else if item.disabled && item.tooltip} <!-- Wrapper carries the native `title`; the disabled button's `pointer-events-none` - lets the hover reach it so the user learns why the item is disabled. --> - <div title={item.tooltip} class="w-full"> - {@render menuItem(item)} + lets the hover reach it so the user learns why the item is disabled. The ⓘ sits + outside the disabled button, the only place it can receive a hover. --> + <div class="relative w-full"> + <div title={item.tooltip} class="w-full pr-6"> + {@render menuItem(item)} + </div> + <div class="absolute right-3 top-1/2 -translate-y-1/2 flex"> + <Tooltip> + {#snippet text()} + {item.tooltip} + {/snippet} + </Tooltip> + </div> </div> {:else} {@render menuItem(item)} diff --git a/frontend/src/lib/components/DucklakePicker.svelte b/frontend/src/lib/components/DucklakePicker.svelte index 3a6f2bf739..d6ed4d0cdf 100644 --- a/frontend/src/lib/components/DucklakePicker.svelte +++ b/frontend/src/lib/components/DucklakePicker.svelte @@ -17,6 +17,9 @@ selectInputClass?: string class?: string onClear?: () => void + /** Size the input to its content, like Path's folder picker. */ + useContentEditable?: boolean + RightIcon?: any } let { @@ -27,7 +30,9 @@ placeholder = undefined, selectInputClass = '', class: className = '', - onClear = undefined + onClear = undefined, + useContentEditable = false, + RightIcon = undefined }: Props = $props() let ducklakes = usePromise(() => @@ -45,6 +50,8 @@ {placeholder} inputClass={selectInputClass} {onClear} + {useContentEditable} + {RightIcon} /> {#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })} <ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'ducklake', path: value }} /> diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index b6c7bd0552..39b3d4a3fe 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1530,6 +1530,9 @@ } catch (e) { console.log('error initializing services', e) } + // Unmounted while the services loaded: the container is detached, and + // creating the editor on it throws. + if (destroyed || !divEl?.isConnected) return // vscode.languages.registerDefinitionProvider('*', { // provideDefinition(document, position, token) { diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 81ef6c1c96..429c99c4a6 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -22,7 +22,6 @@ <script lang="ts"> import { ResourceService, VariableService, WorkspaceService, type Script } from '$lib/gen' - import { base } from '$lib/base' import type Editor from './Editor.svelte' import ItemPicker from './ItemPicker.svelte' import VariableEditor from './VariableEditor.svelte' @@ -105,6 +104,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' | 'wac_python' | 'wac_typescript' | 'ci_test_bun' @@ -844,7 +844,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS startIcon={{ icon: Plus }} target="_blank" variant="accent" - href="{base}/resources?connect_app=undefined" + href="/resources?connect_app=undefined" > Add resource </Button> @@ -903,7 +903,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS startIcon={{ icon: Settings }} target="_blank" variant="accent" - href="{base}/workspace_settings?tab=ducklake" + href="/workspace_settings?tab=ducklake" > Go to settings </Button> @@ -946,7 +946,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS startIcon={{ icon: Settings }} target="_blank" variant="accent" - href="{base}/workspace_settings?tab=windmill_data_tables" + href="/workspace_settings?tab=windmill_data_tables" > Go to settings </Button> diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 25291fa5f0..a44568560c 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -1,10 +1,7 @@ <script lang="ts"> - import { Pencil } from 'lucide-svelte' - import { Alert, Button } from '$lib/components/common' + import { ChevronRight } from 'lucide-svelte' import EditableInput from '$lib/components/common/EditableInput.svelte' - import Popover from '$lib/components/meltComponents/Popover.svelte' - import Path from '$lib/components/Path.svelte' - import Label from '$lib/components/Label.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' import { dirKey, KIND_LABEL_LOWER, @@ -14,17 +11,7 @@ type WorkspaceItemKind } from '$lib/components/workspacePicker' import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte' - import { isOwner } from '$lib/utils' - import { userStore } from '$lib/stores' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' - - const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) - + import { splitItemPath } from '$lib/components/breadcrumbPath' interface Props { summary?: string path?: string @@ -75,55 +62,17 @@ workspaceId }: Props = $props() - let pathPopoverOpen = $state(false) - /** Snapshot of `path` taken when the pen popover opens; cleared on close. - * While set, the breadcrumb derives from it instead of `path` so the pen - * anchor doesn't reflow as the user types in the popover (which would drag - * the popover with it via floating-ui's auto-update). */ + /** Held by the pen popover while it is open, so the trail below — and the pen anchored to its + * end — does not reflow as the user types. */ let snapshotPath = $state<string | undefined>(undefined) - function setPathPopoverOpen(open: boolean) { - pathPopoverOpen = open - // Only snapshot when there's a path to freeze. New flows / scripts open - // the popover with `path === ''` and rely on `Path.reset()` to seed a - // path; if we snapshot the empty value the breadcrumb stays blank for - // the whole popover lifetime. Leaving `snapshotPath` undefined lets - // `displayPath` track the live seeded path instead. - snapshotPath = open && path ? path : undefined - } - // Path segments. e.g. "f/demo/weather_report" → scope "f/demo", slug "weather_report". // `snapshotPath` keeps the breadcrumb frozen while the pen popover is open // so the trigger doesn't drift mid-edit. Outside of that, the breadcrumb // follows the live `path` — coherent with the picker because we inject a // virtual current-item entry at that same live path (see `currentItem`). let displayPath = $derived(snapshotPath ?? path ?? '') - /** Breakdown for breadcrumb rendering. Top-level dirs (scopes) come first - * — `f/<folder>` or `u/<user>` — then any number of subfolders, then the - * item name. Each dir entry's `fullPath` is the cumulative path up to - * that level, used to key the picker's `initialOpen` / `initialHighlight`. */ - let segments = $derived.by(() => { - const parts = displayPath.split('/') - if (parts.length < 3) return null - const scope = parts.slice(0, 2).join('/') - const slug = parts.slice(2) - const dirs: { name: string; fullPath: string }[] = [] - // Top-level scope is a dir. - dirs.push({ name: scope, fullPath: scope }) - // Intermediate slug parts (everything except the last one). - let acc = scope - for (let i = 0; i < slug.length - 1; i++) { - acc = `${acc}/${slug[i]}` - dirs.push({ name: slug[i], fullPath: acc }) - } - const leaf = { name: slug[slug.length - 1], fullPath: displayPath } - return { dirs, leaf } - }) - - // Treat an empty path as ownable so the pen popover lets a user pick the - // path for a brand-new item. `Path.reset()` then synthesizes a default - // under their own user/folder scope. - let own = $derived(!path || isOwner(path, actingUser, $operatingWorkspace)) + let segments = $derived(splitItemPath(displayPath) ?? null) // Virtual entry for the picker: surfaces the currently-edited item at its // live path (which may differ from `savedPath` mid-rename, so the picker @@ -145,7 +94,7 @@ } </script> -<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight"> +<div class="max-w-full group px-2 py-0.5 leading-tight inline-block align-top"> <!-- Path row --> {#if !hidePath} <div class="flex items-center max-w-full text-2xs text-secondary font-mono"> @@ -162,9 +111,9 @@ {#if segments} {#each segments.dirs as dir, i (dir.fullPath)} {@const dKey = dirKey('all', dir.fullPath)} + <ChevronRight size={10} class="shrink-0" /> <BreadcrumbSegment label={dir.name} - withChevron extraClass={i === 0 ? 'gap-0.5 min-w-0 max-w-[40%]' : 'gap-0.5 min-w-0'} initialScope={i === 0 ? { kind: 'all' } @@ -175,11 +124,13 @@ onPick={handlePickerSelect} /> {/each} + {/if} + {#if segments?.leaf} {@const leafKey = leafKeyFor(kind, segments.leaf.fullPath)} {@const leafParent = segments.dirs[segments.dirs.length - 1]?.fullPath} + <ChevronRight size={10} class="shrink-0" /> <BreadcrumbSegment label={segments.leaf.name} - withChevron extraClass="gap-0.5 min-w-0" initialScope={leafParent ? { kind: 'all', dir: leafParent } : { kind: 'all' }} initialHighlight={leafKey} @@ -191,65 +142,21 @@ {/if} </nav> - <!-- Pen → path-edit popover. Skipped entirely when path editing is - disabled so the user doesn't see an inert button. --> + <!-- Skipped entirely when path editing is disabled so the user doesn't see an inert + button. --> {#if pathEditable} - <Popover - placement="bottom-start" - contentClasses="p-4" - usePointerDownOutside - excludeSelectors=".drawer" - disableFocusTrap - closeOnOtherPopoverOpen - bind:isOpen={() => pathPopoverOpen, setPathPopoverOpen} - > - {#snippet trigger()} - <Button - variant="subtle" - unifiedSize="xs" - iconOnly - startIcon={{ icon: Pencil }} - title="Edit path" - aria-label="Edit path" - btnClasses={penVisibility === 'hover' && !pathPopoverOpen - ? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100' - : ''} - /> - {/snippet} - {#snippet content()} - <div class="flex flex-col gap-6 w-[480px]"> - {#if own} - <Path - autofocus - bind:path - initialPath={snapshotPath ?? path ?? ''} - namePlaceholder={kind} - {kind} - size="sm" - drawerOffset={4000} - workspaceOverride={workspaceId} - /> - {#if savedPath && path && path !== savedPath} - <Alert - type="info" - size="xs" - title="Deploy the {kind} to make the path change effective." - /> - {/if} - {#if onBehalfOfEmail} - <Alert type="info" title="Run on behalf of" size="xs"> - This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail} - </Alert> - {/if} - {:else} - <Label label="Path"> - <span class="text-xs font-mono text-secondary">{path}</span> - <p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p> - </Label> - {/if} - </div> - {/snippet} - </Popover> + <!-- Path only: this bar carries its own summary field below, and `hidePath` would take + the pen with it, leaving a condensed pane no way to name the item. --> + <PathEditPopover + bind:path + bind:snapshotPath + summaryEditable={false} + {savedPath} + {kind} + {workspaceId} + {onBehalfOfEmail} + {penVisibility} + /> {/if} </div> {/if} diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index 0adb5dc690..6d95ad0c82 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -1,6 +1,4 @@ <script lang="ts" module> - import type { ErrorHandler } from '$lib/gen/types.gen' - export const errorHandlerArgs = [ 'path', 'workspace_id', @@ -19,17 +17,6 @@ ] export const slackErrorHandlerHubPathEnding = '/workspace-or-schedule-error-handler-slack' - - /** The stored `<script|flow>/<path>` form of a handler. `kind` is the Custom tab's Script/Flow - * choice and survives switching to a built-in tab, whose handlers are all scripts: stored as - * `flow/...` they never run. */ - export function handlerFullPath( - selected: ErrorHandler, - kind: 'flow' | 'script', - path: string - ): string { - return `${selected === 'custom' ? kind : 'script'}/${path}` - } </script> <script lang="ts"> @@ -43,7 +30,6 @@ import ChannelSelector from '$lib/components/ChannelSelector.svelte' import type { Schema, SupportedLanguage } from '$lib/common' - import { base } from '$lib/base' import { enterpriseLicense } from '$lib/stores' import MsTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte' import { classNames, emptySchema, emptyString, sendUserToast, tryEvery } from '$lib/utils' @@ -68,32 +54,22 @@ RotateCw, XCircle } from 'lucide-svelte' - import { hubPaths } from '$lib/hub' import { isCloudHosted } from '$lib/cloud' import SmtpConfigurationStatus from './common/smtp/SmtpConfigurationStatus.svelte' import { SettingService } from '$lib/gen' - import { isSmtpSettingsValid } from './instanceSettings/SmtpSettings.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { builtinHubPath, type HandlerDraft, type HandlerMode } from './handlerDraft.svelte' const operatingWorkspace = useOperatingWorkspace() - const slackRecoveryHandler = hubPaths.slackRecoveryHandler - const slackHandlerScriptPath = hubPaths.slackErrorHandler - const slackSuccessHandler = hubPaths.slackSuccessHandler - const teamsRecoveryHandler = hubPaths.teamsRecoveryHandler - const teamsHandlerScriptPath = hubPaths.teamsErrorHandler - const teamsSuccessHandler = hubPaths.teamsSuccessHandler - interface Props { - errorOrRecovery: 'error' | 'recovery' | 'success' + errorOrRecovery: HandlerMode isEditable: boolean toggleText?: string showScriptHelpText?: boolean - handlerSelected: ErrorHandler - handlerPath: string | undefined - handlerExtraArgs: Record<string, any> + /** Owned by the caller, which restores it on Discard; every tab edits its own slot. */ + draft: HandlerDraft customScriptTemplate: string - customHandlerKind?: 'flow' | 'script' customTabTooltip?: import('svelte').Snippet noMargin?: boolean /** Workspace for handler lookup / settings / test jobs. Defaults to the operating @@ -109,11 +85,8 @@ isEditable, toggleText = 'Enable', showScriptHelpText = false, - handlerSelected = $bindable('custom'), - handlerPath = $bindable(), - handlerExtraArgs = $bindable(), + draft = $bindable(), customScriptTemplate, - customHandlerKind = $bindable('script'), customTabTooltip, noMargin = false, workspace = undefined, @@ -141,8 +114,6 @@ let connectionTestJob: { uuid: string; is_success: boolean; in_progress: boolean } | undefined = $state() - const EMAIL_RECIPIENTS_KEY = 'email_recipients' - const CHANNEL_KEY = 'channel' async function loadSlackResources() { const settings = await WorkspaceService.getPublicSettings({ workspace: effectiveWorkspace! }) @@ -170,11 +141,7 @@ async function loadSmtpConfiguration() { try { - const smtpSettings = (await SettingService.getGlobal({ key: 'smtp_settings' })) as Record< - string, - any - > | null - hasSmtpConfig = smtpSettings ? isSmtpSettingsValid(smtpSettings) : false + hasSmtpConfig = await SettingService.getSmtpConfigured() } catch (error) { hasSmtpConfig = false } @@ -189,7 +156,7 @@ let submitted_job = await testJobFunction({ workspace: effectiveWorkspace!, requestBody: { - hub_script_path: handlerPath, + hub_script_path: draft[platform].path, channel: channel, test_msg: `This is a notification to test the connection between ${platform} and Windmill workspace '${effectiveWorkspace!}'` } @@ -235,7 +202,11 @@ await sendMessage(channel, 'teams') } - async function loadHandlerScriptArgs(p: string, defaultArgs: string[] = []) { + async function loadHandlerScriptArgs( + p: string, + kind: 'script' | 'flow', + defaultArgs: string[] = [] + ) { try { let schema: Schema | undefined = emptySchema() if (p.startsWith('hub/')) { @@ -250,7 +221,7 @@ } } else { let scriptOrFlow: Script | Flow = - customHandlerKind === 'script' + kind === 'script' ? await ScriptService.getScriptByPath({ workspace: effectiveWorkspace!, path: p }) : await FlowService.getFlowByPath({ workspace: effectiveWorkspace!, path: p }) schema = scriptOrFlow.schema as Schema @@ -268,47 +239,6 @@ } } - function isSlackHandler(scriptPath: string | undefined) { - if (!scriptPath) { - return false - } - if (errorOrRecovery == 'error') { - return scriptPath.startsWith('hub/') && scriptPath.endsWith(slackErrorHandlerHubPathEnding) - } else if (errorOrRecovery == 'recovery') { - return ( - scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-slack') - ) - } else { - return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-slack') - } - } - - function isTeamsHandler(scriptPath: string | undefined) { - if (!scriptPath) { - return false - } - if (errorOrRecovery == 'error') { - return ( - scriptPath.startsWith('hub/') && - scriptPath.endsWith('/workspace-or-schedule-error-handler-teams') - ) - } else if (errorOrRecovery == 'recovery') { - return ( - scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-recovery-handler-teams') - ) - } else { - return scriptPath.startsWith('hub/') && scriptPath.endsWith('/schedule-success-handler-teams') - } - } - - function isEmailHandler(scriptPath: string | undefined) { - if (!scriptPath) { - return false - } - - return scriptPath.startsWith('hub/') && scriptPath.endsWith('/workspace-or-error-handler-email') - } - $effect(() => { if (effectiveWorkspace) { loadSlackResources() @@ -318,80 +248,24 @@ }) $effect(() => { - if (handlerSelected === 'slack' && isSlackHandler(handlerPath)) { - handlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - handlerExtraArgs['slack'] = undefined - } - }) - - let lastHandlerSelected: ErrorHandler | undefined = $state(undefined) - let handlerCache = $state({ - slack: undefined as string | undefined, - teams: undefined as string | undefined, - email: undefined as string[] | undefined - }) - let handlerPathCache: Partial<Record<ErrorHandler, string | undefined>> = $state({}) - $effect(() => { - if (lastHandlerSelected !== handlerSelected && lastHandlerSelected !== undefined) { - if (lastHandlerSelected != 'custom') { - const key = lastHandlerSelected === 'email' ? EMAIL_RECIPIENTS_KEY : CHANNEL_KEY - handlerCache[lastHandlerSelected] = handlerExtraArgs[key] - } - handlerPathCache[lastHandlerSelected] = handlerPath - - if (handlerSelected === 'custom') { - delete handlerExtraArgs[CHANNEL_KEY] - delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY] - handlerPath = handlerPathCache['custom'] - } else if (handlerSelected === 'email') { - handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = handlerCache[handlerSelected] ?? [] - delete handlerExtraArgs[CHANNEL_KEY] - } else { - handlerExtraArgs[CHANNEL_KEY] = handlerCache[handlerSelected] ?? '' - delete handlerExtraArgs[EMAIL_RECIPIENTS_KEY] - handlerPath = handlerPathCache[handlerSelected] - } - } - - lastHandlerSelected = handlerSelected - }) - - $effect(() => { - handlerPath && - !isSlackHandler(handlerPath) && - !isTeamsHandler(handlerPath) && - !isEmailHandler(handlerPath) && - loadHandlerScriptArgs(handlerPath, errorHandlerArgs).then( + const { path, kind } = draft.custom + path && + loadHandlerScriptArgs(path, kind, errorHandlerArgs).then( (schema) => (customHandlerSchema = schema) ) }) $effect(() => { - handlerPath && - isSlackHandler(handlerPath) && - loadHandlerScriptArgs(handlerPath, [...errorHandlerArgs, 'slack']).then( + const path = draft.slack.path + path && + loadHandlerScriptArgs(path, 'script', [...errorHandlerArgs, 'slack']).then( (schema) => (slackHandlerSchema = schema) ) }) - - $effect(() => { - if (handlerSelected === 'email') { - handlerPath = hubPaths.emailErrorHandler - } - }) - - // The instance channels are reached by having no workspace handler at all, so the tab - // owns an empty path rather than a handler script. - $effect(() => { - if (handlerSelected === 'instance_alerts') { - handlerPath = undefined - } - }) </script> <div class={classNames('space-y-2', noMargin ? '' : 'mt-2')}> - <ToggleButtonGroup bind:selected={handlerSelected} disabled={!isEditable}> + <ToggleButtonGroup bind:selected={draft.selected} disabled={!isEditable}> {#snippet children({ item })} <ToggleButton label="Slack" value="slack" {item} disabled={!isEditable} /> <ToggleButton label="Teams" value="teams" {item} disabled={!isEditable} /> @@ -416,21 +290,21 @@ </ToggleButtonGroup> <div class="flex flex-col gap-6 p-4 rounded-md shadow-sm bg-surface-tertiary"> - {#if handlerSelected === 'custom'} + {#if draft.selected === 'custom'} <div class="flex flex-col gap-1"> <div class="flex flex-row"> <ScriptPicker disabled={!isEditable || !$enterpriseLicense} kinds={['script', 'failure']} allowFlow={true} - bind:scriptPath={handlerPath} - bind:itemKind={customHandlerKind} + bind:scriptPath={draft.custom.path} + bind:itemKind={draft.custom.kind} allowRefresh={isEditable} workspace={effectiveWorkspace} clearable /> - {#if !handlerPath} + {#if !draft.custom.path} <Button btnClasses="ml-4 whitespace-nowrap" variant="default" @@ -454,7 +328,7 @@ </div> {/if} </div> - {#if handlerPath} + {#if draft.custom.path} <div> <p class="font-semibold text-xs mb-1">Extra arguments</p> {#await import('$lib/components/SchemaForm.svelte')} @@ -463,7 +337,7 @@ <Module.default disabled={!isEditable} schema={customHandlerSchema} - bind:args={handlerExtraArgs} + bind:args={draft.custom.args} shouldHideNoInputs className="text-xs" /> @@ -473,7 +347,7 @@ {/if} </div> {/if} - {:else if handlerSelected === 'slack'} + {:else if draft.selected === 'slack'} <!-- Slack Connection Status --> <SlackConnectionStatus isConnected={workspaceConnectedToSlack} @@ -485,23 +359,15 @@ {#if workspaceConnectedToSlack} <Toggle disabled={!$enterpriseLicense || !isEditable} - checked={isSlackHandler(handlerPath)} + checked={!!draft.slack.path} options={{ right: toggleText }} - on:change={async (e) => { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = slackHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = slackRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = slackSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.slack.path = e.detail ? builtinHubPath(errorOrRecovery, 'slack') : undefined }} /> {/if} - {#if workspaceConnectedToSlack && isSlackHandler(handlerPath)} + {#if workspaceConnectedToSlack && draft.slack.path} <div class="flex flex-col gap-2"> {#await import('$lib/components/SchemaForm.svelte')} <Loader2 class="animate-spin" /> @@ -513,18 +379,18 @@ schemaFieldTooltip={{ channel: 'Slack channel name without the "#" - example: "windmill-alerts"' }} - bind:args={handlerExtraArgs} + bind:args={draft.slack.args} shouldHideNoInputs className="text-xs" /> {/await} - {#if $enterpriseLicense && isSlackHandler(handlerPath)} + {#if $enterpriseLicense} <Button - disabled={emptyString(handlerExtraArgs['channel'])} + disabled={emptyString(draft.slack.args['channel'])} wrapperClasses="w-fit" variant="default" - on:click={() => sendSlackMessage(handlerExtraArgs['channel'])} + on:click={() => sendSlackMessage(draft.slack.args['channel'])} unifiedSize="md">Send test message</Button > {#if connectionTestJob !== undefined} @@ -543,7 +409,7 @@ <a target="_blank" - href={`${base}/run/${connectionTestJob.uuid}?workspace=${effectiveWorkspace}`} + href={`/run/${connectionTestJob.uuid}?workspace=${effectiveWorkspace}`} class="inline-flex items-center gap-1" > {connectionTestJob.uuid} @@ -557,7 +423,7 @@ {:else if workspaceConnectedToSlack == undefined} <Loader2 class="animate-spin" size={10} /> {/if} - {:else if handlerSelected === 'teams'} + {:else if draft.selected === 'teams'} <!-- Teams Connection Status --> <TeamsConnectionStatus isConnected={workspaceConnectedToTeams} @@ -569,18 +435,10 @@ {#if workspaceConnectedToTeams} <Toggle disabled={!$enterpriseLicense || !isEditable} - checked={isTeamsHandler(handlerPath)} + checked={!!draft.teams.path} options={{ right: toggleText }} - on:change={async (e) => { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = teamsHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = teamsRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = teamsSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.teams.path = e.detail ? builtinHubPath(errorOrRecovery, 'teams') : undefined }} /> {/if} @@ -602,26 +460,26 @@ placeholder="Search Teams channels" workspace={effectiveWorkspace} teamId={teams_team_guid} - selectedChannel={handlerExtraArgs['channel'] + selectedChannel={draft.teams.args['channel'] ? { - channel_id: handlerExtraArgs['channel'], - channel_name: handlerExtraArgs['channel_name'] + channel_id: draft.teams.args['channel'], + channel_name: draft.teams.args['channel_name'] } : undefined} onSelectedChannelChange={(channel) => { - handlerExtraArgs['channel'] = channel?.channel_id - handlerExtraArgs['channel_name'] = channel?.channel_name + draft.teams.args['channel'] = channel?.channel_id + draft.teams.args['channel_name'] = channel?.channel_name }} onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)} /> </div> </div> - {#if $enterpriseLicense && isTeamsHandler(handlerPath) && workspaceConnectedToTeams} + {#if $enterpriseLicense && draft.teams.path && workspaceConnectedToTeams} <Button - disabled={emptyString(handlerExtraArgs['channel'])} + disabled={emptyString(draft.teams.args['channel'])} btnClasses="w-32 text-center whitespace-nowrap" variant="default" - on:click={() => sendTeamsMessage(handlerExtraArgs['channel'] ?? '')} + on:click={() => sendTeamsMessage(draft.teams.args['channel'] ?? '')} size="xs">Send test message</Button > {#if connectionTestJob !== undefined} @@ -636,7 +494,7 @@ Message sent via Windmill job <a target="_blank" - href={`${base}/run/${connectionTestJob.uuid}?workspace=${effectiveWorkspace}`} + href={`/run/${connectionTestJob.uuid}?workspace=${effectiveWorkspace}`} > {connectionTestJob.uuid} </a> @@ -647,7 +505,7 @@ {:else if workspaceConnectedToTeams == undefined} <Loader2 class="animate-spin" size={10} /> {/if} - {:else if handlerSelected === 'email'} + {:else if draft.selected === 'email'} {#if isCloudHosted()} <Alert type="info" title="Email notifications are not available in Cloud"> Email notifications for trigger failures are only available in self-hosted Windmill @@ -660,8 +518,8 @@ <MultiSelect items={[] as { label: string; value: string }[]} bind:value={ - () => handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [], - (recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients) + () => draft.email.args.email_recipients ?? [], + (recipients) => (draft.email.args.email_recipients = recipients) } placeholder="Enter email addresses..." onCreateItem={(email) => { @@ -670,23 +528,24 @@ sendUserToast('Invalid email format', true) return } - const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [] - handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email] + draft.email.args.email_recipients = [ + ...(draft.email.args.email_recipients ?? []), + email + ] }} class="w-full" /> - {#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0} + {#if draft.email.args.email_recipients?.length > 0} <span class="text-xs text-secondary"> - {handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[ - EMAIL_RECIPIENTS_KEY - ]?.length === 1 + {draft.email.args.email_recipients.length} email{draft.email.args.email_recipients + .length === 1 ? '' : 's'} configured </span> {/if} </div> {/if} - {:else if handlerSelected === 'instance_alerts'} + {:else if draft.selected === 'instance_alerts'} <div class="flex flex-col gap-2"> <span class="text-xs text-secondary"> Failed jobs are reported to the Slack, Teams and email channels configured at the instance @@ -694,7 +553,7 @@ report is sent without adding an entry to the instance critical alert feed. Canceled jobs are not reported. </span> - <a class="text-xs w-fit" href="{base}/?workspace=admins#superadmin-settings"> + <a class="text-xs w-fit" href="/?workspace=admins#superadmin-settings"> Configure the instance critical alert channels </a> </div> diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index b4bc4471a2..0a65701b06 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -303,7 +303,12 @@ <script lang="ts"> import { twMerge } from 'tailwind-merge' - import { inputBaseClass, inputBorderClass, inputSizeClasses } from './text_input/TextInput.svelte' + import { + inputBaseClass, + inputBorderClass, + inputLeadingClasses, + inputSizeClasses + } from './text_input/TextInput.svelte' import { MinusIcon, SearchIcon } from 'lucide-svelte' import { assignObjInPlace, clone } from '$lib/utils' import GenericDropdown from './select/GenericDropdown.svelte' @@ -338,7 +343,7 @@ hideDropdownOnFreeText?: boolean // Notified whenever the dropdown's effective visibility changes onDropdownVisibleChange?: (visible: boolean) => void - size?: 'md' | 'lg' + size?: 'sm' | 'md' | 'lg' // On the right the icon gives way to the clear button once there is text; on the left // it stays put. searchIconPosition?: 'left' | 'right' @@ -747,7 +752,11 @@ 'overflow-x-auto !pr-24 bg-surface-input outline-none scrollbar-hidden text-nowrap flex-1 mr-2 mt-0.5', inputBaseClass, inputSizeClasses[size], - // The editable's fixed top padding centres the text at md height. + // The editable's own 2px top margin is what centres the text at md height, where its box + // is shorter than the row. At sm the two are the same height, so that margin only pushes + // the text below centre: drop it and take the leading the shared table defines, which is + // the row's height less its padding. + size === 'sm' && twMerge('!mt-0', inputLeadingClasses.sm), size === 'lg' && '!pt-[0.7rem]', searchIconPosition === 'left' && '!pl-1.5' )} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 7d92e08484..17f984bac9 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -28,7 +28,8 @@ userPathPrefix, type Item, type StateStore, - type Value + type Value, + emptyString } from '$lib/utils' import { sendUserToast } from '$lib/toast' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' @@ -36,7 +37,10 @@ import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte' - import { getContext, onDestroy, setContext, untrack } from 'svelte' + import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Button } from './common' @@ -106,13 +110,14 @@ import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from './sessions/openInSessionContext' import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext' - import { useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights' + import { useEditRights, useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights' import { useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() @@ -158,12 +163,16 @@ othersDraftsCount = 0, onOpenOthersDrafts, onTestJob, - condensedHeader = false + condensedHeader = false, + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader = false }: FlowBuilderProps = $props() // Top-bar button size + bar height. Condensed (session preview) uses the // smallest well-supported unified size (`sm`) so the bar is thinner. - const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') + // In the page header the buttons ride a 40px band, the same size the condensed session-pane + // header uses. + const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md') // The workspace this editor operates on: deploy, save-draft, trigger loading // and the AutosaveIndicator all target it. Falls back to the global store, so @@ -216,8 +225,19 @@ // Top-bar responsive collapse. Measured via bind:clientWidth — we can't // rely on viewport `md:` because the editor lives inside other panes // (session pane, drawer, etc.) where the viewport stays wide. + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state<string | undefined>(undefined) + let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has + // given all it can; anywhere else this editor's own top bar is theirs alone. + const compactTopbar = $derived( + ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720 + ) + // The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons + // stand down, since there is no room to run a flow beside its own name. + const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) const diffEnabled = $derived(customUi?.topBar?.diff != false) // Nothing to compare against until a deployed version exists. @@ -228,7 +248,7 @@ // The narrow bar (sessions) and the width-collapsed one have no room for a Diff // button, so it moves into the menu ahead of Deployment History instead of // dropping out of reach. - const diffInMenu = $derived(condensedHeader || compactTopbar) + const diffInMenu = $derived(condensedHeader || compactTopbar || phoneTopbar) const diffMenuItems: Item[] = $derived( diffEnabled && diffInMenu ? [ @@ -336,7 +356,7 @@ savedValue: savedFlow, modifiedValue: { ...flowStore.val, - // `$pathStore` is the live-edited path (the pen popover binds it). + // `$pathStore` is the live-edited path (the rename popover binds it). // `flowStore.val.path` doesn't track those edits, so without this the // rename wouldn't show up in the diff and the unsaved-changes warning // wouldn't fire when leaving with a pending rename. @@ -532,8 +552,14 @@ // flag that answer for it are per workspace, so the nav user would answer for the wrong // membership when a session editor operates on another workspace. const user = await getUserExt(opWorkspace ?? '').catch(() => undefined) + const operatorSettings = $userWorkspaces.find( + (w) => w.id === opWorkspace + )?.operator_settings agentCanWrite = Object.fromEntries( - draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user ?? $userStore ?? undefined)]) + draftAgents.map((a) => [ + a.path, + agentDraftCanWrite(a, user ?? $userStore ?? undefined, operatorSettings) + ]) ) // The path is passed, so a draft that renames the agent is refused here too: a rename is // the agent editor's to deploy, and this dialog lists the agent under the path the flow @@ -1278,6 +1304,16 @@ setContext('FlowCopilotContext', flowCopilotContext) + // The header's action buttons render under the page header, not under this component, so the + // contexts they look up have to travel with them. Read back here, after every setContext above. + const headerContexts = new Map<any, any>([ + ['FlowEditorContext', getContext('FlowEditorContext')], + ['TriggerContext', getContext('TriggerContext')], + ['FlowGraphAssetContext', getContext('FlowGraphAssetContext')], + ['FlowCopilotContext', flowCopilotContext], + ['customUi', customUi] + ]) + let renderCount = $state(0) let jsonViewerDrawer: Drawer | undefined = $state(undefined) @@ -1526,7 +1562,7 @@ <AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} /> {#key renderCount} - {#if !actingUser?.operator || $operatorBuilderFlows} + {#if editRights.roleCanAuthor('flow')} {#if $pathStore} <FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} /> {/if} @@ -1537,81 +1573,222 @@ <FlowEditorDrawer bind:this={$flowEditorDrawer} /> <div bind:this={flowBuilderRoot} class="flex flex-col h-full"> - <!-- Nav between steps--> - <div - bind:clientWidth={topbarWidth} - class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto h-full relative {condensedHeader - ? 'max-h-9' - : 'max-h-12'}" - > - <div class="flex flex-row items-center gap-2 min-w-0"> - {#if customUi?.topBar?.path != false} - <div class="min-w-0 overflow-hidden"> - <EditorHeader - bind:summary={flowStore.val.summary} - bind:path={$pathStore} - savedPath={initialPath} - onBehalfOfEmail={$savedOnBehalfOfEmail} - summaryEditable={customUi?.topBar?.editableSummary != false} - pathEditable={customUi?.topBar?.editablePath != false} - hidePath={condensedHeader} - workspaceId={autosaveWorkspace} - onNavigate={(item) => onNavigate?.(item)} + {#if ownsPageHeader} + <!-- The editor's own top bar is the page header on this route: its breadcrumb and + summary are the header's, and its buttons are the header's actions. --> + <!-- `separator="none"`: the canvas right below the bar draws its own top line, and the + bar's edge would be a second one on top of it. --> + <PageHeaderContent + item={{ + // Frozen while the rename popover is open so the trail holds still as the user types. + kind: 'flow', + path: pathSnapshot ?? $pathStore, + summary: flowStore.val.summary, + summaryContent: flowSummary, + // Gated like the summary's own editor: a host that allows neither field keeps the + // band's segment, which copies the path. + pathTrigger: + customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false + ? flowPathTrigger + : undefined + }} + actions={flowHeaderActions} + actionFolds={2} + contexts={headerContexts} + separator="none" + /> + {:else} + <!-- Nav between steps--> + <div + bind:clientWidth={topbarWidth} + class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto h-full relative {condensedHeader + ? 'max-h-9' + : 'max-h-12'}" + > + <div class="flex flex-row items-center gap-2 min-w-0"> + {#if customUi?.topBar?.path != false} + <div class="min-w-0 overflow-hidden"> + <EditorHeader + bind:summary={flowStore.val.summary} + bind:path={$pathStore} + savedPath={initialPath} + onBehalfOfEmail={$savedOnBehalfOfEmail} + summaryEditable={customUi?.topBar?.editableSummary != false} + pathEditable={customUi?.topBar?.editablePath != false} + hidePath={condensedHeader} + workspaceId={autosaveWorkspace} + onNavigate={(item) => onNavigate?.(item)} + /> + </div> + {/if} + {#if opWorkspace && indicatorPath !== undefined} + <AutosaveIndicator + workspace={opWorkspace} + itemKind="flow" + path={indicatorPath} + draftOnly={newFlow} + {onResetToDeployed} + {loadedFromDraft} + {othersDraftsCount} + {onOpenOthersDrafts} /> - </div> - {/if} - {#if opWorkspace && indicatorPath !== undefined} - <AutosaveIndicator - workspace={opWorkspace} - itemKind="flow" - path={indicatorPath} - draftOnly={newFlow} - {onResetToDeployed} - {loadedFromDraft} - {othersDraftsCount} - {onOpenOthersDrafts} - /> - {/if} - </div> - <div class="flex flex-row gap-2 items-center shrink-0"> - {#if $enterpriseLicense && !newFlow && !inSessionPane} - <Awareness /> - {/if} - <Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} /> - {#if diffEnabled && !diffInMenu} - <!-- A disabled <button> fires no pointer events, so a title/tooltip on + {/if} + </div> + <div class="flex flex-row gap-2 items-center shrink-0"> + {#if $enterpriseLicense && !newFlow && !inSessionPane} + <Awareness /> + {/if} + <Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} /> + {#if diffEnabled && !diffInMenu} + <!-- A disabled <button> fires no pointer events, so a title/tooltip on it never shows on hover. pointer-events-none on the button lets the hover reach this titled wrapper instead. --> - <div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}> - <Button - variant="default" - unifiedSize={headerBtnSize} - on:click={() => openDiffDrawer()} - disabled={diffDisabled} - btnClasses={diffDisabled ? 'pointer-events-none' : undefined} - title={diffTitle} - startIcon={{ icon: DiffIcon }} - > - Diff - </Button> - </div> - {/if} - {#if !compactTopbar} - {@render previewButtons()} - {/if} + <div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}> + <Button + variant="default" + unifiedSize={headerBtnSize} + on:click={() => openDiffDrawer()} + disabled={diffDisabled} + btnClasses={diffDisabled ? 'pointer-events-none' : undefined} + title={diffTitle} + startIcon={{ icon: DiffIcon }} + > + Diff + </Button> + </div> + {/if} + {#if !compactTopbar} + {@render previewButtons()} + {/if} - <DeployButton - on:save={async ({ detail }) => await handleSaveFlow(detail)} - {loading} - {loadingSave} - unifiedSize={headerBtnSize} - {dropdownItems} - /> + <DeployButton + on:save={async ({ detail }) => await handleSaveFlow(detail)} + {loading} + {loadingSave} + unifiedSize={headerBtnSize} + {dropdownItems} + /> + </div> </div> - </div> - <!-- Rendered either inline in the top bar (wide) or as a graph overlay - (compactTopbar). Crossing the 720px threshold remounts - FlowPreviewButtons; any open preview state will reset. --> + {/if} + + <!-- The summary's editor again, hung off the band's path segment so it opens under the + path, with the cursor in the path field. `bind:` cannot be spread, so the slots are + written out twice; both instances bind the same ones and only one is ever open. --> + {#snippet flowPathTrigger(pathLabel: Snippet, triggerClass: string)} + <!-- Gated the way the script editor gates its own, `{:else}` included: this renders from + the band's tree, which outlives what it reads here by a flush, and the band has + already given up its own segment by the time this decides. --> + {#if flowStore?.val} + <PathEditPopover + label={pathLabel} + {triggerClass} + focusField="path" + bind:summary={flowStore.val.summary} + summaryEditable={customUi?.topBar?.editableSummary != false} + pathEditable={customUi?.topBar?.editablePath != false} + bind:path={$pathStore} + bind:snapshotPath={pathSnapshot} + savedPath={initialPath} + kind="flow" + onBehalfOfEmail={$savedOnBehalfOfEmail} + workspaceId={autosaveWorkspace} + /> + {:else} + <span class={triggerClass}>{@render pathLabel()}</span> + {/if} + {/snippet} + + {#snippet flowSummary()} + <!-- Not edited in place: clicking the name opens the summary and the path together, so + the band reads as a name rather than a form. `title` for one it truncates. --> + <div class="flex items-center gap-1 min-w-0"> + {#snippet summaryText()} + <span + class="min-w-0 truncate text-xs {emptyString(flowStore.val.summary) + ? 'text-tertiary italic font-normal' + : 'font-medium text-emphasis'}" + title={flowStore.val.summary} + >{emptyString(flowStore.val.summary) + ? 'Add a summary...' + : flowStore.val.summary}</span + > + {/snippet} + {#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false} + <PathEditPopover + label={summaryText} + bind:summary={flowStore.val.summary} + summaryEditable={customUi?.topBar?.editableSummary != false} + pathEditable={customUi?.topBar?.editablePath != false} + bind:path={$pathStore} + bind:snapshotPath={pathSnapshot} + savedPath={initialPath} + kind="flow" + onBehalfOfEmail={$savedOnBehalfOfEmail} + workspaceId={autosaveWorkspace} + /> + {:else} + {@render summaryText()} + {/if} + </div> + {/snippet} + + {#snippet flowHeaderActions()} + <!-- Saving state, the autosave toggle and other people's drafts: the editor's own + bar carries them beside the path, so the header has to carry them when it owns + the bar — without them a full-page editor saves with no word either way. --> + {#if opWorkspace && indicatorPath !== undefined} + <AutosaveIndicator + workspace={opWorkspace} + itemKind="flow" + path={indicatorPath} + draftOnly={newFlow} + {onResetToDeployed} + {loadedFromDraft} + {othersDraftsCount} + {onOpenOthersDrafts} + /> + {/if} + {#if $enterpriseLicense && !newFlow && !inSessionPane} + <Awareness /> + {/if} + <Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} /> + {#if diffEnabled && !diffInMenu} + <!-- A disabled <button> fires no pointer events, so a title/tooltip on + it never shows on hover. pointer-events-none on the button lets the + hover reach this titled wrapper instead. --> + <div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}> + <Button + variant="default" + unifiedSize={headerBtnSize} + on:click={() => openDiffDrawer()} + disabled={diffDisabled} + btnClasses={diffDisabled ? 'pointer-events-none' : undefined} + title={diffTitle} + startIcon={{ icon: DiffIcon }} + > + Diff + </Button> + </div> + {/if} + {#if !compactTopbar && !phoneTopbar} + {@render previewButtons()} + {/if} + + <DeployButton + on:save={async ({ detail }) => await handleSaveFlow(detail)} + {loading} + {loadingSave} + unifiedSize={headerBtnSize} + {dropdownItems} + /> + {/snippet} + + <!-- Rendered either inline in the top bar (wide) or as a graph overlay. The two + conditions are exact complements: testing the flow is reachable at every width, + and FlowPreviewButtons — which owns the preview state and the handles the graph + calls into — is mounted exactly once. Crossing a threshold remounts it, so any + open preview resets. --> {#snippet previewButtons()} <FlowPreviewButtons {suspendStatus} @@ -1641,7 +1818,7 @@ {#if flowStateStore.val} <FlowEditor bind:this={flowEditor} - graphOverlay={compactTopbar ? previewButtons : undefined} + graphOverlay={compactTopbar || phoneTopbar ? previewButtons : undefined} {disabledFlowInputs} disableAi={disableAi || customUi?.stepInputs?.ai == false} disableSettings={customUi?.settingsPanel === false} diff --git a/frontend/src/lib/components/FlowLogViewer.svelte b/frontend/src/lib/components/FlowLogViewer.svelte index 6a8db18a76..0efbdc332e 100644 --- a/frontend/src/lib/components/FlowLogViewer.svelte +++ b/frontend/src/lib/components/FlowLogViewer.svelte @@ -10,7 +10,6 @@ ExternalLink, Keyboard } from 'lucide-svelte' - import { base } from '$lib/base' import ObjectViewer from './propertyPicker/ObjectViewer.svelte' import LogViewer from './LogViewer.svelte' import FlowLogViewer from './FlowLogViewer.svelte' @@ -98,7 +97,7 @@ function getJobLink(jobId: string | undefined): string { if (!jobId) return '' - return `${base}/run/${jobId}?workspace=${workspaceId ?? $operatingWorkspace}` + return `/run/${jobId}?workspace=${workspaceId ?? $operatingWorkspace}` } function getStatusColor(status: FlowStatusModule['type'] | undefined): string { diff --git a/frontend/src/lib/components/FlowMetadata.svelte b/frontend/src/lib/components/FlowMetadata.svelte index 022fa874a2..b28e694bc3 100644 --- a/frontend/src/lib/components/FlowMetadata.svelte +++ b/frontend/src/lib/components/FlowMetadata.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { type Job } from '$lib/gen' - import { base } from '$lib/base' import JobStatus from '$lib/components/JobStatus.svelte' import { flowPathToHref } from '$lib/scripts' import { displayDate, truncateRev } from '$lib/utils' @@ -58,7 +57,7 @@ <BarsStaggered size={SMALL_ICON_SIZE} class="min-w-3.5" /> <span class="whitespace-nowrap"> Step of flow - <a href={`${base}/run/${job.parent_job}?workspace=${$workspaceStore}`}> + <a href={`/run/${job.parent_job}?workspace=${$workspaceStore}`}> {truncateRev(job.parent_job, 18)} </a> </span> @@ -68,7 +67,7 @@ <Bot size={SMALL_ICON_SIZE} class="min-w-3.5" /> <span class="whitespace-nowrap"> Triggered by parent - <a href={`${base}/run/${job.parent_job}?workspace=${$workspaceStore}`}> + <a href={`/run/${job.parent_job}?workspace=${$workspaceStore}`}> {job.parent_job}</a > </span> @@ -96,7 +95,7 @@ {@const stem = `${job?.job_kind}s`} {@const isScript = job?.job_kind === 'script'} {@const viewHref = isScript - ? `${base}/${stem}/get/${job?.script_hash}` + ? `/${stem}/get/${job?.script_hash}` : flowPathToHref(job?.script_path ?? '')} <div class="flex flex-row gap-2 items-center"> {#if isScript} @@ -128,6 +127,6 @@ </div> <div class="text-secondary text-2xs pt-2 whitespace-nowrap"> run id: - <a href={`${base}/run/${job.id}?workspace=${job.workspace_id}`}> {job.id} </a> + <a href={`/run/${job.id}?workspace=${job.workspace_id}`}> {job.id} </a> </div> </div> diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 1d56c20b38..489d6c4769 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -15,7 +15,6 @@ type FlowNote, type FlowValue } from '$lib/gen' - import { base } from '$lib/base' import FlowJobResult from './FlowJobResult.svelte' import WorkflowTimeline from './WorkflowTimeline.svelte' import DisplayResult from './DisplayResult.svelte' @@ -69,6 +68,7 @@ retainLinkedToolsScope } from './flows/linkedAgentToolsStore.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { AGENT_STEP_ID, agentStepLabel } from './flows/conversations/agentEditorChat' const operatingWorkspace = useOperatingWorkspace() @@ -1238,6 +1238,8 @@ id: string type: FlowModuleValue['type'] suspend?: boolean + /** Shown in place of the id. */ + label?: string } function allModulesForTimeline( @@ -1250,7 +1252,8 @@ ({ id: x.id, type: x.value.type, - suspend: x.suspend != undefined + suspend: x.suspend != undefined, + label: x.id === AGENT_STEP_ID ? agentStepLabel(job?.script_path) : undefined }) as FlowModuleForTimeline, { skipToolNodes: true @@ -1933,7 +1936,7 @@ </Button> {:else} <Button - href="{base}/api{logsApiPath}" + href="/api{logsApiPath}" download={logsName} color="light" size="xs" @@ -2195,7 +2198,7 @@ class="text-right text-xs" rel="noreferrer" target="_blank" - href="{base}/run/{node.job_id ?? + href="/run/{node.job_id ?? ''}?workspace={job?.workspace_id}" > {truncateRev(node.job_id ?? '', 10)} diff --git a/frontend/src/lib/components/FlowTimeline.svelte b/frontend/src/lib/components/FlowTimeline.svelte index 4ed13c1df1..640c8728ce 100644 --- a/frontend/src/lib/components/FlowTimeline.svelte +++ b/frontend/src/lib/components/FlowTimeline.svelte @@ -179,12 +179,12 @@ /> </div> {/if} - {#each flowModules as { id: k, type: typ, suspend: isSuspend } (k)} + {#each flowModules as { id: k, type: typ, suspend: isSuspend, label } (k)} {@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)} <div class="relative px-3 py-1.5"> <div class="flex items-center justify-between mb-0.5"> <div class="text-xs font-medium" - >{k.startsWith('subflow:') ? k.substring(8) : k} + >{label ?? (k.startsWith('subflow:') ? k.substring(8) : k)} {#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')} <span class="text-xs font-mono font-medium inline-flex items-center -my-2"> <button onclick={(e) => e.stopPropagation()}> diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index 940c579fd7..11779afb11 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -1,14 +1,16 @@ <script lang="ts"> + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' + import { Badge } from './common' + import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte' import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores' - import { ScriptService } from '$lib/gen' import type { WorkspaceComparison } from '$lib/gen' import { fetchWorkspaceComparison } from '$lib/workspaceComparison' import { Button } from './common' - import { AlertTriangle, GitFork, CircleCheck, CircleX, Loader2 } from 'lucide-svelte' import { goto } from '$app/navigation' import { onMount, untrack } from 'svelte' import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' - import { childWorkspaceNoun, devLabelWord } from '$lib/utils/devWorkspaceLabel' + import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel' import { diffActionableInDirection } from '$lib/utils_workspace_deploy' let loading = $state(false) @@ -20,12 +22,10 @@ let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore)) let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id) - let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId)) // Detect fork/dev workspaces by their parent link, not the `wm-fork-` id prefix (dev // workspaces have an ordinary, prefix-less id). Keying on the parent (rather than the // prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped. let isFork = $derived(parentWorkspaceId != null) - let isDevWorkspace = $derived(currentWorkspaceData?.is_dev_workspace ?? false) let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceData)) // Operators run scripts and flows, they never deploy a fork, so the banner and // its CTA are noise for them. Gates the fetches too, not just the markup: the @@ -57,6 +57,11 @@ } const hasAnswer = $derived(isAnswerable(comparison, loading)) + /** Bar width from which each badge says what it counts. Below it they keep the icon and the + * number, which is the part a reader scans for; the tooltip names them either way. */ + const BAR_FITS_COUNT_LABELS = 1280 + const compactCounts = $derived(pageHeader.barWidth < BAR_FITS_COUNT_LABELS) + // Fork is fully in sync with its parent (comparison ran, no ahead/behind diffs). function isUpToDate(c: WorkspaceComparison | undefined): boolean { return !!c && !c.skipped_comparison && c.summary.total_diffs === 0 @@ -67,14 +72,12 @@ let showDraftsOnly = $derived(upToDate && draftCount > 0) // Leaving for a workspace with no comparison of its own has to invalidate whatever - // is in flight too, or that response lands as this one's answer — and its CI counts - // would be fetched for paths this workspace may not even have. + // is in flight too, or that response lands as this one's answer. function dropComparison() { requestSeq++ comparison = undefined comparisonFor = undefined loading = false - resetCiTestSummary() } // `isNotOperator` is a dependency of its own: it only turns true once this @@ -113,7 +116,6 @@ if (comparisonFor !== ws) { comparison = undefined comparisonFor = undefined - resetCiTestSummary() } const seq = ++requestSeq loading = true @@ -142,6 +144,18 @@ // Opens the direction the button offers, so the label and the list agree: a fork // with nothing to deploy lands on the update side, not on an empty deploy list. + /** The compare page opened on one direction, for a badge that counts exactly that. The button + * beside them still chooses on the reader's behalf; a badge means the one thing it names. */ + function openCompareDir(dir: 'deploy_to' | 'update') { + if (!parentWorkspaceId || !$workspaceStore) return + goto( + '/forks/compare?workspace_id=' + + encodeURIComponent($workspaceStore) + + (dir === 'update' ? '&dir=update' : ''), + { replaceState: true } + ) + } + function openComparisonDrawer() { if (parentWorkspaceId && $workspaceStore) { const dir = hasAnswer && changesAhead === 0 ? '&dir=update' : '' @@ -159,68 +173,6 @@ } } - let ciTestPassing = $state(0) - let ciTestFailing = $state(0) - let ciTestRunning = $state(0) - let ciTestTotal = $state(0) - - // These describe the rows of one comparison, so they are dropped with it. - function resetCiTestSummary() { - ciTestPassing = 0 - ciTestFailing = 0 - ciTestRunning = 0 - ciTestTotal = 0 - } - - async function fetchCiTestSummary() { - if (!$workspaceStore || !comparison?.diffs) return - const items = comparison.diffs - .filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource') - .map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' })) - if (items.length === 0) return - // Counted for the comparison current at call time — the poll below outlives a - // fork switch, and a slow batch for the fork we left would repaint this one's. - const seq = requestSeq - try { - const batch = await ScriptService.getCiTestResultsBatch({ - workspace: $workspaceStore, - requestBody: { items } - }) - if (seq !== requestSeq) return - let passing = 0 - let failing = 0 - let running = 0 - let total = 0 - for (const results of Object.values(batch)) { - for (const r of results) { - total++ - if (r.status === 'success') passing++ - else if (r.status === 'failure' || r.status === 'canceled') failing++ - else if (r.status === 'running' || (r.job_id && !r.status)) running++ - } - } - ciTestPassing = passing - ciTestFailing = failing - ciTestRunning = running - ciTestTotal = total - } catch (e) { - console.error('Failed to fetch CI test summary:', e) - } - } - - $effect(() => { - if (comparison && comparison.summary.total_diffs > 0) { - fetchCiTestSummary() - } - }) - - // Poll while any CI test is still running - $effect(() => { - if (ciTestRunning <= 0) return - const interval = setInterval(fetchCiTestSummary, 3000) - return () => clearInterval(interval) - }) - // Counted with the compare page's own predicate so the banner never advertises a // direction whose list is empty: the `ahead`/`behind` sums in the summary include // rows a direction does not carry, and miss a parent-only row that the update @@ -246,191 +198,113 @@ </script> {#if showBanner} - <!-- Side padding mirrors the page content container below, so the banner - stays aligned with it instead of bleeding to the viewport edges. --> - <div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2"> - <div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2"> - <!-- The summary wraps inside its own column while the CTA keeps its width and - stays on the first line: laid out as one non-wrapping row, the summary is - long enough on a laptop-width viewport to push the button out of the - banner instead of getting shorter. --> - <div class="flex items-center justify-between gap-x-3"> - <div class="flex items-center flex-wrap gap-x-3 gap-y-1 min-w-0"> - <GitFork class="w-4 h-4 text-accent shrink-0" /> - <div class="text-sm min-w-0"> - <span class="font-medium text-blue-900 dark:text-blue-100"> - {isDevWorkspace - ? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of` - : 'Fork of'} - <b>{parentWorkspaceData?.name}</b - >{#if parentWorkspaceData?.name !== parentWorkspaceId} - ({parentWorkspaceId}){/if} - </span> - </div> - - {#if loading} - <span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span> - {:else if error} - <span class="text-xs text-red-600 dark:text-red-400"> - {error} - </span> - {:else if comparison} - <div class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs min-w-0"> - {#if comparison.summary.total_diffs > 0} - <span class="text-blue-700 dark:text-blue-100"> - {forkAheadBehindMessage(changesAhead, changesBehind)} - <span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison - .summary.total_diffs} items<span class="hidden lg:inline">:</span> - </span> - <!-- The per-kind breakdown is the first thing to go on a narrow - viewport: the item total above already sizes the change set, and - the compare page carries the detail. --> - <div - class="hidden lg:flex items-center flex-wrap gap-x-2 gap-y-1 whitespace-nowrap" - > - {#if comparison.summary.scripts_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.scripts_changed} script{comparison.summary - .scripts_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.flows_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.apps_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.resources_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.resources_changed} resource{comparison.summary - .resources_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.variables_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.variables_changed} variable{comparison.summary - .variables_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.resource_types_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.resource_types_changed} resource type{comparison.summary - .resource_types_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.folders_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.folders_changed} folder{comparison.summary - .folders_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.schedules_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.schedules_changed} schedule{comparison.summary - .schedules_changed !== 1 - ? 's' - : ''} - </span> - {/if} - {#if comparison.summary.triggers_changed > 0} - <span class="text-blue-700 dark:text-blue-100"> - {comparison.summary.triggers_changed} trigger{comparison.summary - .triggers_changed !== 1 - ? 's' - : ''} - </span> - {/if} - </div> - - {#if ciTestTotal > 0} - - - {#if ciTestFailing > 0} - <div - class="flex items-center gap-1 text-red-600 dark:text-red-400 whitespace-nowrap" - > - <CircleX class="w-3 h-3" /> - <span>CI: {ciTestFailing} failing</span> - </div> - {:else if ciTestRunning > 0} - <div - class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400 whitespace-nowrap" - > - <Loader2 class="w-3 h-3 animate-spin" /> - <span>CI: {ciTestRunning} running</span> - </div> - {:else} - <div - class="flex items-center gap-1 text-green-600 dark:text-green-400 whitespace-nowrap" - > - <CircleCheck class="w-3 h-3" /> - <span>CI: {ciTestPassing} passing</span> - </div> - {/if} - {/if} - - {#if comparison.summary.conflicts > 0} - - - <div - class="flex items-center gap-1 text-orange-600 dark:text-orange-400 whitespace-nowrap" - > - <AlertTriangle class="w-3 h-3" /> - <span - >{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 - ? 's' - : ''}</span - > - </div> - {/if} - {:else if comparison.skipped_comparison} - <span class="text-blue-600 dark:text-blue-200"> - This {currentNoun} was created before the addition of certain windmill features, and - therefore the changes with its parent workspace cannot be displayed.</span - > - {:else if showDraftsOnly} - <span class="text-blue-700 dark:text-blue-100"> - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - </span> - {:else} - <span class="text-blue-600 dark:text-blue-200"> Everything is up to date </span> - {/if} - </div> - {/if} - </div> - - <div class="flex items-center gap-2 shrink-0"> - <Button - variant="default" - unifiedSize="sm" - onclick={showDraftsOnly ? openDraftCompare : openComparisonDrawer} - > - {#if showDraftsOnly} - Review & deploy drafts - {:else if !hasAnswer || changesAhead > 0} - Review & Deploy Changes - {:else} - Review & Update {currentNoun} - {/if} - </Button> - </div> - </div> - </div> - </div> + <!-- In the band, not a banner: the breadcrumb already says which fork this is, so all this has + to carry is how far it has drifted and the way to review that. The per-kind breakdown, the + CI line and the conflict count live on the compare page the button opens. --> + <PageHeaderContent actions={forkAction} /> {/if} + +{#snippet forkAction()} + {@const total = comparison?.summary.total_diffs ?? 0} + {@const conflicts = comparison?.summary.conflicts ?? 0} + <!-- What the comparison has to say is a mark beside the button, never instead of it: a fork + still being counted, or one whose count failed, is exactly when a reader wants the page + that does the counting properly. The button below renders in every state. --> + {#if loading} + <span class="text-2xs text-tertiary">Checking for changes…</span> + {:else if error} + <span class="text-2xs text-red-600 dark:text-red-400" title={error}>Comparison failed</span> + {:else if showDraftsOnly} + <Badge color="blue" small> + {draftCount} draft{draftCount !== 1 ? 's' : ''} + </Badge> + {:else if total > 0} + <!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has + to give its parent, behind is what it has yet to take. --> + <!-- The colours and the icons the compare page gives these three, so the counts in the bar + and the rows behind the button say the same thing the same way. --> + {#if changesAhead > 0} + <Badge + color="green" + small + clickable + onclick={() => openCompareDir('deploy_to')} + title="{changesAhead} ahead of {parentWorkspaceId} — review what this {currentNoun} has to give" + > + <ArrowUp class="w-3 h-3 inline" /> + {changesAhead}{compactCounts ? '' : ' ahead'} + </Badge> + {/if} + {#if changesBehind > 0} + <Badge + color="blue" + small + clickable + onclick={() => openCompareDir('update')} + title="{changesBehind} behind {parentWorkspaceId} — review what this {currentNoun} has yet to take" + > + <ArrowDown class="w-3 h-3 inline" /> + {changesBehind}{compactCounts ? '' : ' behind'} + </Badge> + {/if} + {#if conflicts > 0} + <!-- A conflict is ahead and behind at once, so it has no direction of its own; it opens + the side the reader resolves it from before deploying. --> + <Badge + color="orange" + small + clickable + onclick={() => openCompareDir('deploy_to')} + title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''} — review them" + > + <AlertTriangle class="w-3 h-3 inline" /> + {conflicts}{compactCounts ? '' : ` conflict${conflicts !== 1 ? 's' : ''}`} + </Badge> + {/if} + <!-- Drafts are a different axis from the drift beside them: ahead and behind count deployed + items against the parent, this counts work in this fork that is not deployed anywhere + yet. It shares blue with "behind" and is told apart by the pencil and by naming what it + counts, rather than by an arrow that would read as a third direction. Clicking goes to + the drafts half of the compare page rather than the drawer the button opens. It shows at + every drift — a fork both behind and holding drafts said nothing about the drafts. --> + {#if draftCount > 0} + <Badge + color="blue" + small + clickable + onclick={openDraftCompare} + title="{draftCount} draft{draftCount !== 1 + ? 's' + : ''} in this {currentNoun}, not deployed anywhere yet — review and deploy" + > + <Pencil class="w-3 h-3 inline" /> + {draftCount}{compactCounts ? '' : ` draft${draftCount !== 1 ? 's' : ''}`} + </Badge> + {/if} + {/if} + {#if showDraftsOnly} + <Button + variant="subtle" + unifiedSize="sm" + onclick={openDraftCompare} + title={`Review and deploy this workspace's drafts`} + > + Review & deploy drafts + </Button> + {:else} + <Button + variant="subtle" + unifiedSize="sm" + onclick={openComparisonDrawer} + title={hasAnswer + ? `${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items` + : `Review this ${currentNoun} against ${parentWorkspaceId}`} + > + {#if !hasAnswer || changesAhead > 0} + Review changes + {:else} + Review & Update {currentNoun} + {/if} + </Button> + {/if} +{/snippet} diff --git a/frontend/src/lib/components/GitRepoViewer.svelte b/frontend/src/lib/components/GitRepoViewer.svelte index 53ddea2a96..d00449f168 100644 --- a/frontend/src/lib/components/GitRepoViewer.svelte +++ b/frontend/src/lib/components/GitRepoViewer.svelte @@ -3,7 +3,6 @@ import { untrack } from 'svelte' import { Alert, Button } from './common' import S3FilePickerInner from './S3FilePickerInner.svelte' - import { base } from '$lib/base' import { Loader, Loader2, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte' import { hubPaths } from '$lib/hub' import { sleep } from '$lib/utils' @@ -237,7 +236,7 @@ {#if runningJobId} <a class="inline-flex items-center gap-1 mt-2 text-sm underline" - href={`${base}/run/${runningJobId}?workspace=${ws}`} + href={`/run/${runningJobId}?workspace=${ws}`} target="_blank" rel="noreferrer noopener" > @@ -272,7 +271,7 @@ {#if runningJobId} <a class="inline-flex items-center gap-1 text-xs underline text-secondary" - href={`${base}/run/${runningJobId}?workspace=${ws}`} + href={`/run/${runningJobId}?workspace=${ws}`} target="_blank" rel="noreferrer noopener" > diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index f8d0445840..b4229cf9f6 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -15,7 +15,7 @@ import Row from './table/Row.svelte' import Cell from './table/Cell.svelte' import { sendUserToast } from '$lib/toast' - import { canWrite } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import Label from './Label.svelte' @@ -42,6 +42,7 @@ const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) + const editRights = useEditRights() const ROLE_TOOLTIPS = { member: @@ -92,7 +93,7 @@ // Derived for the same reason as the trigger editors: the acting user's role can land after // the group does, and a snapshot taken first would stay read-only. const can_write = $derived( - writeVerdict ?? (groupPerms !== undefined && canWrite(name, groupPerms, actingUser)) + writeVerdict ?? (groupPerms !== undefined && editRights.canEditItem('group', name, groupPerms)) ) let group: Group | undefined let instance_group: InstanceGroup | undefined = $state() diff --git a/frontend/src/lib/components/HeaviestScriptsDrawer.svelte b/frontend/src/lib/components/HeaviestScriptsDrawer.svelte new file mode 100644 index 0000000000..a4591924c9 --- /dev/null +++ b/frontend/src/lib/components/HeaviestScriptsDrawer.svelte @@ -0,0 +1,201 @@ +<script lang="ts"> + import { WorkerService } from '$lib/gen' + import { resource } from 'runed' + import { Alert, Drawer, DrawerContent, Section, Skeleton } from './common' + import TextInput from './text_input/TextInput.svelte' + import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import Select from './select/Select.svelte' + import DataTable from './table/DataTable.svelte' + import Head from './table/Head.svelte' + import Cell from './table/Cell.svelte' + import { formatMemory, msToReadableTime } from '$lib/utils' + import { logFeatureUsage } from '$lib/utils/featureUsage' + + interface Props { + workerGroups: string[] + } + + let { workerGroups }: Props = $props() + + const WINDOWS = { + '24h': 24 * 3600, + '7d': 7 * 24 * 3600, + '30d': 30 * 24 * 3600 + } + const ORDERS = { + total_duration: 'Total runtime', + max_memory: 'Max memory', + cpu: 'CPU time' + } as const + type OrderBy = keyof typeof ORDERS + // The server gathers every run without a stable path (previews, dependency jobs) under this one. + const ADHOC_PATH = '<adhoc>' + + let drawer: Drawer | undefined = $state() + let opened = $state(false) + let windowKey: keyof typeof WINDOWS = $state('24h') + let orderBy: OrderBy = $state('total_duration') + let workerGroup: string | undefined = $state() + let workspace = $state('') + + export function openDrawer(group?: string) { + workerGroup = group + opened = true + drawer?.openDrawer() + logFeatureUsage('worker_resource_stats', 'opened') + } + + const stats = resource( + () => ({ opened, windowKey, orderBy, workerGroup, workspace: workspace.trim() }), + async (q, _, { signal }) => { + if (!q.opened) return undefined + try { + return await WorkerService.getRunnableStats({ + windowSecs: WINDOWS[q.windowKey], + orderBy: q.orderBy, + workerGroup: q.workerGroup || undefined, + workspace: q.workspace || undefined + }) + } finally { + // A slower answer for filters no longer selected must not replace the current one: + // `resource` drops the abort error thrown in its place. + signal.throwIfAborted() + } + }, + { debounce: 200 } + ) + + // Many scripts run for well under a second, which `msToReadableTime` rounds to "0.0s". + function duration(ms: number) { + return ms < 1000 ? `${Math.round(ms)}ms` : msToReadableTime(ms, 1) + } + + function cores(cpuMs: number, durationMs: number) { + return durationMs > 0 ? (cpuMs / durationMs).toFixed(2) : '-' + } +</script> + +<Drawer bind:this={drawer} size="1100px" on:close={() => (opened = false)}> + <DrawerContent title="Heaviest scripts" on:close={drawer?.closeDrawer}> + <Section + label="Resource usage per script" + tooltip="Aggregated by the workers once an hour, per script and worker group, so the last hour is incomplete. Memory is the peak of the job's main process. CPU time counts that process and the child processes it waited for. Scripts run inside the worker process (SQL, native TypeScript) and jobs of dedicated workers report neither, and a killed job reports no CPU time." + > + {#snippet action()} + <ToggleButtonGroup bind:selected={windowKey} noWFull> + {#snippet children({ item })} + {#each Object.keys(WINDOWS) as key (key)} + <ToggleButton value={key} label={key} size="sm" {item} /> + {/each} + {/snippet} + </ToggleButtonGroup> + {/snippet} + + <div class="flex flex-wrap items-center gap-4 pb-4"> + <div class="flex items-center gap-2"> + <span class="text-xs text-secondary">Sort by</span> + <ToggleButtonGroup + bind:selected={ + () => orderBy, + (v) => { + orderBy = v + logFeatureUsage('worker_resource_stats', 'sort', { key: v }) + } + } + noWFull + > + {#snippet children({ item })} + {#each Object.entries(ORDERS) as [value, label] (value)} + <ToggleButton {value} {label} size="sm" {item} /> + {/each} + {/snippet} + </ToggleButtonGroup> + </div> + <div class="w-48"> + <Select + items={workerGroups.map((g) => ({ label: g, value: g }))} + bind:value={workerGroup} + placeholder="All worker groups" + clearable + size="sm" + /> + </div> + <div class="w-48"> + <TextInput + bind:value={workspace} + inputProps={{ placeholder: 'Workspace id' }} + size="sm" + /> + </div> + </div> + + {#if stats.error} + <Alert type="error" title="Failed to load the script statistics"> + {stats.error.message} + </Alert> + {:else if stats.current === undefined} + <Skeleton layout={[[8]]} /> + {:else if stats.current.length === 0} + <p class="text-secondary text-xs">No jobs were recorded for this selection.</p> + {:else} + <DataTable size="sm" noBorder={false} rounded={true}> + <Head> + <tr> + <Cell head first>Script</Cell> + <Cell head>Workspace</Cell> + <Cell head>Worker group</Cell> + <Cell head numeric>Jobs</Cell> + <Cell head numeric>Total runtime</Cell> + <Cell head numeric>Avg runtime</Cell> + <Cell head numeric>Max memory</Cell> + <Cell head numeric>Avg memory</Cell> + <Cell head numeric>CPU time</Cell> + <Cell head numeric last>Avg CPUs</Cell> + </tr> + </Head> + <tbody> + {#each stats.current as s (s.workspace_id + '\0' + s.runnable_path + '\0' + s.worker_group)} + <tr class="border-b last:border-b-0"> + <Cell first class="text-xs text-primary"> + {#if s.runnable_path === ADHOC_PATH} + <span class="text-secondary">Previews and other ad-hoc runs</span> + {:else} + <span class="font-mono">{s.runnable_path}</span> + {/if} + </Cell> + <Cell class="text-xs text-primary">{s.workspace_id}</Cell> + <Cell class="text-xs text-primary">{s.worker_group}</Cell> + <Cell numeric class="text-xs text-primary">{s.job_count}</Cell> + <Cell numeric class="text-xs text-primary"> + {duration(s.total_duration_ms)} + </Cell> + <Cell numeric class="text-xs text-primary"> + <!-- A workflow suspended and not yet completed has time recorded and no job. --> + {s.job_count > 0 ? duration(s.total_duration_ms / s.job_count) : '-'} + </Cell> + {#if s.memory_sample_count > 0} + <Cell numeric class="text-xs text-primary"> + {formatMemory(s.max_memory_peak)} + </Cell> + <Cell numeric class="text-xs text-primary"> + {formatMemory(Math.round(s.sum_memory_peak / s.memory_sample_count))} + </Cell> + {:else} + <Cell numeric class="text-xs text-secondary">-</Cell> + <Cell numeric class="text-xs text-secondary">-</Cell> + {/if} + <Cell numeric class="text-xs text-primary"> + {duration(s.total_cpu_ms)} + </Cell> + <Cell numeric last class="text-xs text-primary"> + {cores(s.total_cpu_ms, s.total_duration_ms)} + </Cell> + </tr> + {/each} + </tbody> + </DataTable> + {/if} + </Section> + </DrawerContent> +</Drawer> diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 1476b9ad87..a1a0f214d3 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -29,6 +29,7 @@ import SettingsPageHeader from './settings/SettingsPageHeader.svelte' import WorkspaceRegistries from './instanceSettings/WorkspaceRegistries.svelte' import DbHealth from './instanceSettings/DbHealth.svelte' + import OidcSigningStatus from './instanceSettings/OidcSigningStatus.svelte' interface Props { tab?: string @@ -1186,6 +1187,15 @@ description="On-demand database diagnostics. Analyze table sizes, job retention, connection pool health, vacuum status, and more." /> <DbHealth /> + {:else if category == 'OIDC signing'} + <SettingsPageHeader + title="OIDC signing" + description="What signs the OIDC tokens Windmill issues, and the keys its JWKS serves to verifiers. Read-only: signing is configured with environment variables." + link="https://www.windmill.dev/docs/core_concepts/oidc#signing-key" + /> + {#if $enterpriseLicense} + <OidcSigningStatus /> + {/if} {:else if category == 'Auth/OAuth/SAML'} <AuthSettings bind:oauths diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index 64ca912608..e0712d66c7 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -15,6 +15,7 @@ import { isWindmillTooBigObject } from './job_args' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { appendViewToken } from '$lib/viewToken' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' interface Props { id?: string | undefined @@ -246,7 +247,7 @@ ${Object.entries(displayArgs) If using the wmill client in your code, set the following env variables: <pre ><code - >BASE_URL="{window.location.origin}" + >BASE_URL="{apiBaseUrl()}" WM_TOKEN="{'<TOKEN>'}"</code ></pre > diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index be4ecb5b36..f15b87f9ef 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -14,7 +14,6 @@ import { ClipboardCopy, Download, Expand, Loader2, Timer, Cpu } from 'lucide-svelte' import { Button, Drawer, DrawerContent } from './common' import { copyToClipboard } from '$lib/utils' - import { base } from '$lib/base' import { withExternalDomain } from '$lib/externalDomain' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { appendViewToken } from '$lib/viewToken' @@ -253,7 +252,7 @@ } }) let logsApiPath = $derived(appendViewToken(`/w/${$operatingWorkspace}/jobs_u/get_logs/${jobId}`)) - let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`)) + let downloadHref = $derived(withExternalDomain(`/api${logsApiPath}`)) let downloadName = $derived(`windmill_logs_${jobId}.txt`) let truncatedContent = $derived( truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT) diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 76f4a2b995..1199d01079 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -30,7 +30,6 @@ import { OauthService, UserService, WorkspaceService } from '$lib/gen' import { usersWorkspaceStore, workspaceStore, userStore } from '$lib/stores' import { emptyString, escapeHtml, parseQueryParams } from '$lib/utils' - import { base } from '$lib/base' import { getUserExt } from '$lib/user' import { sendUserToast } from '$lib/toast' import { isCloudHosted } from '$lib/cloud' @@ -354,7 +353,7 @@ // See (root)/+layout.svelte for why /projects/import skips the picker. } else if ( resolvedRd?.startsWith('/user/workspaces') || - resolvedRd?.startsWith(`${base}/projects/import`) + resolvedRd?.startsWith('/projects/import') ) { goto(resolvedRd) } else if (resolvedRd == '/#user-settings') { @@ -575,7 +574,7 @@ if (automatic) params.set('auto', 'true') if (guestApp) params.set('guest_app', guestApp) const query = params.size > 0 ? '?' + params.toString() : '' - let url = base + '/api/oauth/login/' + provider + query + let url = '/api/oauth/login/' + provider + query console.log('storeRedirect', popup, url) if (popup) { @@ -856,7 +855,7 @@ {#if smtpConfigured} <div class="text-center pt-2"> <a - href="{base}/user/forgot-password" + href="/user/forgot-password" class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300" > Forgot password? diff --git a/frontend/src/lib/components/MemoryFootprintViewer.svelte b/frontend/src/lib/components/MemoryFootprintViewer.svelte index 9547861bc3..14ad1a2b06 100644 --- a/frontend/src/lib/components/MemoryFootprintViewer.svelte +++ b/frontend/src/lib/components/MemoryFootprintViewer.svelte @@ -3,7 +3,7 @@ import { type MetricDataPoint, MetricsService } from '$lib/gen' import { displayTime } from '$lib/utils' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from './operatingWorkspace.svelte' import { CategoryScale, Chart as ChartJS, @@ -26,6 +26,8 @@ let { jobId, jobUpdateLastFetch }: Props = $props(); + const operatingWorkspace = useOperatingWorkspace() + let jobMetricsLastFetch: Date | undefined = undefined let jobMemoryStats: MetricDataPoint[] | undefined = $state(undefined) @@ -38,7 +40,7 @@ async function loadMetricsData() { try { let jobStatsPromise = MetricsService.getJobMetrics({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, id: jobId, requestBody: { from_timestamp: jobMetricsLastFetch?.toISOString(), diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte new file mode 100644 index 0000000000..4df66e0362 --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -0,0 +1,578 @@ +<!-- +@component +The page header's breadcrumb: workspace / fork / kind / path, in one flat line. + +Every segment is a trigger for the picker that changes it — the workspace menu, the fork family +picker, and the item drill picker for the kind and each path level — and all of them share one +size and one icon size, so the line reads as one control rather than four. + +Two weights, not one: the segment that ends the trail is what the page is about and carries the +weight, and everything leading to it is a step quieter. Which segment that is depends on the +route — the workspace on home, a list page's own name, an item's summary (which the band draws, +not this component) — so the lead can run the whole line. +--> +<script lang="ts"> + import { Building, ChevronDown, Folder, GitFork, User } from 'lucide-svelte' + import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte' + import { Menu, Menubar } from '$lib/components/meltComponents' + import MeltButton from '$lib/components/meltComponents/MeltButton.svelte' + import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte' + import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte' + import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores' + import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy' + import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' + import { twMerge } from 'tailwind-merge' + import { Badge } from '$lib/components/common' + import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' + import { forkAccentStyle } from '$lib/utils/forkColor' + import DevWorkspaceBadge from './DevWorkspaceBadge.svelte' + import { page } from '$app/state' + import { navPageFor } from './sidebar/navPages' + import { copyToClipboard, getContrastTextColor } from '$lib/utils' + import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker' + import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte' + import type { Snippet } from 'svelte' + import { + BAR_STAGES, + FLOOR, + shownPathLevels, + type Part, + type TrailMeasures, + type TrailStage + } from './pageHeaderStages' + + let { + item, + section, + afterName, + actingWorkspaceId, + nameOnly = false, + stage = BAR_STAGES[0], + caps = {}, + scopesGive = false, + measures = $bindable() + }: { + item?: PageHeaderItem + section?: PageHeaderSection + /** What sits beside the page's name: a mark like a documentation tooltip, or a control + * that acts on the thing named. */ + afterName?: Snippet + /** The workspace the page acts on, when it differs from the one the app is pointed at. */ + actingWorkspaceId?: string + /** Which parts the bar has dropped for room (see pageHeaderStages). */ + stage?: TrailStage + /** Max widths the bar holds the names to, once dropping whole parts is not enough. */ + caps?: Partial<Record<Part, number>> + /** The folders above the name truncate too: even the name at its last floor does not fit. */ + scopesGive?: boolean + /** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */ + measures?: TrailMeasures + /** Only the page's own name, with no workspace part and no picker — the band inside a + * session's preview frame, where the workspace is the host's and leading the reader out of + * it is exactly what that band must not offer. The page still has to say what it is. */ + nameOnly?: boolean + } = $props() + + const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined) + const homeHref = $derived( + actingWorkspaceId ? `/?workspace=${encodeURIComponent(actingWorkspaceId)}` : `/` + ) + + // With no item or section registered, the route still says where the user is: Runs, Variables, + // and the rest name themselves in the breadcrumb rather than leaving it at the workspace. + // Home is the root the workspace part already stands for, so it names no page of its own. + const routePage = $derived.by(() => { + if (item || section) return undefined + const found = navPageFor(page.url.pathname) + return found?.path === '/' ? undefined : found + }) + + /** One look for every segment, whichever picker it opens. */ + const SEGMENT = + 'flex items-center gap-1 min-w-0 px-1 py-0.5 rounded text-xs font-normal text-primary hover:bg-surface-hover hover:text-emphasis transition-colors' + /** The trail up to the last segment: where the thing lives, which the reader scans past. */ + const LEAD = 'font-normal text-secondary' + /** The last segment: what the page is about, and the only part of the line with weight. */ + const LAST = 'font-medium text-emphasis' + const ICON = 14 + /** The workspace disc: the same glyph as the rest, with a ring of its own around it. */ + const DISC = 20 + + /** Width of the name beside the chevron, so the picker can hang from the part's own left edge. */ + let nameWidth = $state(0) + let workspaceMenu: Menu | undefined = $state(undefined) + + // Its own confirmation under the path rather than the global toast: the answer belongs where + // the click was, and a toast for something this small is a lot of furniture. + let pathCopied = $state(false) + let copiedReset: ReturnType<typeof setTimeout> | undefined + async function copyPath() { + if (!item?.path || !(await copyToClipboard(item.path, false))) return + pathCopied = true + clearTimeout(copiedReset) + copiedReset = setTimeout(() => (pathCopied = false), 1500) + } + + const family = $derived(findWorkspaceRoot(scopeId, $userWorkspaces ?? [])) + const discColor = $derived(family?.color ?? $workspaceColor ?? 'rgb(var(--color-surface-sunken))') + const glyphColor = $derived(getContrastTextColor(discColor)) + + const forkable = useForkableWorkspaces({ + workspaces: () => $userWorkspaces, + currentWorkspaceId: () => scopeId, + isSuperadmin: () => !!$superadmin + }) + const forkableWorkspaces = $derived(forkable.current) + const currentWs = $derived(forkableWorkspaces.find((w) => w.id === scopeId)) + const inFork = $derived(!!currentWs?.parent_workspace_id) + const showFork = $derived(inFork) + const familyName = $derived(family?.name ?? scopeId ?? '') + // The workspace the user actually stands in, which is the fork's own name inside a fork. + const scopeName = $derived(currentWs?.name ?? familyName) + + // A fork wears its workspace colour and, for a dev workspace, its environment badge — the same + // two marks the fork chip carries everywhere else. + const forkAccent = $derived(inFork ? forkAccentStyle(currentWs?.color) : undefined) + // A dev workspace wears its environment; the root wears "prod", so which environment the user + // is standing in is answered the same way whichever one it is. + /** Whether this family has a dev workspace anywhere under it. */ + const familyHasDev = $derived.by(() => { + const all = $userWorkspaces ?? [] + const root = family?.id + return ( + !!root && all.some((w) => w.is_dev_workspace && findWorkspaceRoot(w.id, all)?.id === root) + ) + }) + const envBadge = $derived.by(() => { + // "prod" only where a dev workspace exists to be the other half of the pair: a family with + // one environment gains nothing from naming it. + if (!inFork) return familyHasDev ? 'prod' : undefined + if (currentWs?.is_dev_workspace) return 'dev' + return undefined + }) + + // Which segment ends the trail, and so carries the weight. Everything before it is `LEAD`: on + // home that is nothing and the workspace itself is the subject, on a list page the page's name + // takes over, and on an item page the summary does — so the workspace, the fork and the path + // all step back to make room for it. + const namedAfterWorkspace = $derived(!!item || !!section || !!routePage) + const workspaceIsLast = $derived(!nameOnly && !namedAfterWorkspace) + // The summary is the band's, not the breadcrumb's (see PageHeaderBar), so the path reads as the + // last segment only for an item that has no summary to follow it. + const itemIsLast = $derived(!!item && !item.summaryContent && !item.summary) + /** Only the summary's text names the item: a control offering to add one does not, and the path + * then stays at every stage. */ + const summaryNamesItem = $derived(!!item?.summary?.trim()) + + // No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the + // fork's own name inside, so the family's name is not painted as if it were the fork. + // + // Inside a fork the family's name is the lead even where the workspace part ends the trail: the + // workspace the user stands in is the fork, and the family before it says which product that + // fork belongs to. Weighting both would name two workspaces as the subject. + const scopeChipClass = $derived( + twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD) + ) + + const pathSegment = $derived( + twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD) + ) + // Copying is the one thing a segment does that leaves no mark on the page, so it is the one + // without a hover fill: the pointer and the confirmation under it are the affordance. A segment + // that opens the rename editor keeps the fill, which is what tells the two apart on sight. + const copyPathSegment = $derived(twMerge(pathSegment, 'hover:bg-transparent')) + + // The chip keeps its colour wherever it is on the line — that colour is how a fork is told apart + // from its family. Only its weight follows the trail, dropping back once a page's name ends it. + const forkChipClass = $derived( + twMerge( + 'flex items-center gap-1 min-w-0 px-1 rounded', + // A filled chip needs room around what it holds; an uncolored one is just the name and + // would only be pushed away from the slash before it. + forkAccent && + 'px-1.5 py-0.5 bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]', + // Where the workspace part ends the trail, the fork is the segment that ends it, so it + // takes the weight on its own. Without a colour of its own it also has to take the text + // colour, since it would otherwise inherit the lead's from the link around it. + workspaceIsLast ? twMerge('font-semibold', !forkAccent && 'text-emphasis') : 'font-normal' + ) + ) + + /** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the path's icon. */ + const scopeKind = $derived(item?.path?.split('/')[0]) + /** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */ + const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean)) + + // The off-screen copies stand in a fixed-width box for what a page renders into the trail: a + // second render of a page's snippet would be a second live copy of its controls. + let afterNameWidth = $state(0) + let sectionContentWidth = $state(0) + + /** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */ + function capStyle(part: Part, measure: boolean): string | undefined { + if (measure) return `max-width: ${FLOOR[part]}px` + const cap = caps[part] + return cap === undefined ? undefined : `max-width: ${cap}px` + } +</script> + +{#snippet workspaceDisc()} + <!-- The glyph is the same size as every other icon on the line; only the disc around it is + larger, by the ring it needs to read as a disc at all. --> + <svg width={DISC} height={DISC} viewBox="0 0 {DISC} {DISC}" class="flex-shrink-0"> + <circle cx={DISC / 2} cy={DISC / 2} r={DISC / 2} fill={discColor} /> + <foreignObject x={(DISC - ICON) / 2} y={(DISC - ICON) / 2} width={ICON} height={ICON}> + <Building size={ICON} style={glyphColor ? `color: ${glyphColor}` : undefined} /> + </foreignObject> + </svg> +{/snippet} + +{#snippet itemKindIcon()} + <!-- No kind, no glyph: WorkspaceItemKindIcon falls through to the script icon, which would + label an agent a script. --> + {#if item?.kind}<WorkspaceItemKindIcon kind={item.kind} size={ICON} />{/if} +{/snippet} + +{#snippet sectionKindIcon()} + {#if section?.kind}<WorkspaceItemKindIcon kind={section.kind} size={ICON} />{/if} +{/snippet} + +{#snippet folderIcon()} + <Folder size={ICON} class="flex-shrink-0 text-tertiary" /> +{/snippet} + +{#snippet userIcon()} + <User size={ICON} class="flex-shrink-0 text-tertiary" /> +{/snippet} + +{#snippet envBadgeMark()} + {#if envBadge === 'dev'} + <DevWorkspaceBadge + label={currentWs?.dev_workspace_label} + color={currentWs?.color} + fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white" + class="text-3xs px-1 py-0" + /> + {:else if envBadge === 'prod'} + <Badge color="gray" small class="text-3xs px-1 py-0">prod</Badge> + {/if} +{/snippet} + +{#snippet slash(extra = '')} + <!-- `extra` tops the workspace part's own 4px gap up to the 6px the breadcrumb puts around its + other separators, so the slash before a fork is spaced like every other slash on the line. --> + <span class={twMerge('shrink-0 text-hint/40 text-xs', extra)} aria-hidden="true">/</span> +{/snippet} + +{#snippet workspaceMenu_(measure: boolean)} + {#if measure} + <span class="flex items-center h-9 px-1.5 shrink-0"> + <ChevronDown size={ICON} class="flex-shrink-0" /> + </span> + {:else} + <Menubar> + {#snippet children({ createMenu })} + <Menu + bind:this={workspaceMenu} + {createMenu} + usePointerDownOutside + placement="bottom-start" + class="h-auto" + contentStyle="margin-left: {-nameWidth}px" + > + {#snippet triggr({ trigger })} + <MeltButton + meltElement={trigger} + class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors" + title="Switch workspace" + > + <ChevronDown size={ICON} class="flex-shrink-0" /> + </MeltButton> + {/snippet} + {#snippet children({ item: menuItem })} + <WorkspacePickerBody + item={menuItem} + collapseFamilies={actingWorkspaceId != undefined} + closeMenu={() => workspaceMenu?.close()} + /> + {/snippet} + </Menu> + {/snippet} + </Menubar> + {/if} +{/snippet} + +{#snippet trail(f: TrailStage, measure: boolean)} + {@const scopeTitle = showFork ? `${familyName} / ${scopeName}` : familyName} + {#if !nameOnly} + <!-- One part for where the user is: the name of the workspace they are standing in — a fork + included, in its own colour — leading home, and one chevron whose picker changes it. The + picker lists the families and expands one to reach its forks, so a fork needs no part of + its own here. Its parts never shrink on their own: the bar drops them whole and caps the + names left, in the order pageHeaderStages sets. --> + <div class="flex items-center shrink-0"> + <!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two + boxes reacting to one pass of the pointer read as two controls fighting. --> + <!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of + the name to line its left edge up with the disc. --> + <div + class="flex items-center" + bind:clientWidth={() => nameWidth, (w) => !measure && (nameWidth = w)} + > + <svelte:element + this={measure ? 'span' : 'a'} + href={measure ? undefined : homeHref} + class={f.wsButton ? 'flex items-center gap-1 pl-1 py-0.5 rounded' : scopeChipClass} + title={scopeTitle} + > + {#if f.wsButton} + <!-- Down to its disc, the part still leads home: the fork it stands in rides on the + disc as a mark, and the environment stays beside it. --> + <span class="relative flex shrink-0"> + {@render workspaceDisc()} + {#if showFork} + <span + class={twMerge( + 'absolute -right-1 -bottom-0.5 size-3.5 rounded-full grid place-items-center ring-2 ring-surface', + forkAccent + ? 'bg-[color:var(--fork-accent-badge)] text-white' + : 'bg-surface-sunken text-primary' + )} + style={forkAccent} + > + <GitFork size={9} /> + </span> + {/if} + </span> + {@render envBadgeMark()} + {:else} + <!-- The family's name goes before the fork's: the fork is where the work happens, + the family is which product it is part of, and of the two that is the one a + reader can do without. Outside a fork it is the workspace's own name and stays + until the part is down to its disc. --> + <span class="flex items-center gap-1 shrink-0"> + {@render workspaceDisc()} + {#if f.familyName || !showFork} + <span class="truncate" style={capStyle('family', measure)}>{familyName}</span> + {/if} + </span> + {#if showFork} + {@render slash('mx-0.5')} + <span class={twMerge(forkChipClass, 'shrink-0')} style={forkAccent}> + <GitFork size={ICON} class="flex-shrink-0" /> + {#if f.forkName} + <span class="truncate" style={capStyle('fork', measure)}>{scopeName}</span> + {/if} + {@render envBadgeMark()} + </span> + {:else} + {@render envBadgeMark()} + {/if} + {/if} + </svelte:element> + </div> + {@render workspaceMenu_(measure)} + </div> + {/if} + + <!-- The summary that follows is the item's name for a person, so on a short bar it is the path + that goes (see `TrailStage.path`). What the page hangs after the name stays. --> + {@const pathShown = f.path || !summaryNamesItem} + {#if !nameOnly && (item ? pathShown : section || routePage)} + {@render slash()} + {/if} + + {#if item} + {#if !pathShown} + {@render afterNameSlot(measure)} + {:else if item.path} + {@const shown = shownPathLevels(pathLevels, f)} + <!-- The path whole, as one label rather than a row of pickers: it is what a person reads + to know which item this is, and what they reach for to paste somewhere else. --> + <div class="relative flex items-center min-w-0"> + <!-- Past the cap the trail truncates; whatever the click does still has the path whole. --> + {#snippet pathLabel()} + {#if scopeKind === 'u'} + {@render userIcon()} + {:else} + {@render folderIcon()} + {/if} + {#each shown as level, i (i)} + {#if i > 0}{@render slash()}{/if} + {#if i === shown.length - 1} + <span class="truncate shrink-0" style={capStyle('name', measure)}>{level}</span> + {:else if level === '…' && !f.middleLevels} + <span class="shrink-0 text-tertiary">…</span> + {:else} + <!-- The folder or user gives width only once the name is down to a few + characters: it is short and says whose thing this is, while a name long + enough to need truncating still reads from its first words. --> + <span + class={twMerge( + 'truncate max-w-[8rem]', + scopesGive && !measure ? 'min-w-0' : 'shrink-0' + )}>{level}</span + > + {/if} + {/each} + {/snippet} + {#if measure} + <span class={pathSegment}>{@render pathLabel()}</span> + {:else if item.pathTrigger} + <!-- The page hangs its own rename off this segment, anchored here so the editor + opens under the path rather than beside the summary. It wears the segment's + own classes, so a clickable path reads the same as one that only copies. --> + {@render item.pathTrigger(pathLabel, pathSegment)} + {:else} + <button + class={copyPathSegment} + title="Copy path" + onclick={copyPath} + aria-label="Copy path {item.path}" + > + {@render pathLabel()} + </button> + {/if} + {@render afterNameSlot(measure)} + {#if pathCopied && !measure} + <span + class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap" + role="status" + > + Path copied successfully + </span> + {/if} + </div> + {:else if item.kind} + <!-- The path is on its way: a page registers its kind as soon as it mounts and fills the + path once it has loaded one. Naming the kind here reads as the answer and then jumps + when the real path replaces it, so hold its place instead. The width is a plausible + path's, which is what keeps the trail from shifting when it lands. --> + <span + class={twMerge(SEGMENT, 'hover:bg-transparent', itemIsLast ? LAST : LEAD)} + aria-label="Loading {KIND_LABEL_LOWER[item.kind]} path" + aria-busy="true" + > + {@render itemKindIcon()} + <Skeleton layout={[[{ h: 0.75, w: 100, minW: 96 }]]} class="w-24" /> + </span> + {/if} + {:else if section?.kind} + {#if measure} + <span class={twMerge('inline-flex items-center px-1', SEGMENT, LAST)}> + {@render sectionKindIcon()}<span>{KIND_LABEL_LOWER[section.kind]}</span> + </span> + {:else} + <BreadcrumbSegment + label={KIND_LABEL_LOWER[section.kind]} + icon={sectionKindIcon} + extraClass={twMerge(SEGMENT, LAST)} + isCurrent + initialHighlight={kindKey(section.kind)} + initialScope={{ kind: section.kind }} + onPick={() => {}} + /> + {/if} + {:else if section} + {#if section.parent} + <!-- Gives width back with the workspace part, before the section's own name does. --> + <span + class={twMerge(SEGMENT, LEAD, 'shrink-[999] hover:bg-transparent hover:text-secondary')} + > + <span class="truncate">{section.parent}</span> + </span> + {@render slash()} + {/if} + {#if section.content && measure} + <span class="shrink-0" style="width: {sectionContentWidth}px"></span> + {:else} + <!-- A section's own widget lays out as a row: its title, and whatever belongs with it. --> + <span + class={twMerge( + SEGMENT, + LAST, + 'hover:bg-transparent hover:text-emphasis', + section.content ? '' : 'shrink-0' + )} + aria-current={measure ? undefined : 'page'} + bind:clientWidth={ + () => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w) + } + > + <!-- A plain label is the page's name, so it truncates the way an item's name does, in + its turn: a run's id, left to flex, is squeezed to nothing under the buttons. --> + {#if section.content}{@render section.content()}{:else}<span + class="truncate" + style={capStyle('name', measure)}>{section.label}</span + >{/if} + </span> + {/if} + {@render afterNameSlot(measure)} + {:else if routePage} + {@const RouteIcon = routePage.icon} + <!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping + whole, so the workspace and fork names give way first when the bar is full. --> + <span + class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')} + aria-current={measure ? undefined : 'page'} + > + {#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if} + <span class="truncate">{routePage.label}</span> + </span> + {@render afterNameSlot(measure)} + {/if} +{/snippet} + +{#snippet afterNameSlot(measure: boolean)} + {#if afterName} + {#if measure} + <span class="shrink-0" style="width: {afterNameWidth}px"></span> + {:else} + <span class="flex items-center shrink-0" bind:clientWidth={afterNameWidth}> + {@render afterName()} + </span> + {/if} + {/if} +{/snippet} + +<nav + aria-label="Breadcrumb" + class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary" +> + {@render trail(stage, false)} +</nav> + +{#if measures} + <!-- The trail at every stage, each name at its floor, for the bar to choose a stage with; and + each name whole, for it to cap them with. Same snippet, same classes, so the widths are the + ones the trail on screen would take. --> + <div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert> + {#each BAR_STAGES as s, i (i)} + <nav + class="flex items-center gap-1.5 w-max text-xs font-normal text-primary" + bind:clientWidth={measures.trail[i]} + > + {@render trail(s, true)} + </nav> + {/each} + <!-- Always rendered, empty where the page has no such part: a width of 0 is how the bar + knows the part is not there. --> + <span + class={twMerge(scopeChipClass, 'inline-block whitespace-nowrap p-0')} + bind:clientWidth={measures.family}>{nameOnly ? '' : familyName}</span + > + <span + class={twMerge(forkChipClass, 'inline-block whitespace-nowrap p-0')} + bind:clientWidth={measures.fork}>{!nameOnly && showFork ? scopeName : ''}</span + > + <span + class={twMerge('inline-block whitespace-nowrap text-xs', itemIsLast || !item ? LAST : LEAD)} + bind:clientWidth={measures.name} + >{item?.path + ? (pathLevels[pathLevels.length - 1] ?? '') + : !item && section && !section.kind && !section.content + ? section.label + : ''}</span + > + </div> +{/if} diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte index d1017f7347..a3a249caf5 100644 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -16,7 +16,8 @@ import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel' import { switchWorkspace } from '$lib/storeUtils' - import { Badge, Button } from './common' + import { Button, ButtonType } from './common' + import { twMerge } from 'tailwind-merge' import Popover from './meltComponents/Popover.svelte' import Toggle from './Toggle.svelte' import { GitFork, Lock, ShieldOff } from 'lucide-svelte' @@ -83,28 +84,35 @@ ? `Edits in ${devLabelKey(canonicalDev.dev_workspace_label)}` : 'Edits restricted' ) + + // The Popover renders its trigger as a <button>, so this wears the design system's subtle + // button rather than containing one — a <Button> here would nest a button inside a button. + const triggerClass = twMerge( + 'inline-flex items-center gap-1 rounded-md whitespace-nowrap transition-all', + ButtonType.VariantStyles.subtle, + ButtonType.UnifiedSizingClasses.sm, + ButtonType.UnifiedHeightClasses.sm, + ButtonType.UnifiedFontSizes.sm, + 'text-xs' + ) </script> {#if !operatingUser.current?.operator && activeDeployRulesets.length > 0} <div class="my-2"> <Popover placement="bottom-start" - class="inline-flex items-center" + class={triggerClass} triggerAttrs={{ 'aria-label': badgeLabel }} > {#snippet trigger()} - <!-- `clickable` is unusable here: it renders the badge as a <button>, nested inside the - one Popover wraps its trigger in. --> - <Badge small color={bypassActive ? 'yellow' : 'blue'} class="cursor-pointer"> - {#if bypassActive} - <ShieldOff class="h-3 w-3" /> - {:else if canonicalDev} - <GitFork class="h-3 w-3" /> - {:else} - <Lock class="h-3 w-3" /> - {/if} - {badgeLabel} - </Badge> + {#if bypassActive} + <ShieldOff size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" /> + {:else if canonicalDev} + <GitFork size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" /> + {:else} + <Lock size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" /> + {/if} + {badgeLabel} {/snippet} {#snippet content()} <div class="flex flex-col gap-3 p-4 text-xs max-w-sm"> diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte new file mode 100644 index 0000000000..8c3dd509d5 --- /dev/null +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -0,0 +1,383 @@ +<!-- +@component +The bar every route wears, above the page content and beside the sidebar: the sidebar toggle, the +workspace picker, then whatever the route registered — an item's breadcrumb and summary, or a +section name — and the route's own buttons at the far end. + +The row's height matches the sidebar's own header row, so the two read as one band. +--> +<script lang="ts"> + import { PanelLeft } from 'lucide-svelte' + import { Button } from '$lib/components/common' + import { navDetached } from './sidebar/navDetached.svelte' + import { navHandleSlot } from './sidebar/navHandlePlacement.svelte' + import NavBreadcrumb from './NavBreadcrumb.svelte' + import { BAR_HEIGHT, pageHeader, PHONE_BAR } from './pageHeaderRegistry.svelte' + import ContextBridge from './ContextBridge.svelte' + import { afterNavigate } from '$app/navigation' + import { twMerge } from 'tailwind-merge' + import { onDestroy } from 'svelte' + import type { Attachment } from 'svelte/attachments' + import { BAR_STAGES, FLOOR, fitBar, type BarFit, type TrailMeasures } from './pageHeaderStages' + + let { + navHidden = false, + panelled = false + }: { + navHidden?: boolean + /** The sidebar is a panel over the page rather than a rail beside it — detached, or a + * window too narrow to seat one. Either way this bar carries the switch that opens it. */ + panelled?: boolean + } = $props() + + const content = $derived(pageHeader.content) + const actions = $derived(pageHeader.actions) + + const item = $derived(content?.item) + const section = $derived(content?.section) + const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary)) + /** A summary control can be a placeholder offering to add one; the text is what names the item. */ + const summaryNamesItem = $derived(!!item?.summary?.trim()) + + /** A phone's bar: a tighter gutter around the same row. */ + const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR) + + // What the bar drops, and when the page's actions fold, follows from the widths of what is in + // it (see pageHeaderStages): measured here and in NavBreadcrumb, never guessed per page. + /** `gap-1` between the bar's boxes. */ + const GAP = 4 + /** Actions that shrink or fill the bar have no width of their own to plan around: the trail + * leaves them this much and they take whatever is left. */ + const FLEXIBLE_ACTIONS_RESERVE = 240 + + let trailMeasures = $state<TrailMeasures>({ trail: [], family: 0, fork: 0, name: 0 }) + /** The summary held at its floor, with the dot before it. */ + let summaryFloorWidth = $state(0) + let summaryTextWidth = $state(0) + /** A page's own summary control, last seen uncapped. */ + let summaryContentWidth = $state(0) + let handleWidth = $state(0) + /** The actions box at each fold level it has been seen at, for this page. */ + let actionWidthsByVersion = $state<Record<string, (number | undefined)[]>>({}) + let actionsEpoch = $state(0) + const actionsVersion = $derived(content?.actionsVersion ?? '') + const actionWidths = $derived(actionWidthsByVersion[actionsVersion] ?? []) + + const handleShown = $derived((navDetached.val || panelled) && !navHidden) + // The bar's own padding, in px, as the classes on the row below set it. + const padLeft = $derived(navHidden ? 16 : phone ? 4 : 8) + const padRight = $derived(content?.actionsFill ? 0 : phone ? 8 : 16) + const actionsRigid = $derived(!content?.actionsFlexible && !content?.actionsFill) + const FULL: BarFit = { stage: 0, fold: 0, caps: {}, scopesGive: false } + + const fit = $derived.by((): BarFit => { + // Unmeasured counts as wide: the bar measures itself on mount, and starting narrow would fold + // a page's buttons for one frame and pop them back out the next. + if (pageHeader.barWidth === 0) return FULL + return fitBar({ + barWidth: pageHeader.barWidth, + chrome: padLeft + padRight + (handleShown ? handleWidth + GAP : 0), + trail: trailMeasures.trail, + summary: BAR_STAGES.map(() => (hasSummary ? GAP + summaryFloorWidth : 0)), + actions: + actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE], + folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0, + earlyFolds: content?.earlyActionFolds, + summaryNamesItem, + natural: { + family: trailMeasures.family, + fork: trailMeasures.fork, + summary: hasSummary ? (item?.summaryContent ? summaryContentWidth : summaryTextWidth) : 0, + name: trailMeasures.name + } + }) + }) + const stage = $derived(BAR_STAGES[fit.stage]) + /** The level alone: `fit` is a new object on every recompute, and the attachment re-measuring + * on each one would feed itself. */ + const fold = $derived(fit.fold) + onDestroy(pageHeader.provideActionFold(() => fold)) + + // Re-attached whenever the fold level, the page or the actions' version changes, so the first + // reading there is taken even when the row comes out exactly as wide as it was before. + // The page's actions can change on screen — a run's Cancel goes once the job completes — and + // the other levels most likely changed with them, so a change here drops their widths: kept, a + // stale wider one would hold the row folded for good. The bar measures them again by trying. + function recordActions(fold: number, width: number) { + const version = actionsVersion + const widths = actionWidthsByVersion[version] ?? [] + const seen = widths[fold] + // Unchanged, nothing is written: a write recomputes `fit`, and a reading that always wrote + // kept the bar measuring every frame. + if (seen === width) return + const next = seen !== undefined ? [] : [...widths] + next[fold] = width + actionWidthsByVersion[version] = next + } + function measureActions( + fold: number, + _epoch: number, + _version: string + ): Attachment<HTMLElement> { + return (node) => { + const ro = new ResizeObserver(() => recordActions(fold, GAP + node.offsetWidth)) + ro.observe(node) + return () => ro.disconnect() + } + } + // Until the row has been seen at the level the bar picked, it is laid out but not shown: the bar + // may find it does not fit and fold it further, and that should not flash. + const actionsUnmeasured = $derived(actionsRigid && actionWidths[fit.fold] === undefined) + + // The band's edge is there to say "something is passing under me", so it is drawn only while + // something is. Scroll events do not bubble, but they do capture, so one listener on the window + // sees every box under the band — which matters because most pages scroll inside their own + // (the audit table, a detail page's panes) and never move this layout's own scroller. + let scrolledUnder = $state(false) + /** Sub-pixel layout and a border or two, between a box's top and the content box's. */ + const AT_THE_TOP_PX = 4 + function onScrollCapture(e: Event) { + const el = e.target as HTMLElement | null + if (!el || el.nodeType !== 1) return + const box = document.getElementById('content') + if (!box || !box.contains(el)) return + // The answer this event would give, before measuring anything. While it matches what the + // band already draws there is nothing to decide — which is the steady state of a scroll, + // and the two rects below each force a synchronous layout. This listener captures every + // scroller in the app (Monaco, the audit table, a run's logs), about once a frame while any + // of them moves, so the common path has to stay off the layout. + const next = el.scrollTop > 0 + if (next === scrolledUnder) return + // Only a box that starts where the content does has anything passing under the band. The + // audit page's list begins 285px lower, behind its own chart and column headers, and a + // select's option list floats wherever it opens: neither moves anything under the header. + const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + if (top > AT_THE_TOP_PX) return + scrolledUnder = next + } + // A page left while scrolled would otherwise hand its edge to the next one, which may have + // nothing to scroll at all. Its actions' widths go with it. + afterNavigate(({ from, to }) => { + scrolledUnder = false + if (from?.url.pathname !== to?.url.pathname) { + actionWidthsByVersion = {} + actionsEpoch++ + } + }) + + const separator = $derived(pageHeader.content?.separator ?? 'onScroll') + const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder)) +</script> + +<svelte:window onscrollcapture={onScrollCapture} /> + +<!-- The band owns its bottom edge, so a page never draws a second one under it. A page says which + rule it wants (see `separator` in the registry); the default shows the edge only while + something is passing under the bar. An inset shadow rather than a border: a border would make + the row 45px and every page below it would move a pixel the moment the edge appeared. --> +<div + data-page-header + bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)} + class={twMerge( + 'flex flex-col shrink-0 min-w-0 bg-surface transition-shadow duration-150', + showEdge && + 'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]' + )} +> + <div + class={twMerge( + 'flex items-center gap-1 min-w-0', + // No flex-wrap. A wrapping row never squeezes anything — flex moves an item to the next line + // instead of taking it below its content width — so wrapping here would hand the trail a + // line of its own and leave it whole. What needs a line of its own asks for one through + // `belowBar`, at a fold the bar picks. + phone ? `${BAR_HEIGHT} pl-1 pr-2` : `${BAR_HEIGHT} pl-2 pr-4`, + // The filling surface is what meets the right edge, so the bar keeps no gutter of its own + // there. + content?.actionsFill && 'pr-0', + // The small left padding is there to sit a button against: the sidebar handle, or the + // workspace disc, both of which carry their own visual margin. Embedded there is neither, + // and the page's name would start as bare text 8px from the edge. + navHidden && 'pl-4' + )} + > + {#if (navDetached.val || panelled) && !navHidden} + <!-- Reveals the hidden sidebar, and only that: the card follows the pointer, holding while it + is on the handle, on the card, or in a menu either opened, and sliding back out when it + leaves. A click or tap pins it instead, until another one here, a press on the page or Esc + puts it back. Attaching + it for good belongs to the toggle in the sidebar's own footer, where detaching it + happened. Docked, the sidebar speaks for itself and nothing leads the bar. --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + data-nav-handle + bind:clientWidth={handleWidth} + onmouseenter={() => navHandleSlot.open()} + onmouseleave={() => navHandleSlot.scheduleClose()} + > + <!-- No tooltip: hovering here already slides the sidebar in, which says what the button + does better than a label popping up over it. --> + <Button + variant="subtle" + unifiedSize="sm" + iconOnly + startIcon={{ icon: PanelLeft }} + aria-label="Show sidebar" + selected={navHandleSlot.pinned} + aria-pressed={navHandleSlot.pinned} + onclick={() => navHandleSlot.toggle()} + /> + </div> + {/if} + + <!-- The breadcrumb's parts are rigid and the bar sizes them (see `fit`): it drops whole parts + and caps the names left, so this box only shrinks past what they add up to on a bar too + narrow for even the last stage. + An embed (`navHidden`) keeps the page's own name but drops the workspace part around it: + the trail would offer to navigate the host's workspace, while the name is what says which + page the controls beside it belong to. --> + <div class="flex min-w-0"> + <!-- Bridged like the actions below: what a page hangs off its own name renders here, out of + the tree that named it, and the rename editor in there asks that tree who the acting user + is before it offers to rename anything. --> + <!-- Each fragment the band renders comes from another tree and reads that tree's state. The + registry already tolerates a registration that cannot answer while its owner's data is + being cleared (see `read`); the same window reaches rendering, and an error here would + otherwise travel to the root and blank the app rather than the one fragment. A + boundary per fragment keeps that to the fragment. --> + <svelte:boundary + onerror={(e) => console.error('page header: breadcrumb failed to render', e)} + > + {#key content?.contexts} + <ContextBridge contexts={content?.contexts}> + <NavBreadcrumb + {item} + {section} + {stage} + caps={fit.caps} + scopesGive={fit.scopesGive} + bind:measures={trailMeasures} + nameOnly={navHidden} + afterName={content?.afterName} + actingWorkspaceId={content?.actingWorkspaceId} + /> + </ContextBridge> + {/key} + </svelte:boundary> + </div> + + {#if item && hasSummary} + <!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow + editor tells the reader what they can already see. --> + <div class="flex items-center gap-1 shrink-0"> + <!-- A dot after the path: the summary names the same item the path just located, it is not + another level of it. With the path dropped it follows the workspace, and the slash is + the trail's own separator. Fixed width, so either fits the width measured below. --> + <span class="shrink-0 w-2 text-center text-hint/40 text-xs" aria-hidden="true" + >{stage.path || !summaryNamesItem ? '·' : '/'}</span + > + {#if item.summaryContent} + <!-- Read only while uncapped: capped, the box says how wide the bar let it be, not how + wide the page's control wants to be. --> + <div + class="flex min-w-0 overflow-hidden" + style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`} + bind:clientWidth={ + () => summaryContentWidth, + (w) => fit.caps.summary === undefined && (summaryContentWidth = w) + } + > + <svelte:boundary + onerror={(e) => console.error('page header: summary failed to render', e)} + > + {#key content?.contexts} + <ContextBridge contexts={content?.contexts}> + {@render item.summaryContent()} + </ContextBridge> + {/key} + </svelte:boundary> + </div> + {:else} + <span + class="truncate text-xs font-medium text-emphasis" + style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`} + >{item.summary}</span + > + {/if} + </div> + {/if} + + {#if actions.length > 0} + <!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out + of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the + page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that + can give width back, which it can only do if this box may shrink. --> + <!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the + bar and run to its right edge, and they stretch its full height because that surface + sits on the bottom edge. --> + <div + {@attach measureActions(fold, actionsEpoch, actionsVersion)} + class={twMerge( + 'flex', + // pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it + // belongs here rather than inside that surface, where it would be filled. + content?.actionsFill + ? 'flex-1 min-w-0 self-stretch items-stretch pl-4' + : 'ml-auto items-center', + !content?.actionsFill && (actionsRigid ? 'shrink-0' : 'min-w-0'), + !content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4'), + actionsUnmeasured && 'invisible' + )} + > + {#each actions as entry, i (i)} + <svelte:boundary + onerror={(e) => console.error('page header: actions failed to render', e)} + > + {#key entry.contexts} + <ContextBridge contexts={entry.contexts}> + {@render entry.render()} + </ContextBridge> + {/key} + </svelte:boundary> + {/each} + </div> + {/if} + + {#if item && hasSummary} + <!-- The summary at its floor, which is what a stage that keeps it needs; and whole, which is + what the bar caps it from. A page's own control stands in at the width last seen. --> + <div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert> + <div class="flex items-center gap-1 w-max" bind:clientWidth={summaryFloorWidth}> + <span class="shrink-0 w-2 text-center text-hint/40 text-xs">·</span> + {#if item.summaryContent} + <span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span> + {:else} + <span class="truncate text-xs font-medium" style="max-width: {FLOOR.summary}px" + >{item.summary}</span + > + {/if} + </div> + <span + class="inline-block whitespace-nowrap text-xs font-medium" + bind:clientWidth={summaryTextWidth}>{item.summary ?? ''}</span + > + </div> + {/if} + </div> + {#if content?.belowBar && fit.fold >= (content.belowBarFold ?? 0)} + <!-- Same gutter as the row above, so what moved down lines up with the breadcrumb's start. --> + <div + class={twMerge('flex items-center pb-2', phone ? 'px-2' : 'pl-3 pr-4', navHidden && 'pl-4')} + > + <svelte:boundary + onerror={(e) => console.error('page header: second row failed to render', e)} + > + {#key content?.contexts} + <ContextBridge contexts={content?.contexts}> + {@render content.belowBar()} + </ContextBridge> + {/key} + </svelte:boundary> + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte new file mode 100644 index 0000000000..cc37b727cb --- /dev/null +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -0,0 +1,53 @@ +<!-- +@component +Fills the layout's page header from the route that mounts it: the breadcrumb (an item's path, or a +section name for a list) and the page's own buttons. Renders nothing itself. + +Only a route page mounts this. An editor nested in a session pane or a drawer must not take over +the header, so nesting is decided by the caller rather than guessed from context. +--> +<script lang="ts"> + import { onMount } from 'svelte' + import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' + + let { + item, + section, + afterName, + actions, + actionsOrder, + actionsFlexible, + actionsFill, + actionFolds, + earlyActionFolds, + actionsVersion, + belowBar, + belowBarFold, + separator, + contexts, + actingWorkspaceId, + barRightInset + }: PageHeaderContent = $props() + + onMount(() => { + const id = pageHeader.register(() => ({ + item, + section, + afterName, + actions, + actionsOrder, + actionsFlexible, + actionsFill, + actionFolds, + earlyActionFolds, + actionsVersion, + belowBar, + belowBarFold, + separator, + contexts, + actingWorkspaceId, + barRightInset + })) + return () => pageHeader.release(id) + }) +</script> diff --git a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte index f51d9b4536..da409b697a 100644 --- a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte +++ b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte @@ -8,7 +8,6 @@ import { twMerge } from 'tailwind-merge' import DarkModeObserver from './DarkModeObserver.svelte' import { AppService, HelpersService } from '$lib/gen' - import { base } from '$lib/base' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { enterpriseLicense } from '$lib/stores' import { Download } from 'lucide-svelte' @@ -267,7 +266,7 @@ {:else} <a target="_blank" - href="{base}/api{csvApiPath}" + href="/api{csvApiPath}" class="text-secondary w-full text-right underline text-2xs whitespace-nowrap" ><div class="flex flex-row-reverse gap-2 items-center"><Download size={12} /> CSV</div></a > diff --git a/frontend/src/lib/components/PathEditPen.svelte b/frontend/src/lib/components/PathEditPen.svelte new file mode 100644 index 0000000000..c74ea8dded --- /dev/null +++ b/frontend/src/lib/components/PathEditPen.svelte @@ -0,0 +1,37 @@ +<!-- +@component +The pen that opens an item's summary and path for editing, beside the breadcrumb that shows them. + +One affordance for both popovers behind it: an editor's, which edits the draft the next deploy +writes, and a deployed page's, which saves straight away. They look the same because they mean +the same thing to a reader — "rename this". +--> +<script lang="ts"> + import { Pencil } from 'lucide-svelte' + import { Button } from '$lib/components/common' + + interface Props { + /** Names what the popover offers, for the tooltip and the accessible name. */ + label: string + /** `hover` keeps the pen out of the way until the row is hovered, which is what the band + * wants beside a name; `always` is for a name with nothing else to point at, such as an + * item with no summary yet. */ + visibility?: 'hover' | 'always' + /** Held visible while the popover it opens is open, whatever `visibility` says. */ + open?: boolean + } + + let { label, visibility = 'hover', open = false }: Props = $props() +</script> + +<Button + variant="subtle" + unifiedSize="sm" + iconOnly + startIcon={{ icon: Pencil }} + title={label} + aria-label={label} + btnClasses={visibility === 'hover' && !open + ? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100' + : ''} +/> diff --git a/frontend/src/lib/components/PathEditPopover.svelte b/frontend/src/lib/components/PathEditPopover.svelte new file mode 100644 index 0000000000..bcf6106be0 --- /dev/null +++ b/frontend/src/lib/components/PathEditPopover.svelte @@ -0,0 +1,227 @@ +<!-- +@component +The editor behind an item's name: clicking the name it is given opens the summary and the path. +A caller that has no name to give it — a bar with a summary field of its own — gets a pen instead. + +A caller that draws the path itself should render it from `snapshotPath ?? path`: while the +popover is open this holds the path as it was when it opened, so the trail — and the popover +anchored to it — does not reflow under the pointer as the user types, which floating-ui would +follow. +--> +<script lang="ts"> + import type { ComponentProps, Snippet } from 'svelte' + import { Alert } from '$lib/components/common' + import PathEditPen from '$lib/components/PathEditPen.svelte' + import Popover from '$lib/components/meltComponents/Popover.svelte' + import Path from '$lib/components/Path.svelte' + import Label from '$lib/components/Label.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import { isOwner } from '$lib/utils' + import { userStore } from '$lib/stores' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + + interface Props { + /** The item's human name. Committed trimmed, like the bar's own field did. */ + summary?: string + /** When false the summary is shown but not editable, for a host whose `customUi` says so. */ + summaryEditable?: boolean + /** When false the path is shown but not editable, the same way. A host may allow one and + * refuse the other, and the popover offers whichever it is given. */ + pathEditable?: boolean + path?: string + /** The item's *saved* path on the server, so the popover can say a rename needs deploying. */ + savedPath?: string + /** Scopes the path picker's validation, and names the item in the placeholder and the + * deploy note. An agent lives at a resource path, so this is wider than the three + * workspace item kinds. */ + kind?: ComponentProps<typeof Path>['kind'] + /** Workspace the path picker scopes to; defaults to the operating workspace. */ + workspaceId?: string + /** When set, warns that a redeploy happens on behalf of the current user instead. */ + onBehalfOfEmail?: string | undefined + /** Only read when there is no `label`, where the pen is the whole trigger. */ + penVisibility?: 'hover' | 'always' + /** The path as it was when the popover opened, undefined while it is closed. See the + * component note: the caller draws its trail from this so it holds still mid-rename. */ + snapshotPath?: string | undefined + /** The item's name, rendered inside the trigger so clicking it opens the editor. Without + * one the trigger is a pen, for a host that draws its name somewhere this cannot reach. */ + label?: Snippet + /** The trigger's own classes, for a host that needs it to wear the look of the row it sits + * in — the band's path segment. The default is a name with a hover fill behind it: the + * click opens an editor here rather than going anywhere, and the fill is what says the + * name is a control at all now that no pen sits beside it. */ + triggerClass?: string + /** Which field takes the cursor on open. A popover anchored under the path opens on the + * path, one under the summary opens on the summary: the click says which half of the name + * the user came to change. `Path` autofocuses itself, so 'path' is the absence of a + * claim on the summary rather than a claim of its own. */ + focusField?: 'summary' | 'path' + /** The path field's verdict, for a host that refuses to deploy while it is non-empty. + * A host whose item has a second path field has to bind both to the same slot, or + * whichever one is unmounted leaves its last verdict standing. */ + error?: string + } + + let { + summary = $bindable(), + summaryEditable = true, + pathEditable = true, + path = $bindable(), + savedPath, + kind = 'flow', + workspaceId, + onBehalfOfEmail, + penVisibility = 'hover', + label, + triggerClass = 'inline-flex items-center gap-1.5 min-w-0 max-w-full px-1 py-0.5 rounded cursor-pointer text-left hover:bg-surface-hover transition-colors', + focusField = 'summary', + snapshotPath = $bindable(), + error = $bindable() + }: Props = $props() + + const penLabel = $derived( + summaryEditable && pathEditable + ? 'Edit summary and path' + : summaryEditable + ? 'Edit summary' + : 'Edit path' + ) + + // The popover's own state: a host opens this editor by clicking the name it handed over, so + // there is nothing for one to drive from outside. + let open = $state(false) + + // Treat an empty path as ownable so the popover lets a user pick the path for a brand-new + // item. `Path.reset()` then synthesizes a default under their own user/folder scope. + const own = $derived(pathEditable && (!path || isOwner(path, actingUser, $operatingWorkspace))) + + /** The path's name field inside this popover. `Path` hardcodes `id="path"`, and melt resolves a + * string `openFocus` with a document-wide `querySelector`, so a popover portalled to the body + * would hand the keystrokes to whichever other `Path` the page happens to have mounted. */ + const pathField = () => + document.querySelector<HTMLInputElement>('[data-path-edit-path] #path') ?? null + + function setOpen(v: boolean) { + open = v + // Only snapshot when there is a path to freeze. A new flow or script opens with `path === ''` + // and relies on `Path.reset()` to seed one; snapshotting the empty value would leave the + // trail blank for the popover's whole lifetime. + snapshotPath = v && path ? path : undefined + } +</script> + +<!-- `openFocus`: without naming the path's field melt lands on the row's first button, so a + popover opened from the path would not be typing into the path. --> +<Popover + placement="bottom-start" + contentClasses="p-4" + class={triggerClass} + triggerAttrs={{ title: penLabel, 'aria-label': penLabel }} + usePointerDownOutside + excludeSelectors=".drawer" + disableFocusTrap + closeOnOtherPopoverOpen + openFocus={focusField === 'path' + ? pathField + : summaryEditable + ? '[data-path-edit-summary]' + : undefined} + bind:isOpen={() => open, setOpen} +> + {#snippet trigger()} + {#if label} + <!-- The name is the affordance; a pen next to it would be a second one for the same + click, and the band keeps its line quiet. --> + {@render label()} + {:else} + <PathEditPen label={penLabel} visibility={penVisibility} {open} /> + {/if} + {/snippet} + {#snippet content()} + <div class="flex flex-col gap-6 w-[480px]"> + <!-- The summary first: it is the name a reader sees, and the path below is where that + name lives. --> + {#if summaryEditable} + <Label label="Summary"> + <TextInput + bind:value={summary} + inputProps={{ + placeholder: 'Add a summary...', + 'data-path-edit-summary': '', + // The popover sits in a page that binds keys of its own (the editors all do), + // and a summary is prose — it must not reach them. Enter commits the line and + // closes, which is what a one-line field reads as; the path below is reached + // with Tab, not Enter. + onkeydown: (e: KeyboardEvent) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + summary = summary?.trim() + setOpen(false) + } + }, + onblur: () => (summary = summary?.trim()) + }} + size="sm" + /> + </Label> + {:else if summary} + <Label label="Summary"> + <span class="text-xs text-secondary">{summary}</span> + </Label> + {/if} + {#if own} + <!-- Both of these say the same thing — the item's own saved path is not a collision — + and both are needed, because `Path` skips the whole existence check while the + typed path equals `initialPath`. Seeding that from the working path instead + would make a rename to a taken path look untaken the moment the popover is + reopened on it. With no saved path there is nothing the item occupies, so every + path is checked. --> + <div data-path-edit-path> + <Path + autofocus + bind:path + bind:error + initialPath={savedPath ?? path ?? ''} + allowedExistingPath={savedPath} + checkInitialPathExistence={savedPath == undefined} + namePlaceholder={kind} + {kind} + size="sm" + drawerOffset={4000} + workspaceOverride={workspaceId} + /> + </div> + {#if savedPath && path && path !== savedPath} + <Alert + type="info" + size="xs" + title="Deploy the {kind} to make the path change effective." + /> + {/if} + {#if onBehalfOfEmail} + <Alert type="info" title="Run on behalf of" size="xs"> + This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail} + </Alert> + {/if} + {:else} + <Label label="Path"> + <span class="text-xs font-mono text-secondary">{path}</span> + <p class="text-2xs text-tertiary mt-1"> + {pathEditable + ? 'Only the owner can change the path' + : 'This path cannot be changed here'} + </p> + </Label> + {/if} + </div> + {/snippet} +</Popover> diff --git a/frontend/src/lib/components/PersistentScriptDrawer.svelte b/frontend/src/lib/components/PersistentScriptDrawer.svelte index 168169e937..e1bd3401bd 100644 --- a/frontend/src/lib/components/PersistentScriptDrawer.svelte +++ b/frontend/src/lib/components/PersistentScriptDrawer.svelte @@ -3,7 +3,6 @@ import { Badge, Button, Drawer } from './common' import DrawerContent from './common/drawer/DrawerContent.svelte' import { createEventDispatcher, onDestroy } from 'svelte' - import { base } from '$lib/base' import { displayDate, sleep, sendUserToast } from '$lib/utils' import TableCustom from './TableCustom.svelte' import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte' @@ -141,7 +140,7 @@ <td class="text-xs"> <a class="pr-3" - href="{base}/scripts/get/{scriptHash}?workspace={$operatingWorkspace}" + href="/scripts/get/{scriptHash}?workspace={$operatingWorkspace}" target="_blank" > {scriptHash} @@ -150,7 +149,7 @@ <td class="text-xs"> <a class="pr-3" - href="{base}/run/{jobId}?workspace={$operatingWorkspace}" + href="/run/{jobId}?workspace={$operatingWorkspace}" target="_blank">{jobId.substring(24)}</a > </td> diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 9847454aca..bd9d51638d 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -7,7 +7,8 @@ type Resource, type ResourceType } from '$lib/gen' - import { canWrite } from '$lib/utils' + import { canEditItem } from '$lib/editRights' + import { userWorkspaces } from '$lib/stores' import { createEventDispatcher, onDestroy, untrack } from 'svelte' import { sendUserToast } from '$lib/toast' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' @@ -261,8 +262,16 @@ if (!initialPath || !selected) return true const r = fetchedResources[selected] if (!r || !acting.resolved(selected)) return undefined - return canWrite(current?.path ?? initialPath, r.extra_perms ?? {}, acting.in(selected)) + return canEditResourceIn(selected, current?.path ?? initialPath, r.extra_perms) }) + function canEditResourceIn( + ws: string, + path: string, + extraPerms: Record<string, boolean> | undefined + ): boolean { + const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings + return canEditItem('resource', path, extraPerms, acting.in(ws), settings) + } const dirtyWorkspaces = $derived( Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) @@ -423,9 +432,7 @@ const dirtyCanWrite = $derived( dirtyWorkspaces.every((ws) => { const r = fetchedResources[ws] - return ( - !r || canWrite(states[ws]?.draft?.path ?? initialPath, r.extra_perms ?? {}, acting.in(ws)) - ) + return !r || canEditResourceIn(ws, states[ws]?.draft?.path ?? initialPath, r.extra_perms) }) ) diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index ff32cbdbd7..ad5e13df6c 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -11,23 +11,13 @@ import { sendUserToast } from '$lib/toast' import { displayDate } from '$lib/utils' + import { userWorkspaces, superadmin, devopsRole, enterpriseLicense } from '$lib/stores' + import { useHostedPage } from '$lib/components/hostedPage' import { - userStore, - workspaceStore, - userWorkspaces, - superadmin, - devopsRole, - enterpriseLicense - } from '$lib/stores' - import { - Button, - ButtonType, - Drawer, - DrawerContent, - Skeleton, - Tab, - Tabs - } from '$lib/components/common' + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common' import TextInput from '$lib/components/text_input/TextInput.svelte' import RunChart from '$lib/components/RunChart.svelte' @@ -44,8 +34,9 @@ import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import RunsQueue from '$lib/components/runs/RunsQueue.svelte' import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' - import { pageHref, RUNS_PATH } from '$lib/components/sessions/previewPaths' - import { twMerge } from 'tailwind-merge' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' + import { RUNS_PATH } from '$lib/components/sessions/previewPaths' import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte' import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte' import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte' @@ -87,6 +78,13 @@ let { initialPath }: Props = $props() + // Under a host — an AI session's preview tab — the document URL, the page header band and + // the navigation workspace all belong to the host: the filters live where it keeps them, + // the filter row renders in the page, and requests go to the workspace it names. + const hosted = useHostedPage() + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + let paths: string[] = $state([]) let usernames: string[] = $state([]) let folders: string[] = $state([]) @@ -97,7 +95,7 @@ folders, jobTriggerKinds, isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, - isAdminsWorkspace: $workspaceStore === 'admins' + isAdminsWorkspace: $operatingWorkspace === 'admins' }) ) let perPage = useLocalStorageValue('runs_per_page', 1000, 'number') @@ -121,6 +119,8 @@ // what was seeded at mount is gone. Re-apply it. Editing a filter writes with `replaceState`, // which never reaches `page.url`, so a filter the user clears stays cleared. $effect(() => { + // A host remounts the page when it re-points it, and its document URL is not this page's. + if (hosted) return page.url.href untrack(() => filters.seed(filterSeeds())) }) @@ -177,7 +177,9 @@ let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined) let graph: 'RunChart' | 'ConcurrencyChart' = $state( - typeOfChart(page.url.searchParams.get('graph')) + typeOfChart( + (hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph') + ) ) let innerWidth = $state(window.innerWidth) let jobsLoader = useJobsLoader(() => ({ @@ -189,7 +191,7 @@ resultError, perPage: perPage.val, lookback: graph === 'RunChart' ? 0 : lookback, - currentWorkspace: $workspaceStore ?? '' + currentWorkspace: $operatingWorkspace ?? '' })) let batchProgress = $derived(jobsLoader.batchProgress) let currentBatchSize = $derived(jobsLoader.currentBatchSize) @@ -217,18 +219,18 @@ } async function loadUsernames(): Promise<void> { - usernames = await UserService.listUsernames({ workspace: $workspaceStore! }) + usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! }) } async function loadFolders(): Promise<void> { folders = await FolderService.listFolders({ - workspace: $workspaceStore! + workspace: $operatingWorkspace! }).then((x) => x.map((y) => y.name)) } async function loadPaths() { - const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' }) - const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' }) + const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' }) + const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' }) paths = npaths_scripts.concat(npaths_flows).sort() } @@ -271,7 +273,7 @@ const resultFilter = filters.val.result && JSON.parse(filters.val.result) const { minTs, maxTs } = timeframe.computeMinMax() return { - workspace: $workspaceStore ?? '', + workspace: $operatingWorkspace ?? '', startedBefore: maxTs ?? undefined, startedAfter: minTs ?? undefined, schedulePath: filters.val.schedule_path ?? undefined, @@ -329,7 +331,7 @@ } async function cancelJobs(uuidsToCancel: string[], forceCancel: boolean = false) { const uuids = await JobService.cancelSelection({ - workspace: $workspaceStore ?? '', + workspace: $operatingWorkspace ?? '', requestBody: uuidsToCancel, forceCancel: forceCancel, allWorkspaces: filters.val.all_workspaces || undefined @@ -356,7 +358,7 @@ for (const j of jobs ?? []) if (j.workspace_id) workspaceById.set(j.id, j.workspace_id) const byWorkspace = new Map<string, string[]>() for (const id of jobIds) { - const ws = workspaceById.get(id) ?? $workspaceStore ?? '' + const ws = workspaceById.get(id) ?? $operatingWorkspace ?? '' const bucket = byWorkspace.get(ws) if (bucket) bucket.push(id) else byWorkspace.set(ws, [id]) @@ -434,7 +436,7 @@ } async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) { - if (!$workspaceStore) return + if (!$operatingWorkspace) return if (askingForConfirmation) { askingForConfirmation.loading = true @@ -448,7 +450,7 @@ // workaround because EventSource does not support POST requests // https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425 - const response = await fetch(`${OpenAPI.BASE}/w/${$workspaceStore}/jobs/run/batch_rerun_jobs`, { + const response = await fetch(`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) @@ -561,7 +563,7 @@ } $effect(() => { - if ($workspaceStore) { + if ($operatingWorkspace) { untrack(() => { loadUsernames() loadFolders() @@ -609,8 +611,247 @@ openedRunId = undefined } let resolutionNote = $state('') + // The bar folds this row (`actionFolds` below) once its breadcrumb has given all it can: first + // the counters, which are a glance and repeat the chart below; then the session button; then + // the filter field moves to a row of its own rather than shrinking past usable. Hosted, the row + // is the page's own and wraps instead. + const fold = $derived(hosted ? 0 : pageHeader.actionFold) + const filterBelow = $derived(fold >= 3) </script> +{#snippet runsHint()} + <Tooltip + documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs" + > + All past and schedule executions of scripts and flows, including previews. You only see your own + runs or runs of groups you belong to unless you are an admin. + </Tooltip> +{/snippet} + +{#snippet runsFilter(cls: string)} + <FilterSearchbar + class={cls} + schema={runsFilterSearchbarSchema} + presets={buildRunsFilterPresets({ + isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, + isAdminsWorkspace: $operatingWorkspace === 'admins' + })} + bind:value={filters.val} + placeholder="Filter runs..." + size="sm" + autofocus={!hosted} + /> +{/snippet} + +{#snippet filterRow()} + {@render runsFilter('relative w-full min-w-0')} +{/snippet} + +{#snippet headerActions()} + <!-- The page's own row inside the band's actions box: these controls are denser than a page's + two or three buttons, so they take a wider gap than the band's own. Every control keeps its + width: the bar measures the row and folds it rather than squeezing it. --> + <div class="flex items-center gap-3 {hosted ? 'flex-wrap justify-end' : ''}"> + <!-- Always the icon form: the bar has room for two counters, never for the two labelled + blocks. pr-2 on top of the row's own gap because the counts hang outside their icons, + so without it the badge butts against the first filter. --> + <div class="flex items-center pr-2 {fold >= 1 ? 'hidden' : ''}"> + <RunsQueue + success={filters.val.status ?? null} + {queue_count} + {suspended_count} + onJobsWaiting={() => { + jobsFilter('waiting') + }} + onJobsSuspended={() => { + jobsFilter('suspended') + }} + small + /> + </div> + <!-- The two groups are the widest thing in the bar: they appear only once the bar is wide + enough to hold them next to the search field, below which the same filtering is reachable + from the search field's own `kind` and `status` chips. --> + <div class="hidden min-[1500px]:flex gap-2"> + <ToggleButtonGroup + tabListClass="hidden min-[1500px]:flex" + bind:selected={ + () => filters.val.job_kinds ?? 'runs', + (v) => (v === 'runs' ? delete filters.val.job_kinds : (filters.val.job_kinds = v)) + } + > + {#snippet children({ item })} + <ToggleButton size="sm" value="all" label="All" {item} /> + <ToggleButton + size="sm" + value="runs" + label="Runs" + tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook" + {item} + /> + <ToggleButton + size="sm" + value="dependencies" + label="Deps" + tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies." + {item} + /> + <ToggleButtonMore + small + hideSelectedOption={innerWidth < smallScreenWidth} + togglableItems={[ + { + label: 'Previews', + value: 'previews', + tooltip: "Previews are jobs that have been started in the editor as 'Tests'" + }, + { + label: 'Sync', + value: 'deploymentcallbacks', + tooltip: + 'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings' + } + ]} + {item} + bind:selected={filters.val.job_kinds} + /> + {/snippet} + </ToggleButtonGroup> + <ToggleButtonGroup + bind:selected={ + () => filters.val.status ?? 'all', + (v) => (v === 'all' ? delete filters.val.status : (filters.val.status = v)) + } + id="status" + > + {#snippet children({ item })} + <ToggleButton size="sm" value={'all'} label="All" {item} /> + <ToggleButton + size="sm" + value={'running'} + tooltip="Running" + class="whitespace-nowrap" + icon={CirclePlay} + iconProps={{ + class: + 'group-data-[state=on]:text-yellow-600 dark:group-data-[state=on]:text-yellow-400' + }} + {item} + /> + <ToggleButton + size="sm" + value={'success'} + tooltip="Success" + class="whitespace-nowrap" + icon={CircleCheck} + iconProps={{ + class: + 'group-data-[state=on]:text-green-500 dark:group-data-[state=on]:text-green-300' + }} + {item} + /> + <ToggleButton + size="sm" + value={'failure'} + tooltip="Failure" + class="whitespace-nowrap" + icon={CircleAlert} + iconProps={{ + class: 'group-data-[state=on]:text-red-500 dark:group-data-[state=on]:text-red-300' + }} + {item} + /> + <ToggleButton + size="sm" + value={'canceled'} + tooltip="Canceled" + class="whitespace-nowrap" + icon={Hourglass} + selectedColor="gray" + {item} + /> + {#if filters.val.status == 'waiting'} + <ToggleButton + size="sm" + value={'waiting'} + tooltip="Waiting" + class="whitespace-nowrap" + icon={Hourglass} + selectedColor="blue" + {item} + /> + {:else if filters.val.status == 'suspended'} + <ToggleButton + size="sm" + value={'suspended'} + tooltip="Suspended" + class="whitespace-nowrap" + icon={Hourglass} + selectedColor="purple" + {item} + /> + {/if} + {/snippet} + </ToggleButtonGroup> + </div> + + <div class="hidden xl:flex gap-2 items-center"> + {#if !filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule'} + <div class="flex items-center gap-1" title="Show schedules"> + <Toggle + size="xs" + id="show-schedules" + bind:checked={ + () => filters.val.job_trigger_kind !== '!schedule', + (v) => + v + ? delete filters.val.job_trigger_kind + : (filters.val.job_trigger_kind = '!schedule') + } + /> + <Calendar size={14} /> + </div> + {/if} + <div class="flex items-center gap-1" title="Show future jobs"> + <Toggle + size="xs" + id="show-future-jobs" + bind:checked={ + () => filters.val.show_future_jobs !== false, + (v) => + v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false) + } + /> + <Clock size={14} /> + </div> + </div> + + <!-- A width of its own, never what the row has left over: as `flex-1` a chip appearing or a + filter committing resized it under the cursor. `shrink-0` keeps it at that width, which + is the row's width the bar measures and folds. --> + {#if !filterBelow}{@render runsFilter('relative w-[18rem] shrink-0')}{/if} + <TimeframeSelect + unifiedSize="sm" + onClick={() => jobsLoader?.loadJobs(true)} + loading={jobsLoader?.loading} + items={runsTimeframes} + bind:value={_timeframe.val} + /> + <!-- The filters are shallow-routed, so the search has to come off `window.location` at click + time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized + preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. --> + <div class={fold >= 2 || hosted ? 'hidden' : 'contents'}> + <OpenInSessionButton + source={{ + page: () => RUNS_PATH + window.location.search, + workspaceId: $operatingWorkspace ?? undefined + }} + btnProps={{ unifiedSize: 'sm' }} + /> + </div> + </div> +{/snippet} + <ConfirmationModal title={askingForConfirmation?.title ?? ''} confirmationText={askingForConfirmation?.confirmBtnText ?? ''} @@ -666,243 +907,33 @@ </DrawerContent> </Drawer> +<!-- The document's history is the host's: its Back is not a move on this page. --> <svelte:window onpopstate={() => { - reset() + if (!hosted) reset() }} /> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.runs} +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.runs} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> <p>Page not available for operators</p> </div> {:else} - <div class="w-full h-screen flex flex-col" bind:clientWidth={innerWidth}> - <!-- Header and filters --> - <div id="runs-filters-bar" class="flex flex-row items-start w-full px-4 gap-3 py-4 flex-wrap"> - <div class="flex flex-row items-center gap-6"> - <h1 - class={twMerge( - '!text-2xl font-semibold leading-6 tracking-tight', - $userStore?.operator ? 'pl-10' : '' - )} - > - Runs - </h1> - - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs" - > - All past and schedule executions of scripts and flows, including previews. You only see - your own runs or runs of groups you belong to unless you are an admin. - </Tooltip> - - <!-- Queue --> - <RunsQueue - success={filters.val.status ?? null} - {queue_count} - {suspended_count} - onJobsWaiting={() => { - jobsFilter('waiting') - }} - onJobsSuspended={() => { - jobsFilter('suspended') - }} - small={innerWidth < smallScreenWidth} - /> + <div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}> + {#if hosted} + <div class="flex px-4 pt-3 pb-2"> + {@render headerActions()} </div> - - <div class="hidden xl:flex gap-2 ml-6"> - <ToggleButtonGroup - tabListClass="hidden xl:flex" - bind:selected={ - () => filters.val.job_kinds ?? 'runs', - (v) => (v === 'runs' ? delete filters.val.job_kinds : (filters.val.job_kinds = v)) - } - > - {#snippet children({ item })} - <ToggleButton value="all" label="All" {item} /> - <ToggleButton - value="runs" - label="Runs" - tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook" - {item} - /> - <ToggleButton - value="dependencies" - label="Deps" - tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies." - {item} - /> - <ToggleButtonMore - hideSelectedOption={innerWidth < smallScreenWidth} - togglableItems={[ - { - label: 'Previews', - value: 'previews', - tooltip: "Previews are jobs that have been started in the editor as 'Tests'" - }, - { - label: 'Sync', - value: 'deploymentcallbacks', - tooltip: - 'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings' - } - ]} - {item} - bind:selected={filters.val.job_kinds} - /> - {/snippet} - </ToggleButtonGroup> - <ToggleButtonGroup - bind:selected={ - () => filters.val.status ?? 'all', - (v) => (v === 'all' ? delete filters.val.status : (filters.val.status = v)) - } - id="status" - > - {#snippet children({ item })} - <ToggleButton value={'all'} label="All" {item} /> - <ToggleButton - value={'running'} - tooltip="Running" - class="whitespace-nowrap" - icon={CirclePlay} - iconProps={{ - class: - 'group-data-[state=on]:text-yellow-600 dark:group-data-[state=on]:text-yellow-400' - }} - {item} - /> - <ToggleButton - value={'success'} - tooltip="Success" - class="whitespace-nowrap" - icon={CircleCheck} - iconProps={{ - class: - 'group-data-[state=on]:text-green-500 dark:group-data-[state=on]:text-green-300' - }} - {item} - /> - <ToggleButton - value={'failure'} - tooltip="Failure" - class="whitespace-nowrap" - icon={CircleAlert} - iconProps={{ - class: 'group-data-[state=on]:text-red-500 dark:group-data-[state=on]:text-red-300' - }} - {item} - /> - <ToggleButton - value={'canceled'} - tooltip="Canceled" - class="whitespace-nowrap" - icon={Hourglass} - selectedColor="gray" - {item} - /> - {#if filters.val.status == 'waiting'} - <ToggleButton - value={'waiting'} - tooltip="Waiting" - class="whitespace-nowrap" - icon={Hourglass} - selectedColor="blue" - {item} - /> - {:else if filters.val.status == 'suspended'} - <ToggleButton - value={'suspended'} - tooltip="Suspended" - class="whitespace-nowrap" - icon={Hourglass} - selectedColor="purple" - {item} - /> - {/if} - {/snippet} - </ToggleButtonGroup> - </div> - - <div class="hidden xl:flex gap-2 items-center min-h-8 ml-2"> - {#if !filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule'} - <div class="flex items-center gap-1" title="Show schedules"> - <Toggle - size="xs" - color="nord" - id="show-schedules" - bind:checked={ - () => filters.val.job_trigger_kind !== '!schedule', - (v) => - v - ? delete filters.val.job_trigger_kind - : (filters.val.job_trigger_kind = '!schedule') - } - /> - <Calendar size={14} /> - </div> - {/if} - <div class="flex items-center gap-1" title="Show future jobs"> - <Toggle - size="xs" - color="nord" - id="show-future-jobs" - bind:checked={ - () => filters.val.show_future_jobs !== false, - (v) => - v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false) - } - /> - <Clock size={14} /> - </div> - </div> - - <TimeframeSelect - wrapperClasses="ml-auto" - onClick={() => jobsLoader?.loadJobs(true)} - loading={jobsLoader?.loading} - items={runsTimeframes} - bind:value={_timeframe.val} + {:else} + <PageHeaderContent + afterName={runsHint} + actions={headerActions} + actionFolds={3} + belowBar={filterRow} + belowBarFold={3} /> - <!-- One flex item, so the session button never wraps to a row of its own. --> - <div class="flex flex-1 items-start justify-end gap-3"> - <FilterSearchbar - class={twMerge( - 'flex-1 relative min-w-[14rem]', - Object.keys(filters.val).length <= 3 - ? 'max-w-[20rem] 2xl:max-w-[28rem]' - : 'max-w-[34rem]', - ButtonType.UnifiedMinHeightClasses.md - )} - schema={runsFilterSearchbarSchema} - presets={buildRunsFilterPresets({ - isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, - isAdminsWorkspace: $workspaceStore === 'admins' - })} - bind:value={filters.val} - placeholder="Filter runs..." - autofocus - /> - <!-- The filters are shallow-routed, so the search has to come off - `window.location` at click time — `page.url` never sees them. Always the - canonical `/runs`: only that is a recognized preview page, and the - `/runs/<path>` route mirrors its path into `?path=` anyway. --> - <!-- The button stretches to its parent's height, which is the whole header once - this row wraps: pin it to one row's height. --> - <div class={twMerge('flex empty:hidden', ButtonType.UnifiedHeightClasses.md)}> - <OpenInSessionButton - source={{ - page: () => pageHref(RUNS_PATH) + window.location.search, - workspaceId: $workspaceStore ?? undefined - }} - btnProps={{ unifiedSize: 'md' }} - /> - </div> - </div> - </div> + {/if} <!-- Graph --> <div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md"> @@ -1093,7 +1124,7 @@ batchRerunOptionsIsOpen = true } }, - ...(!$userStore?.operator + ...(!operatingUser.current?.operator ? [ { // Operators are rejected by the endpoint, so offering it would only 403. diff --git a/frontend/src/lib/components/S3FilePicker.svelte b/frontend/src/lib/components/S3FilePicker.svelte index ce1af47118..a5e4849c57 100644 --- a/frontend/src/lib/components/S3FilePicker.svelte +++ b/frontend/src/lib/components/S3FilePicker.svelte @@ -105,6 +105,7 @@ size="1200px" > <DrawerContent + noPadding title={s3ResourcePath ? `Exploring ${s3ResourcePath}` : 'S3 file browser'} on:close={() => { s3FilePickerInner?.exit?.() diff --git a/frontend/src/lib/components/S3FilePickerInner.svelte b/frontend/src/lib/components/S3FilePickerInner.svelte index 1bf1b4358a..89c23e84ae 100644 --- a/frontend/src/lib/components/S3FilePickerInner.svelte +++ b/frontend/src/lib/components/S3FilePickerInner.svelte @@ -8,8 +8,35 @@ Loader2, Download, Trash, - MoveRight + MoveRight, + Ellipsis, + FileText, + FileCode, + FileJson, + FileSpreadsheet, + FileImage, + FileArchive, + FilePlay, + FileAudio, + Table2 } from 'lucide-svelte' + import { Pane, Splitpanes } from 'svelte-splitpanes' + import TextInput from './text_input/TextInput.svelte' + import Select from './select/Select.svelte' + import Toggle from './Toggle.svelte' + import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import type { IDbTableOps } from './dbOps' + import { resource } from 'runed' + import { staticDbTableOps } from './staticDbTableOps' + import { + fetchJsonTableRows, + isJsonFile, + JSON_TABLE_MAX_BYTES, + s3TableOps, + serverTableFormat, + type S3TableSource + } from './s3TableOps' import { CancelablePromise, HelpersService, @@ -28,21 +55,19 @@ type MoveS3FileData, type MoveS3FileResponse } from '$lib/gen' - import { base } from '$lib/base' import { displayDate, displaySize, emptyString, parseS3Object, sendUserToast, - type S3Object + type S3Object, + type IconType } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' - import { Alert, Button } from './common' - import Section from './Section.svelte' + import { Alert, Button, CopyButton } from './common' import { createEventDispatcher, untrack, type Snippet } from 'svelte' import VirtualList from '@tutorlatin/svelte-tiny-virtual-list' - import TableSimple from './TableSimple.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' import FileUploadModal from './common/fileUpload/FileUploadModal.svelte' import S3FilePreview from './S3FilePreview.svelte' @@ -144,8 +169,14 @@ let rootPath = $state(initialRootPath) let rootPathNestingLevel = $derived(1 * (rootPath.split('/').length - 1)) - let csvSeparatorChar: string = $state(',') + let csvSeparatorChar: string | undefined = $state(undefined) let csvHasHeader: boolean = $state(true) + const CSV_SEPARATORS = [ + { value: ',', label: ',' }, + { value: ';', label: ';' }, + { value: '\t', label: 'Tab' }, + { value: '|', label: '|' } + ] let dispatch = createEventDispatcher<{ close: { s3: string; storage: string | undefined } | undefined @@ -179,6 +210,68 @@ } | undefined = $state(undefined) + let viewMode: 'table' | 'text' = $state('table') + /** The selected file, when it can be shown as a table. Its own requests can't read + * files behind a custom listing (git repos), so those keep the text preview. */ + let tableSource = $derived.by(() => { + if (hideS3SpecificDetails || !fileMetadata || !ws) return undefined + const fileKey = fileMetadata.fileKey + const inBrowser = isJsonFile(fileKey) && (fileMetadata.size ?? Infinity) <= JSON_TABLE_MAX_BYTES + if (!inBrowser && !serverTableFormat(fileKey)) return undefined + return { + workspace: ws, + fileKey, + storage, + s3ResourcePath, + csvSeparator: csvSeparatorChar, + csvHasHeader, + inBrowser + } + }) + let tableKey = $derived(tableSource && JSON.stringify(tableSource)) + let tableOps = resource( + () => (viewMode === 'table' ? tableSource : undefined), + async (source, _previous, { signal }) => { + if (!source) return undefined + const key = JSON.stringify(source) + // A response for a file or options since left must not replace the current one; + // the resource drops a fetch that throws an AbortError. + const superseded = () => new DOMException('Superseded', 'AbortError') + try { + const table = source.inBrowser ? await jsonTable(source) : await s3TableOps(source) + if (signal.aborted) throw superseded() + return { key, ops: table?.ops, csvSeparator: table?.csvSeparator } + } catch (e) { + throw signal.aborted ? superseded() : e + } + } + ) + async function jsonTable( + source: S3TableSource + ): Promise<{ ops: IDbTableOps; csvSeparator?: string } | undefined> { + const rows = await fetchJsonTableRows(source) + return rows && { ops: staticDbTableOps(rows, 'duckdb', source.fileKey.split('/').pop() ?? '') } + } + /** The loaded table, once it belongs to the selected file and options. */ + let currentTable = $derived(tableOps.current?.key === tableKey ? tableOps.current : undefined) + /** A JSON file that turns out not to be an array has no table to show. */ + let showTable = $derived( + viewMode === 'table' && !!tableSource && !(currentTable && !currentTable.ops) + ) + // Each file starts on the separator the server guesses from its first bytes. + $effect(() => { + fileMetadata?.fileKey + untrack(() => { + csvSeparatorChar = undefined + detectedCsvSeparator = undefined + }) + }) + /** Kept while the file stays selected, so the text view still shows what was guessed. */ + let detectedCsvSeparator: string | undefined = $state(undefined) + $effect(() => { + if (currentTable?.csvSeparator) detectedCsvSeparator = currentTable.csvSeparator + }) + /** Identifies the metadata request that currently owns the preview pane. */ let metadataRequestId = 0 @@ -229,6 +322,25 @@ /** Per-level listing only makes sense while browsing; searching stays flat. */ let lazyMode = $derived(lazyFolders && filter.trim() === '') + /** `nestingLevel` counts two per folder depth. */ + function treeRowIndent(nestingLevel: number): string { + return `padding-left: ${12 + (nestingLevel / 2) * 16}px;` + } + + const FILE_ICONS: [RegExp, IconType][] = [ + [/\.(csv|tsv|xlsx?|parquet|ods)$/i, FileSpreadsheet], + [/\.(json|jsonl|ndjson)$/i, FileJson], + [/\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?)$/i, FileImage], + [/\.(zip|tar|gz|tgz|bz2|xz|7z|rar|zst)$/i, FileArchive], + [/\.(mp4|mov|webm|mkv|avi)$/i, FilePlay], + [/\.(mp3|wav|ogg|flac|m4a)$/i, FileAudio], + [/\.(py|ts|js|go|rs|sh|sql|java|rb|php|ya?ml|toml|html|css|xml)$/i, FileCode], + [/\.(txt|md|log|pdf|docx?|rtf)$/i, FileText] + ] + function fileIconFor(name: string): IconType { + return FILE_ICONS.find(([re]) => re.test(name))?.[1] ?? FileIcon + } + function nestingLevelOf(key: string): number { const slashes = (key.match(/\//g) ?? []).length return (key.endsWith('/') ? slashes - 1 : slashes) * 2 @@ -1047,41 +1159,50 @@ }) </script> +{#snippet tableLoading()} + <div class="flex items-center gap-2 p-3 text-xs text-tertiary"> + <Loader2 size={14} class="animate-spin" /> Loading table... + </div> +{/snippet} + {#if workspaceSettingsInitialized === false} - {#if fromWorkspaceSettings} - <Alert type="error" title="Connection to remote S3 bucket unsuccessful"> - <div class="flex flex-row gap-x-1 w-full items-center"> - <p class="text-clip grow min-w-0"> Double check the S3 resource fields and try again. </p> - </div> - </Alert> - {:else if s3ResourcePath} - <Alert type="error" title="Could not connect to the object storage of {s3ResourcePath}"> - <div class="flex flex-row gap-x-1 w-full items-center"> - <p class="text-clip grow min-w-0"> - Double check the resource fields and that its object storage is reachable, then try again. - </p> - <Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} /> - </div> - </Alert> - {:else} - <Alert type="error" title="Workspace not connected to any S3 storage"> - <div class="flex flex-row gap-x-1 w-full items-center"> - <p class="text-clip grow min-w-0"> - The workspace needs to be connected to an S3 storage to use this feature. You can <a - target="_blank" - href="{base}/workspace_settings?tab=windmill_lfs">configure it here</a - >. - </p> - <Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} /> - </div> - </Alert> - {/if} + <div class="p-4"> + {#if fromWorkspaceSettings} + <Alert type="error" title="Connection to remote S3 bucket unsuccessful"> + <div class="flex flex-row gap-x-1 w-full items-center"> + <p class="text-clip grow min-w-0"> Double check the S3 resource fields and try again. </p> + </div> + </Alert> + {:else if s3ResourcePath} + <Alert type="error" title="Could not connect to the object storage of {s3ResourcePath}"> + <div class="flex flex-row gap-x-1 w-full items-center"> + <p class="text-clip grow min-w-0"> + Double check the resource fields and that its object storage is reachable, then try + again. + </p> + <Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} /> + </div> + </Alert> + {:else} + <Alert type="error" title="Workspace not connected to any S3 storage"> + <div class="flex flex-row gap-x-1 w-full items-center"> + <p class="text-clip grow min-w-0"> + The workspace needs to be connected to an S3 storage to use this feature. You can <a + target="_blank" + href="/workspace_settings?tab=windmill_lfs">configure it here</a + >. + </p> + <Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} /> + </div> + </Alert> + {/if} + </div> {:else} {#if fileListUnavailable == true} {#if replaceUnauthorizedWarning} {@render replaceUnauthorizedWarning()} {:else} - <div class="mb-2"> + <div class="p-3 border-b"> <Alert type="info" title="Access to S3 bucket restricted"> <p> You don't have access to the S3 bucket resource and your administrator has restricted @@ -1098,28 +1219,25 @@ </div> {/if} {/if} - <div class="flex flex-row border rounded-md h-full min-h-0 overflow-hidden"> + <Splitpanes class="h-full min-h-0 overflow-hidden"> {#if !fileListUnavailable} - <div class="min-w-[30%] border-r flex flex-col min-h-0"> + <Pane size={28} minSize={15} class="flex flex-col min-h-0"> {#if !rootPath} - <div class="w-full p-1 border-b"> - <input - type="text" - placeholder="Search by path prefix" + <div class="mx-3 mt-3"> + <TextInput bind:value={filter} - class="text-xl" + inputProps={{ placeholder: 'Search by path prefix...' }} /> </div> {/if} {#if displayedFileKeys.length === 0} {#if fileListLoading} - <div class="grow min-h-0 flex justify-center items-center"> - <div class="flex text-secondary text-xs items-center"> - <Loader2 size={12} class="animate-spin mr-1" /> Loading content - </div> + <div class="flex items-center gap-2 text-tertiary p-3"> + <Loader2 class="animate-spin" size={14} /> + <span class="text-xs">Loading...</span> </div> {:else} - <div class="p-4 text-primary text-xs text-center italic"> + <div class="p-3 text-tertiary text-xs"> {#if filter.trim() !== ''} No files starting with "{filter.trim()}" {:else} @@ -1131,19 +1249,19 @@ can remove every match. Without this the only control that could resume the listing would be hidden behind the empty state. --> <div class="flex justify-center pb-4"> - <Button variant="default" size="xs2" on:click={() => loadNextFlatPage()}> + <Button variant="default" unifiedSize="xs" onClick={() => loadNextFlatPage()}> Keep looking </Button> </div> {/if} {/if} {:else} - <div class="grow min-h-0" bind:clientHeight={listDivHeight}> + <div class="grow min-h-0 mt-1.5" bind:clientHeight={listDivHeight}> <VirtualList width="100%" height={listDivHeight} itemCount={displayedFileKeys.length} - itemSize={42} + itemSize={32} > {#snippet header()}{/snippet} {#snippet footer()}{/snippet} @@ -1155,199 +1273,249 @@ {@const load_more_prefix = is_load_more ? item_key.slice(0, -1) : ''} {@const file_info = allFilesByKey[item_key]} - <div - {style} - class={twMerge( - 'hover:bg-surface-hover border-b', - index === displayedFileKeys.length - 1 && 'border-b-0' - )} - > + <div {style}> {#if is_load_more} <!-- Indented like the siblings it belongs to, so it must discount the browsing root the same way entry rows do. --> {@const loadMoreNesting = nestingLevelOf(load_more_prefix + 'x') - 2 * rootPathNestingLevel} {@const loadingMore = folderState[load_more_prefix]?.loading === true} - <!-- svelte-ignore a11y_click_events_have_key_events --> - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div + <button onclick={() => !loadingMore && loadMore(load_more_prefix)} - class={twMerge( - 'flex flex-row h-full text-xs items-center justify-start text-secondary', - loadingMore ? 'cursor-default' : 'cursor-pointer' - )} + disabled={loadingMore} + class="w-full text-xs font-normal flex gap-2 items-center h-8 pr-1 text-secondary hover:bg-surface-hover cursor-pointer disabled:cursor-default disabled:hover:bg-transparent" + style={treeRowIndent(loadMoreNesting)} > - <div - class="flex flex-row w-full gap-2 h-full items-center" - style={`margin-left: ${(2 + loadMoreNesting) * 0.25}rem;`} - > - <!-- Occupies the same slot as the sibling rows' file/folder icon so - the labels line up, and holds its width when the spinner swaps in. --> - <div class="w-4 shrink-0 flex items-center justify-center"> - {#if loadingMore} - <Loader2 size={16} class="animate-spin" /> - {:else} - <ChevronDown size={16} /> - {/if} - </div> - <div class="truncate text-ellipsis w-56">Load more</div> - </div> - </div> + <span class="shrink-0 w-3.5"></span> + {#if loadingMore} + <Loader2 class="shrink-0 animate-spin" size={14} /> + {:else} + <Ellipsis class="shrink-0" size={14} /> + {/if} + <span class="truncate">Load more</span> + </button> {:else if file_info} {@const nestingLevel = file_info.nestingLevel - 2 * rootPathNestingLevel} - <!-- svelte-ignore a11y_click_events_have_key_events --> - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div + {@const isSelected = selectedFileKey?.s3 === file_info.full_key} + {@const isFolder = file_info.type === 'folder'} + {@const RowIcon = isFolder + ? file_info.collapsed + ? FolderClosed + : FolderOpen + : fileIconFor(file_info.display_name)} + <button onclick={() => selectItem(index)} + title={file_info.full_key.slice(rootPath.length)} class={twMerge( - 'flex flex-row h-full font-semibold text-xs items-center justify-start', - selectedFileKey !== undefined && selectedFileKey.s3 === file_info.full_key - ? 'bg-surface-hover' - : '' + 'w-full text-xs font-normal text-primary flex gap-2 items-center h-8 pr-1 cursor-pointer', + isSelected ? 'bg-surface-secondary' : 'hover:bg-surface-hover' )} + style={treeRowIndent(nestingLevel)} > - <div - class={`flex flex-row w-full gap-2 h-full items-center`} - style={`margin-left: ${(2 + nestingLevel) * 0.25}rem;`} - > - {#if file_info.type === 'folder'} - {#if folderState[file_info.full_key]?.loading} - <Loader2 size={16} class="animate-spin" /> - {:else if file_info.collapsed}<FolderClosed size={16} />{:else}<FolderOpen - size={16} - />{/if} - <div class="truncate text-ellipsis w-56"> - <!-- An object-store key may contain an empty segment, so `a//` is a real - folder whose name is ''. Label it rather than rendering a blank row. --> - {#if file_info.display_name === ''} - <span class="italic text-secondary">(empty name)</span> - {:else}{file_info.display_name}{/if} - {#if !lazyMode} - ({file_info.count}{count % 1000 === 0 && - lastKeyFolders[file_info.nestingLevel / 2] === file_info.display_name - ? '+' - : ''} item{file_info.count === 1 ? '' : 's'}) - {/if} - </div> + {#if isFolder} + {#if folderState[file_info.full_key]?.loading} + <Loader2 class="shrink-0 text-secondary animate-spin" size={14} /> {:else} - <FileIcon size={16} /> - <div class="truncate text-ellipsis w-56"> - {file_info.display_name} - </div> + <ChevronDown + class={twMerge( + 'shrink-0 text-secondary transition-transform', + file_info.collapsed && '-rotate-90' + )} + size={14} + /> {/if} - </div> - </div> + {:else} + <span class="shrink-0 w-3.5"></span> + {/if} + <RowIcon class="shrink-0" size={14} /> + <span class="truncate text-left"> + <!-- An object-store key may contain an empty segment, so `a//` is a real + folder whose name is ''. Label it rather than rendering a blank row. --> + {#if file_info.display_name === ''} + <span class="italic text-secondary">(empty name)</span> + {:else}{file_info.display_name}{/if} + </span> + <span class="grow"></span> + {#if isFolder && !lazyMode} + <span + class="shrink-0 text-2xs text-tertiary mr-2" + title="Items inside this folder" + > + {file_info.count}{count % 1000 === 0 && + lastKeyFolders[file_info.nestingLevel / 2] === file_info.display_name + ? '+' + : ''} + </span> + {/if} + </button> {/if} </div> {/snippet} </VirtualList> </div> <div - class="flex flex-col gap-2 text-2xs justify-center items-center text-secondary w-full border-t py-1" + class="flex items-center justify-between gap-2 min-h-8 px-3 py-1 border-t text-2xs text-tertiary" > {#if fileListLoading === true} - <div class="flex text-secondary mt-1 text-xs justify-center items-center w-full"> - <Loader2 size={12} class="animate-spin mr-1" /> Loading content + <div class="flex items-center gap-1.5"> + <Loader2 size={12} class="animate-spin" /> Loading... </div> {:else if lazyMode} <!-- Per-level listing: totals below the tree would be a count of what happens to be expanded, and each folder carries its own Load more row. --> <div>{displayedCount} item{displayedCount === 1 ? '' : 's'} shown</div> {:else} - <div> + <div class="min-w-0"> {displayedCount}{flatHasMore ? '+' : ''} {displayedCount !== count ? 'filtered ' : ''}items (including inside folders) </div> {#if flatHasMore} - <Button variant="default" size="xs2" on:click={() => loadNextFlatPage()}> + <Button variant="subtle" unifiedSize="xs" onClick={() => loadNextFlatPage()}> Load more </Button> {/if} {/if} </div> {/if} - </div> + </Pane> {/if} - <div class="flex flex-col h-full w-full min-h-0 overflow-hidden"> - {#if fileMetadata === undefined} - <div class="p-4"> - {#if fileInfoLoading} - <Section label="Loading..." /> - {:else if fileListUnavailable} - <Section label="No file to preview" /> - {:else} - <Section label="Select a file to preview" /> - {/if} - </div> - {:else} - <div class="px-3 py-2 flex flex-col gap-2"> - <div class="flex flex-row items-center justify-between gap-2"> - <h2 class="text-emphasis text-sm font-semibold break-all min-w-0"> - {((p) => (p.startsWith(rootPath) ? p.slice(rootPath.length) : p))( - fileMetadata.fileKey - )} - </h2> - {#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)} - <div class="flex gap-2 shrink-0"> - {#if !hideS3SpecificDetails} - {@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}${s3ResourcePath ? `&s3_resource_path=${encodeURIComponent(s3ResourcePath)}` : ''}`} - {@const downloadName = - fileMetadata?.fileKey.split('/').pop() ?? 'unnamed_download.file'} - {#if shouldDownloadViaClient()} - <Button - title="Download file from S3" - variant="default" - on:click={() => downloadViaClient(downloadApiPath, downloadName)} - startIcon={{ icon: Download }} - iconOnly={true} - /> - {:else} - <Button - title="Download file from S3" - variant="default" - href={`${base}/api${downloadApiPath}`} - download={downloadName} - startIcon={{ icon: Download }} - iconOnly={true} - /> - {/if} - {/if} - {#if !readOnlyMode} - <Button - title="Move file" - variant="default" - on:click={() => { - moveDestKey = fileMetadata?.fileKey ?? '' - moveModalOpen = true - }} - startIcon={{ icon: MoveRight }} - iconOnly={true} - /> - {/if} - {#if !readOnlyMode || allowDelete} - <Button - title="Delete file" - variant="default" - on:click={() => { - deletionModalOpen = true - }} - startIcon={{ icon: Trash }} - iconOnly={true} - /> - {/if} + <Pane class="flex flex-col min-h-0 overflow-hidden"> + <div + class="flex items-center gap-3 px-3 py-3 min-h-[3.75rem] shrink-0 border-b bg-surface-secondary" + > + {#if fileMetadata === undefined} + <div class="flex items-center gap-2 text-xs text-tertiary"> + {#if fileInfoLoading} + <Loader2 size={14} class="animate-spin" /> Loading... + {:else if fileListUnavailable} + No file to preview + {:else} + Select a file to preview + {/if} + </div> + {:else} + {@const displayedKey = fileMetadata.fileKey.startsWith(rootPath) + ? fileMetadata.fileKey.slice(rootPath.length) + : fileMetadata.fileKey} + {@const slash = displayedKey.lastIndexOf('/')} + {@const fileName = displayedKey.slice(slash + 1)} + {@const FileKindIcon = fileIconFor(fileName)} + {@const details = [ + fileMetadata.mimeType, + fileMetadata.sizeStr, + fileMetadata.lastModified && `Modified ${fileMetadata.lastModified}` + ].filter(Boolean)} + <div + class="shrink-0 flex items-center justify-center w-9 h-9 rounded-md border bg-surface text-secondary" + > + <FileKindIcon size={18} /> + </div> + <div class="flex flex-col gap-0.5 min-w-0 grow"> + <div class="flex items-center gap-1 min-w-0"> + <span class="truncate text-sm font-semibold text-emphasis" title={displayedKey}> + {#if slash >= 0}<span class="font-normal text-tertiary" + >{displayedKey.slice(0, slash + 1)}</span + >{/if}{fileName} + </span> + <CopyButton value={displayedKey} title="Copy file key" class="shrink-0" /> + </div> + {#if !hideS3SpecificDetails && details.length > 0} + <div class="flex flex-wrap items-center gap-x-1.5 text-xs text-secondary"> + {#each details as detail, i} + {#if i > 0}<span class="text-tertiary">·</span>{/if} + <span>{detail}</span> + {/each} </div> {/if} </div> - {#if !hideS3SpecificDetails} - <TableSimple - headers={['Last modified', 'Size', 'Type']} - data={[fileMetadata]} - keys={['lastModified', 'sizeStr', 'mimeType']} - /> + {#if tableSource} + <ToggleButtonGroup + noWFull + selected={viewMode} + onSelected={(v) => (viewMode = v)} + class="shrink-0" + > + {#snippet children({ item })} + <ToggleButton value="table" label="Table" icon={Table2} small {item} /> + <ToggleButton value="text" label="Text" icon={FileText} small {item} /> + {/snippet} + </ToggleButtonGroup> {/if} - </div> - {/if} + {#if filePreview?.contentType === 'Csv'} + <div class="flex items-center gap-4 shrink-0 text-xs text-secondary"> + <div class="flex items-center gap-2"> + <span>Separator</span> + <Select + size="sm" + class="w-16" + RightIcon={ChevronDown} + items={CSV_SEPARATORS} + bind:value={csvSeparatorChar} + placeholder={CSV_SEPARATORS.find((s) => s.value === detectedCsvSeparator) + ?.label ?? 'Auto'} + /> + </div> + <Toggle size="xs" bind:checked={csvHasHeader} options={{ right: 'Header row' }} /> + </div> + <div class="w-px h-6 border-l shrink-0"></div> + {/if} + {#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)} + <div class="flex gap-1 shrink-0"> + {#if !hideS3SpecificDetails} + {@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata.fileKey)}${storage ? `&storage=${storage}` : ''}${s3ResourcePath ? `&s3_resource_path=${encodeURIComponent(s3ResourcePath)}` : ''}`} + {@const downloadName = fileName || 'unnamed_download.file'} + {#if shouldDownloadViaClient()} + <Button + title="Download" + variant="subtle" + unifiedSize="md" + onClick={() => downloadViaClient(downloadApiPath, downloadName)} + startIcon={{ icon: Download }} + iconOnly + /> + {:else} + <Button + title="Download" + variant="subtle" + unifiedSize="md" + href={`/api${downloadApiPath}`} + download={downloadName} + startIcon={{ icon: Download }} + iconOnly + /> + {/if} + {/if} + {#if !readOnlyMode} + <Button + title="Move" + variant="subtle" + unifiedSize="md" + onClick={() => { + moveDestKey = fileMetadata?.fileKey ?? '' + moveModalOpen = true + }} + startIcon={{ icon: MoveRight }} + iconOnly + /> + {/if} + {#if !readOnlyMode || allowDelete} + <Button + title="Delete" + variant="subtle" + unifiedSize="md" + destructive + onClick={() => { + deletionModalOpen = true + }} + startIcon={{ icon: Trash }} + iconOnly + /> + {/if} + </div> + {/if} + {/if} + </div> <!-- Visual preview extracted to a standalone S3FilePreview component so the asset detail pane (and other surfaces) can render the @@ -1356,17 +1524,40 @@ above for the download/move toolbar; S3FilePreview does an independent load — fine on this non-hot path, and avoids plumbing pre-loaded state through component boundaries. --> - <S3FilePreview - fileKey={fileMetadata?.fileKey} - {storage} - {s3ResourcePath} - workspace={ws} - {loadFilePreviewRequest} - {loadFileMetadataRequest} - class="h-full" - /> - </div> - </div> + {#if showTable} + <div class="flex-1 min-h-0 relative"> + {#if currentTable?.ops} + <!-- Lazy: the (logged) layout mounts this picker and DBTable reaches monaco. --> + {#await import('./DBTable.svelte')} + {@render tableLoading()} + {:then DBTable} + {#key currentTable.ops} + <DBTable.default dbTableOps={currentTable.ops} /> + {/key} + {/await} + {:else if tableOps.error} + <div class="p-3 text-xs text-red-600 dark:text-red-400 break-words"> + Could not read this file as a table: {tableOps.error.message} + </div> + {:else} + {@render tableLoading()} + {/if} + </div> + {:else} + <S3FilePreview + fileKey={fileMetadata?.fileKey} + {storage} + {s3ResourcePath} + workspace={ws} + {loadFilePreviewRequest} + {loadFileMetadataRequest} + class="flex-1 min-h-0" + csvOptions={{ separator: csvSeparatorChar, hasHeader: csvHasHeader }} + bodyClass="bg-transparent px-3" + /> + {/if} + </Pane> + </Splitpanes> {/if} <ConfirmationModal diff --git a/frontend/src/lib/components/S3FilePreview.svelte b/frontend/src/lib/components/S3FilePreview.svelte index 411592b936..4635d4f6c7 100644 --- a/frontend/src/lib/components/S3FilePreview.svelte +++ b/frontend/src/lib/components/S3FilePreview.svelte @@ -5,8 +5,10 @@ // renderings without dragging in the whole picker UI. // // The component owns its own metadata + preview loads — callers only - // need to hand it a `fileKey`. CSV separator/header are local state so - // the user can re-preview the same file with different parsing flags. + // need to hand it a `fileKey`. CSV separator/header are local state (unless + // the caller passes `csvOptions`) so the user can re-preview the same file + // with different parsing flags. + import { untrack } from 'svelte' import { FileX2, Loader2 } from 'lucide-svelte' import { HelpersService, @@ -46,6 +48,11 @@ // metadata out themselves (the picker already has its own). showMetadata?: boolean class?: string + /** CSV parsing options owned by the caller, which then renders its own controls: + * the preview hides its own and reloads when these change. */ + csvOptions?: { separator: string | undefined; hasHeader: boolean } + /** Merged onto the preview body, below the metadata strip. */ + bodyClass?: string // Bump this to force a re-fetch (metadata + preview). Used by the // asset detail pane after an upstream producer run completes — // without it, the "Asset not yet materialized" empty state stays @@ -62,6 +69,8 @@ loadFileMetadataRequest = HelpersService.loadFileMetadata, showMetadata = false, class: className = '', + bodyClass = '', + csvOptions = undefined, refreshKey }: Props = $props() @@ -179,8 +188,8 @@ fileKey: key, fileSizeInBytes: size, fileMimeType: mimeType, - csvSeparator: csvSeparatorChar, - csvHasHeader: csvHasHeader, + csvSeparator: csvOptions ? csvOptions.separator : csvSeparatorChar, + csvHasHeader: csvOptions?.hasHeader ?? csvHasHeader, readBytesFrom: 0, readBytesLength: 128 * 1024, storage, @@ -221,6 +230,16 @@ void reloadPreview(fileMetadata.fileKey, fileMetadata.size, fileMetadata.mimeType) } } + + // Skips the first run: the file load already fetches the preview with these options. + let lastCsvOptionsKey: string | undefined + $effect(() => { + const key = csvOptions ? `${csvOptions.separator}|${csvOptions.hasHeader}` : undefined + if (lastCsvOptionsKey !== undefined && key !== lastCsvOptionsKey) { + untrack(onCsvControlsChanged) + } + lastCsvOptionsKey = key + }) </script> <div class={twMerge('flex flex-col h-full w-full overflow-auto text-xs', className)}> @@ -231,7 +250,7 @@ {#if fileMetadata.lastModified}<span>{fileMetadata.lastModified}</span>{/if} </div> {/if} - <div class="flex-1 min-h-0 overflow-auto p-4 bg-surface-secondary"> + <div class={twMerge('flex-1 min-h-0 overflow-auto p-4 bg-surface-secondary', bodyClass)}> {#if !fileKey} <div class="text-tertiary text-xs">No file selected.</div> {:else if fileInfoLoading && !fileMetadata && !notFound && !loadError} @@ -291,32 +310,36 @@ <Loader2 size={12} class="animate-spin mr-1" /> File preview loading </div> {:else if fileMetadata !== undefined && filePreview !== undefined} - <div class="flex items-center text-primary mb-4"> - {#if filePreview.contentType === 'Unknown'} + {#if filePreview.contentType === 'Unknown'} + <div class="flex items-center text-primary mb-4"> Type of file not supported for preview. - {:else if filePreview.contentType === 'Csv'} - Previewing a {filePreview.contentType?.toLowerCase()} file. Separator character: - <div class="inline-flex w-12 ml-2 mr-2"> - <select class="h-8" bind:value={csvSeparatorChar} onchange={onCsvControlsChanged}> - <option value=",">,</option> - <option value=";">;</option> - <option value="\t">\t</option> - <option value="|">|</option> - </select> - </div> - Header row: - <div class="inline-flex item-center w-4 ml-2 mr-2"> - <input - type="checkbox" - class="h-5" - bind:checked={csvHasHeader} - onchange={onCsvControlsChanged} - /> - </div> - {:else} - Previewing a {filePreview.contentType?.toLowerCase()} file. - {/if} - </div> + </div> + {:else if !csvOptions} + <div class="flex items-center text-primary mb-4"> + {#if filePreview.contentType === 'Csv'} + Previewing a {filePreview.contentType?.toLowerCase()} file. Separator character: + <div class="inline-flex w-12 ml-2 mr-2"> + <select class="h-8" bind:value={csvSeparatorChar} onchange={onCsvControlsChanged}> + <option value=",">,</option> + <option value=";">;</option> + <option value="\t">\t</option> + <option value="|">|</option> + </select> + </div> + Header row: + <div class="inline-flex item-center w-4 ml-2 mr-2"> + <input + type="checkbox" + class="h-5" + bind:checked={csvHasHeader} + onchange={onCsvControlsChanged} + /> + </div> + {:else} + Previewing a {filePreview.contentType?.toLowerCase()} file. + {/if} + </div> + {/if} <pre class="grow whitespace-no-wrap break-words" >{#if !emptyString(filePreview.contentPreview)}{filePreview.contentPreview}{:else if filePreview.contentType !== undefined}Preview impossible.{/if}</pre > diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index d05c3cce72..d761101d76 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -300,7 +300,14 @@ <!-- {JSON.stringify(schema)} --> <div class="w-full {className} {flexWrap ? 'flex flex-row flex-wrap gap-x-6 ' : ''} {nestedClasses}" - use:dragHandleZone={dndConfig ?? { items: [], dragDisabled: true }} + use:dragHandleZone={dndConfig ?? { + items: [], + dragDisabled: true, + // A drag-disabled zone otherwise sets aria-disabled="true" on itself, which screen readers + // and Playwright apply to every field inside it. + autoAriaDisabled: true, + zoneTabIndex: -1 + }} onfinalize={bubble('finalize')} onconsider={bubble('consider')} > diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 62f9851fcc..b11fd34362 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -72,7 +72,6 @@ Tag, X } from 'lucide-svelte' - import { base } from '$lib/base' import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' import { parsePipelineAnnotations } from './assets/AssetGraph/parsePipelineAnnotations' import DropdownV2 from './DropdownV2.svelte' @@ -93,8 +92,11 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { getContext, onDestroy, onMount, setContext, tick, untrack } from 'svelte' + import { getContext, onDestroy, onMount, setContext, tick, untrack, type Snippet } from 'svelte' import EditorHeader from './EditorHeader.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import ScriptSettingsBadges from './ScriptSettingsBadges.svelte' import Badge from './common/badge/Badge.svelte' import Modal from './common/modal/Modal.svelte' @@ -125,8 +127,9 @@ import WacExportDrawer from './scripts/WacExportDrawer.svelte' import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' const triggerLock = useTriggerLock() + const editRights = useEditRights() let { script = $bindable(), @@ -162,12 +165,14 @@ loadedFromDraft = false, othersDraftsCount = 0, onOpenOthersDrafts, - condensedHeader = false + condensedHeader = false, + ownsPageHeader = false }: ScriptBuilderProps = $props() // Top-bar button size + bar height. Condensed (session preview) uses the // smallest well-supported unified size (`sm`) so the bar is thinner. - const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') + // In the page header the buttons ride a 44px band, which takes the same size. + const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md') export function getInitialAndModifiedValues(): SavedAndModifiedValue { return { @@ -196,8 +201,19 @@ // right group (hide the ~200px tag select, icon-only Diff/Settings) before the // full group crowds the path into heavy truncation; ~900 is where the path // keeps a usable width given the right group's natural ~440px. + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state<string | undefined>(undefined) + let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 900) + // In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has + // given all it can; anywhere else this editor's own top bar is theirs alone. + const compactTopbar = $derived( + ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900 + ) + // The second fold holds the trail and one button. Diff and Settings join the menu that compact + // already shows, and Deploy stays out: it is what this page is for. + const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) // The workspace this editor operates on: deploy, save-draft, trigger loading // and the AutosaveIndicator all target it. Falls back to the full-page @@ -220,7 +236,21 @@ function getCompactMenuItems(): Item[] { const hasTags = (scriptWorkerTags?.length ?? 0) > 0 + const isDraftOnly = savedScript?.no_deployed === true return [ + ...(phoneTopbar && customUi?.topBar?.diff != false + ? [ + { + displayName: 'Diff', + icon: DiffIcon, + disabled: !savedScript || !diffDrawer || isDraftOnly, + action: () => openDiffDrawer() + } + ] + : []), + ...(phoneTopbar && customUi?.topBar?.settings != false + ? [{ displayName: 'Settings', icon: Settings, action: () => (metadataOpen = true) }] + : []), ...(customUi?.topBar?.tagEdit != false && hasTags ? [ { @@ -561,6 +591,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' | 'wac_python' | 'wac_typescript' | 'ci_test_bun' @@ -1349,7 +1380,7 @@ onCanceled={() => (perpetualRunsToConfirm = undefined)} /> -{#if !actingUser?.operator} +{#if editRights.roleCanAuthor('script')} <Drawer placement="right" bind:open={metadataOpen} @@ -1508,10 +1539,12 @@ (lang == script.language && template != 'bunnative' && template != 'docker' && - template != 'claudesandbox') || + template != 'claudesandbox' && + template != 'pisandbox') || (template == 'bunnative' && lang == 'bunnative') || (template == 'docker' && lang == 'docker') || - (template == 'claudesandbox' && lang == 'bun')} + ((template == 'claudesandbox' || template == 'pisandbox') && + lang == 'bun')} <Popover disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense} > @@ -1562,6 +1595,21 @@ > Claude Sandbox </Button> + <Button + unifiedSize="xs" + variant="default" + startIcon={{ + icon: LanguageIcon, + props: { lang: 'pisandbox', width: 16, height: 16 } + } as ButtonType.Icon} + onclick={() => { + template = 'pisandbox' + script.language = 'bun' + initContent('bun', script.kind, template) + }} + > + Pi Sandbox + </Button> <span title="Workflow-as-Code"> <Button size="xs2" @@ -2249,164 +2297,300 @@ </Drawer> <div class="flex flex-col h-full"> - <div - bind:clientWidth={topbarWidth} - class="flex items-center px-4 {condensedHeader ? 'h-9' : 'h-12'}" - > - <div class="flex gap-2 lg:gap-2 w-full items-center"> - <div class="flex flex-row items-center gap-2 min-w-0 shrink"> - <button - disabled={customUi?.topBar?.settings == false} - class="shrink-0" - onclick={async () => { - metadataOpen = true - }} - > - <LanguageIcon lang={script.language} size={condensedHeader ? 18 : 24} /> - </button> - {#if customUi?.topBar?.path != false} - <div class="min-w-0 overflow-hidden"> - <EditorHeader - bind:summary={script.summary} - bind:path={script.path} - savedPath={initialPath} - kind="script" - summaryEditable={customUi?.topBar?.editableSummary != false} - pathEditable={customUi?.topBar?.editablePath != false} - hidePath={condensedHeader} - workspaceId={autosaveWorkspace} - onNavigate={(item) => onNavigate?.(item)} - /> - </div> + {#if ownsPageHeader} + <!-- The editor's own top bar is the page header on this route: the script's path and + summary are the breadcrumb's, and its buttons are the header's actions. --> + <!-- `separator="none"`: the toolbar right below the bar draws its own `border-b`, and the + bar's edge would be a second line on top of it. --> + <PageHeaderContent + item={{ + // Frozen while the rename popover is open so the trail holds still as the user types. + kind: 'script', + // `script?`: this object is built inside a getter the band evaluates from its own + // tree, and `script` is a bindable prop. Deploying a new script clears the draft the + // route binds here and drops the `{#if}` around this editor in one flush, which + // invalidates the band before this component is torn down. + path: pathSnapshot ?? script?.path, + summary: script?.summary, + summaryContent: scriptSummary, + // Gated like the summary's own editor: a host that allows neither field keeps the + // band's segment, which copies the path. + pathTrigger: + customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false + ? scriptPathTrigger + : undefined + }} + afterName={scriptMarks} + actions={scriptHeaderActions} + actionFolds={2} + separator="none" + /> + {:else} + <div + bind:clientWidth={topbarWidth} + class="flex items-center px-4 {condensedHeader ? 'h-9' : 'h-12'}" + > + <div class="flex gap-2 lg:gap-2 w-full items-center"> + <div class="flex flex-row items-center gap-2 min-w-0 shrink"> + {@render languageButton(condensedHeader ? 18 : 24)} + {#if customUi?.topBar?.path != false} + <div class="min-w-0 overflow-hidden"> + <EditorHeader + bind:summary={script.summary} + bind:path={script.path} + savedPath={initialPath} + kind="script" + summaryEditable={customUi?.topBar?.editableSummary != false} + pathEditable={customUi?.topBar?.editablePath != false} + hidePath={condensedHeader} + workspaceId={autosaveWorkspace} + onNavigate={(item) => onNavigate?.(item)} + /> + </div> + {/if} + {@render autosaveIndicator()} + {#if !condensedHeader} + {@render settingsBadges()} + {/if} + </div> + + <!-- Separator --> + <div class="flex-1"></div> + + {#if $enterpriseLicense && initialPath != '' && !inSessionPane} + <Awareness /> {/if} - {#if opWorkspace} - <AutosaveIndicator - workspace={opWorkspace} - itemKind="script" - path={indicatorPath} - draftOnly={savedScript?.no_deployed === true} - {onResetToDeployed} - {loadedFromDraft} - {othersDraftsCount} - {onOpenOthersDrafts} - /> - {/if} - {#if !condensedHeader} - {@const canOpenRuntime = - customUi?.topBar?.settings != false && - customUi?.settingsPanel?.disableRuntime !== true} - <ScriptSettingsBadges - settings={script} - onclick={canOpenRuntime - ? () => { - selectedTab = 'runtime' - metadataOpen = true - } - : undefined} + + <!-- Separator --> + <div class="flex-1"></div> + + {@render rightButtons()} + </div> + </div> + {/if} + + {#snippet scriptSummary()} + <!-- Everything the band pulls from this editor is gated on `script`. It is a bindable prop, + and these snippets render from the band's tree, where nothing orders the route clearing + that prop against this component's teardown: deploying a new script discards the draft + bound here and drops the `{#if}` around this editor in the same flush. --> + {#if script} + <!-- Not edited in place: clicking the name opens the summary and the path together, so + the band reads as a name rather than a form. `title` for one it truncates. --> + <div class="flex items-center gap-1 min-w-0"> + {#snippet summaryText()} + <span + class="min-w-0 truncate text-xs {emptyString(script.summary) + ? 'text-tertiary italic font-normal' + : 'font-medium text-emphasis'}" + title={script.summary} + >{emptyString(script.summary) ? 'Add a summary...' : script.summary}</span + > + {/snippet} + {#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false} + <PathEditPopover + label={summaryText} + bind:summary={script.summary} + summaryEditable={customUi?.topBar?.editableSummary != false} + pathEditable={customUi?.topBar?.editablePath != false} + bind:path={script.path} + bind:snapshotPath={pathSnapshot} + savedPath={initialPath} + kind="script" + workspaceId={autosaveWorkspace} /> + {:else} + {@render summaryText()} {/if} </div> + {/if} + {/snippet} - <!-- Separator --> - <div class="flex-1"></div> + <!-- The same editor as the summary's, hung off the band's path segment: anchored there it + opens under the path, and `focusField` puts the cursor in the field that half of the + name belongs to. The bindings are written out again because `bind:` cannot be spread; + both instances bind the same slots, and only one is ever open. --> + {#snippet scriptPathTrigger(pathLabel: Snippet, triggerClass: string)} + <!-- Gated like the summary beside it: `script` is a bindable prop the route can clear in + the same flush that drops this editor, and this renders from the band's tree. The + `{:else}` is not decoration: the band hands the segment over the moment a page offers + a `pathTrigger`, so a gate that drew nothing would take the path off the trail. --> + {#if script} + <PathEditPopover + label={pathLabel} + {triggerClass} + focusField="path" + bind:summary={script.summary} + summaryEditable={customUi?.topBar?.editableSummary != false} + pathEditable={customUi?.topBar?.editablePath != false} + bind:path={script.path} + bind:snapshotPath={pathSnapshot} + savedPath={initialPath} + kind="script" + workspaceId={autosaveWorkspace} + /> + {:else} + <span class={triggerClass}>{@render pathLabel()}</span> + {/if} + {/snippet} + {#snippet scriptMarks()} + {#if script} + <!-- Concurrency, cache, a dedicated worker: settings that hold whatever the editor shows, + so they belong with the script's name. The language icon the editor's own bar carried + does not come along — the code under it says which language this is. --> + {@render settingsBadges('pl-1.5')} + {/if} + {/snippet} + + {#snippet scriptHeaderActions()} + {#if script} + <!-- Saving state and other people's drafts: the editor's own bar carries them beside the + path, so the header has to carry them when it owns the bar — without them a + full-page editor saves with no word either way. --> + {@render autosaveIndicator()} {#if $enterpriseLicense && initialPath != '' && !inSessionPane} <Awareness /> {/if} + {@render rightButtons()} + {/if} + {/snippet} - <!-- Separator --> - <div class="flex-1"></div> + {#snippet languageButton(size: number)} + <button + disabled={customUi?.topBar?.settings == false} + class="shrink-0" + title="Settings" + onclick={async () => { + metadataOpen = true + }} + > + <LanguageIcon lang={script.language} {size} /> + </button> + {/snippet} - {#snippet settingsButton()} - {#if customUi?.topBar?.settings != false} - <Button - aiId="script-builder-settings" - aiDescription="Script builder settings to configure metadata, runtime, triggers, and generated UI." - variant="default" - unifiedSize={headerBtnSize} - on:click={() => (metadataOpen = true)} - startIcon={{ icon: Settings }} - iconOnly={compactTopbar} - title="Settings" - > - <span> Settings </span> - </Button> - {/if} - {/snippet} - {#snippet diffButton()} - {#if customUi?.topBar?.diff != false} - {@const isDraftOnly = savedScript?.no_deployed === true} - {@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly} - {@const diffTitle = isDraftOnly - ? 'Deploy this script once to compare against the deployed version' - : 'Diff'} - <!-- A disabled <button> fires no pointer events, so a title/tooltip on it + {#snippet autosaveIndicator()} + {#if opWorkspace} + <AutosaveIndicator + workspace={opWorkspace} + itemKind="script" + path={indicatorPath} + draftOnly={savedScript?.no_deployed === true} + {onResetToDeployed} + {loadedFromDraft} + {othersDraftsCount} + {onOpenOthersDrafts} + /> + {/if} + {/snippet} + + {#snippet settingsBadges(className?: string)} + {@const canOpenRuntime = + customUi?.topBar?.settings != false && customUi?.settingsPanel?.disableRuntime !== true} + <ScriptSettingsBadges + class={className} + settings={script} + onclick={canOpenRuntime + ? () => { + selectedTab = 'runtime' + metadataOpen = true + } + : undefined} + /> + {/snippet} + + {#snippet rightButtons()} + {#snippet settingsButton()} + {#if customUi?.topBar?.settings != false} + <Button + aiId="script-builder-settings" + aiDescription="Script builder settings to configure metadata, runtime, triggers, and generated UI." + variant="default" + unifiedSize={headerBtnSize} + on:click={() => (metadataOpen = true)} + startIcon={{ icon: Settings }} + iconOnly={compactTopbar} + title="Settings" + > + <span> Settings </span> + </Button> + {/if} + {/snippet} + {#snippet diffButton()} + {#if customUi?.topBar?.diff != false} + {@const isDraftOnly = savedScript?.no_deployed === true} + {@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly} + {@const diffTitle = isDraftOnly + ? 'Deploy this script once to compare against the deployed version' + : 'Diff'} + <!-- A disabled <button> fires no pointer events, so a title/tooltip on it never shows on hover. pointer-events-none on the button lets the hover reach this titled wrapper instead. --> - <div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}> - <Button - variant="default" - unifiedSize={headerBtnSize} - on:click={() => openDiffDrawer()} - disabled={diffDisabled} - btnClasses={diffDisabled ? 'pointer-events-none' : undefined} - iconOnly={compactTopbar} - title={diffTitle} - startIcon={{ icon: DiffIcon }} - > - Diff - </Button> - </div> - {/if} - {/snippet} - {#if compactTopbar} - <DropdownV2 items={getCompactMenuItems} placement="bottom-end"> - {#snippet buttonReplacement()} - <Button - nonCaptureEvent - unifiedSize={headerBtnSize} - variant="subtle" - startIcon={{ icon: EllipsisVertical }} - iconOnly - title="More" - /> - {/snippet} - </DropdownV2> + <div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}> + <Button + variant="default" + unifiedSize={headerBtnSize} + on:click={() => openDiffDrawer()} + disabled={diffDisabled} + btnClasses={diffDisabled ? 'pointer-events-none' : undefined} + iconOnly={compactTopbar} + title={diffTitle} + startIcon={{ icon: DiffIcon }} + > + Diff + </Button> + </div> + {/if} + {/snippet} + {#if compactTopbar} + <DropdownV2 items={getCompactMenuItems} placement="bottom-end"> + {#snippet buttonReplacement()} + <Button + nonCaptureEvent + unifiedSize={headerBtnSize} + variant="subtle" + startIcon={{ icon: EllipsisVertical }} + iconOnly + title="More" + /> + {/snippet} + </DropdownV2> + {#if !phoneTopbar} {@render diffButton()} {@render settingsButton()} - {:else} - {@render diffButton()} - {#if customUi?.topBar?.tagEdit != false} - {#if scriptWorkerTags} - {#if scriptWorkerTags?.length ?? 0 > 0} - <div class="max-w-[200px]"> - <WorkerTagSelect - nullTag={script.language} - placeholder={customUi?.tagSelectPlaceholder} - bind:tag={script.tag} - size={headerBtnSize} - workspaceId={opWorkspace} - /> - </div> - {/if} - {/if} - {/if} - {@render settingsButton()} {/if} + {:else} + {@render diffButton()} + {#if customUi?.topBar?.tagEdit != false} + {#if scriptWorkerTags} + {#if scriptWorkerTags?.length ?? 0 > 0} + <div class="max-w-[200px]"> + <WorkerTagSelect + nullTag={script.language} + placeholder={customUi?.tagSelectPlaceholder} + bind:tag={script.tag} + size={headerBtnSize} + workspaceId={opWorkspace} + /> + </div> + {/if} + {/if} + {/if} + {@render settingsButton()} + {/if} - <DeployButton - loading={!fullyLoaded} - {loadingSave} - unifiedSize={headerBtnSize} - dropdownItems={computeDropdownItems(initialPath, savedScript)} - on:save={({ detail }) => handleEditScript(false, detail)} - /> - </div> - </div> + <DeployButton + loading={!fullyLoaded} + {loadingSave} + unifiedSize={headerBtnSize} + dropdownItems={computeDropdownItems(initialPath, savedScript)} + on:save={({ detail }) => handleEditScript(false, detail)} + /> + {/snippet} {#if showPipelineHint} <div - class="flex items-center gap-2 px-4 py-1 border-y bg-surface-secondary text-xs text-secondary" + class="flex items-center gap-2 px-4 py-1 border-b bg-surface-secondary text-xs text-secondary" > <Network size={14} class="shrink-0 text-tertiary" /> <span class="truncate"> @@ -2417,7 +2601,7 @@ -- materialize </code> to materialize its result, or build it in the - <a href="{base}/pipeline" class="text-blue-500 hover:underline">pipeline editor</a>. + <a href="/pipeline" class="text-blue-500 hover:underline">pipeline editor</a>. <a href="https://www.windmill.dev/docs/pipelines" target="_blank" diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 1cabcf8265..ef3df73f63 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -87,7 +87,6 @@ import CaptureButton from './triggers/CaptureButton.svelte' import { setContext } from 'svelte' import HideButton from './apps/editor/settingsPanel/HideButton.svelte' - import { base } from '$lib/base' import DispatchEventsButton from '$lib/components/runs/DispatchEventsButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/script/core' import { getStringError } from './copilot/chat/utils' @@ -150,6 +149,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' | 'wac_python' | 'wac_typescript' | 'ci_test_bun' @@ -2100,43 +2100,15 @@ </div> {/if} {#if customUi?.previewPanel?.hideArgs} - <!-- Compact preview layout used by the pipeline editor: - no args column (the script is known to take no - inputs), LogPanel takes the full width, with a - small Test/Cancel button at the top-left of the - preview band. The earlier `-translate-y-1/2` - "float onto the editor" version was clipped in - Firefox (transform + overflow: visible interaction - on the parent splitpane Pane), so the button is - now positioned inside the panel — `top-1` keeps - it visually pinned to the top edge without - relying on cross-browser overflow behaviour. --> - <div class="relative h-full pt-9 flex flex-col"> - {#if testJob?.id && testJob.type === 'CompletedJob' && opWs} - <!-- Right-side affordances when we're displaying a *completed* - job (either the user just ran a test, or the on-mount - last-run loader populated the panel). The job-id link - opens the full run page in a new tab; the dispatch - button mounts a popover that shows what downstream - jobs this run triggered (rendered only when the run - actually dispatched anything). --> - <div class="absolute top-1 right-2 z-10 flex items-center gap-2"> - <a - class="text-3xs text-blue-600 hover:underline font-mono" - href={`${base}/run/${testJob.id}?workspace=${opWs}`} - target="_blank" - rel="noopener noreferrer" - title="Open this run" - > - {testJob.id.slice(0, 8)}… ↗ - </a> - <DispatchEventsButton - workspace={testJob.workspace_id ?? opWs} - jobId={testJob.id} - /> - </div> - {/if} - <div class="absolute top-1 left-2 z-10"> + <!-- Compact preview layout used by the pipeline editor: no args + column (most pipeline scripts take no inputs) and a full-width + LogPanel. The controls sit in normal flow rather than floating + onto the editor band, which Firefox clipped (transform + + overflow: visible on the parent splitpane Pane). --> + <div class="h-full flex flex-col"> + <!-- Test sits at the top right, the last run's link under it. --> + <div class="shrink-0 flex flex-col items-end gap-1 px-2 pt-2 pb-1"> + <div> {#if testIsLoading} {@render cancelTestButton('sm', 'shadow-md')} {:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun} @@ -2270,6 +2242,31 @@ {@render runTestButton('sm', 'shadow-md')} {/if} </div> + {#if testJob?.id && testJob.type === 'CompletedJob' && opWs} + <!-- Right-side affordances when we're displaying a *completed* + job (either the user just ran a test, or the on-mount + last-run loader populated the panel). The job-id link + opens the full run page in a new tab; the dispatch + button mounts a popover that shows what downstream + jobs this run triggered (rendered only when the run + actually dispatched anything). --> + <div class="flex items-center gap-2"> + <a + class="text-3xs text-blue-600 hover:underline font-mono" + href={`/run/${testJob.id}?workspace=${opWs}`} + target="_blank" + rel="noopener noreferrer" + title="Open this run" + > + {testJob.id.slice(0, 8)}… ↗ + </a> + <DispatchEventsButton + workspace={testJob.workspace_id ?? opWs} + jobId={testJob.id} + /> + </div> + {/if} + </div> {#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0} <div class="shrink-0 overflow-auto max-h-[45%] border-b border-gray-200 dark:border-gray-700 px-4 py-3" @@ -2743,7 +2740,7 @@ {#snippet popoverOverride()} <div class="text-sm"> Enable Windmill AI in the <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" > diff --git a/frontend/src/lib/components/ScriptPicker.svelte b/frontend/src/lib/components/ScriptPicker.svelte index 781cb5155e..97ac81713d 100644 --- a/frontend/src/lib/components/ScriptPicker.svelte +++ b/frontend/src/lib/components/ScriptPicker.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import { ScriptService, FlowService, type Script, AppService } from '$lib/gen' - import { base } from '$lib/base' import { createEventDispatcher, untrack } from 'svelte' import Select from './select/Select.svelte' @@ -198,7 +197,7 @@ target="_blank" variant="default" size="xs" - href="{base}/flows/edit/{scriptPath}{wsParam}">Edit</Button + href="/flows/edit/{scriptPath}{wsParam}">Edit</Button > {/if} {#if allowView} @@ -221,7 +220,7 @@ target="_blank" variant="default" size="xs" - href="{base}/apps/edit/{scriptPath}{wsParam}" + href="/apps/edit/{scriptPath}{wsParam}" > Edit </Button> @@ -232,7 +231,7 @@ size="xs" target="_blank" startIcon={{ icon: Code }} - href="{base}/apps/get/{scriptPath}{wsParam}" + href="/apps/get/{scriptPath}{wsParam}" > View </Button> @@ -246,7 +245,7 @@ target="_blank" variant="default" size="xs" - href="{base}/scripts/edit/{scriptPath}{wsParam}" + href="/scripts/edit/{scriptPath}{wsParam}" > Edit </Button> diff --git a/frontend/src/lib/components/ScriptSettingsBadges.svelte b/frontend/src/lib/components/ScriptSettingsBadges.svelte index 8d73609891..7d09bce459 100644 --- a/frontend/src/lib/components/ScriptSettingsBadges.svelte +++ b/frontend/src/lib/components/ScriptSettingsBadges.svelte @@ -1,4 +1,5 @@ <script lang="ts"> + import { twMerge } from 'tailwind-merge' import Badge from '$lib/components/common/badge/Badge.svelte' import Popover from '$lib/components/Popover.svelte' import { @@ -12,15 +13,18 @@ // (e.g. to open the settings drawer focused on that section). onclick?: (key: string) => void small?: boolean + /** On the row that holds the badges — so a script with none of these settings leaves no + * gap where they would have been. */ + class?: string } - let { settings, onclick, small = true }: Props = $props() + let { settings, onclick, small = true, class: className = '' }: Props = $props() let badges = $derived(getActiveScriptSettingsBadges(settings)) </script> {#if badges.length > 0} - <div class="flex flex-row flex-wrap gap-1 items-center"> + <div class={twMerge('flex flex-row flex-wrap gap-1 items-center', className)}> {#each badges as badge (badge.key)} <!-- Icon-only chips to save space in crowded top bars; the label and current value are shown in the hover popover. --> diff --git a/frontend/src/lib/components/ServiceLogsInner.svelte b/frontend/src/lib/components/ServiceLogsInner.svelte index 85c6cf7fb9..03cd5bf676 100644 --- a/frontend/src/lib/components/ServiceLogsInner.svelte +++ b/frontend/src/lib/components/ServiceLogsInner.svelte @@ -10,8 +10,10 @@ import Toggle from './Toggle.svelte' import TextInput from './text_input/TextInput.svelte' import Popover from './Popover.svelte' + // The clickable one, for the folded switches; the import above is the hover tooltip. + import MeltPopover from './meltComponents/Popover.svelte' import { sendUserToast } from '$lib/toast' - import { onDestroy, tick, type Snippet } from 'svelte' + import { onDestroy, tick } from 'svelte' import { fade } from 'svelte/transition' import { AlertTriangle, @@ -22,7 +24,8 @@ Loader2, ScrollText, Search, - SearchX + SearchX, + SlidersHorizontal } from 'lucide-svelte' import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils' import LogSnippetViewer from './LogSnippetViewer.svelte' @@ -33,15 +36,24 @@ import { goto } from '$lib/navigation' import { page } from '$app/state' import { watch } from 'runed' + import Tooltip from './Tooltip.svelte' + import PageHeaderContent from './PageHeaderContent.svelte' + import { pageHeader } from './pageHeaderRegistry.svelte' interface Props { searchTerm: string tagLabel?: string - /** Rendered at the start of the toolbar row, so the page title shares it. */ - title?: Snippet } - let { searchTerm = $bindable(), tagLabel, title }: Props = $props() + let { searchTerm = $bindable(), tagLabel }: Props = $props() + + /** Bar width below which the two switches fold into a popover. Measured: the breadcrumb takes + * ~360, the search field asks for 384 and the timeframe 223, so under this the field is what + * would start giving width back — and it is the control the page exists for. */ + const COMPACT_SWITCHES_BAR = 1200 + const compactSwitches = $derived( + pageHeader.barWidth > 0 && pageHeader.barWidth < COMPACT_SWITCHES_BAR + ) let queryParseErrors: string[] | undefined = $state(undefined) @@ -692,88 +704,135 @@ </DrawerContent> </Drawer> -<div class="flex flex-col grow min-h-0 min-w-0 gap-2 pb-2"> - <div class="flex flex-wrap items-center gap-x-4 gap-y-2 pt-4 pb-2"> - {@render title?.()} - <div class="relative grow min-w-64"> - <Search - size={16} - class="absolute left-2 top-1/2 -translate-y-1/2 text-hint pointer-events-none z-10" - /> - <TextInput - bind:value={searchTerm} - size="md" - class="pl-8 pr-8" - inputProps={{ - id: 'quickSearchInput', - placeholder: 'Search service logs', - autocomplete: 'off', - autofocus: true - }} - /> - {#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0} - <div class="absolute right-2 top-1/2 -translate-y-1/2 z-10 flex"> - <Popover notClickable placement="bottom-end"> - <AlertTriangle size={16} class="text-yellow-500" /> - {#snippet text()} - <div class="flex flex-col gap-1 text-xs"> - <span>Some search terms were ignored because they could not be parsed:</span> - <ul class="list-disc pl-4"> - {#each queryParseErrors ?? [] as msg} - <li>{msg}</li> - {/each} - </ul> - </div> - {/snippet} - </Popover> - </div> - {/if} - </div> - <TimeframeSelect - items={serviceLogsTimeframes} - bind:value={timeframe} - {loading} - wrapperClasses="w-64" - onClick={() => { - minTs = undefined - maxTs = undefined - allLogs = undefined - const ts = timeframe.computeMinMax() - getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined) +{#snippet serviceLogsHint()} + <Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/service_logs"> + Logs written by the servers, workers and indexers of this instance, grouped by host. + </Tooltip> +{/snippet} + +{#snippet serviceLogsActions()} + <!-- The widest control here, because searching is what the page is for. It is also the one that + gives width back when the bar is tight — that is what `actionsFlexible` on the registration + asks the bar to allow — down to a floor that still fits a query worth reading. --> + <div class="relative w-96 min-w-40 shrink"> + <Search + size={16} + class="absolute left-2 top-1/2 -translate-y-1/2 text-hint pointer-events-none z-10" + /> + <TextInput + bind:value={searchTerm} + size="sm" + class="pl-8 pr-8" + inputProps={{ + id: 'quickSearchInput', + placeholder: 'Search service logs', + autocomplete: 'off', + autofocus: true }} /> - <div class="flex items-center gap-4"> - <Toggle - size="sm" - bind:checked={withError} - options={{ - right: 'With errors only', - rightTooltip: 'Only list the log files that contain at least one error line' - }} - on:change={() => { - allLogs = undefined - getAllLogs(minTs, maxTs) - }} - /> - <Toggle - size="sm" - bind:checked={autoRefresh} - disabled={searchTerm != ''} - on:change={(e) => { - if (e.detail) { - getAllLogs(maxTs, undefined) - } else { - timeout && clearTimeout(timeout) - } - }} - options={{ - right: 'Auto-refresh', - rightTooltip: 'Fetch new log files every 5 seconds. Paused while searching.' - }} - /> - </div> + {#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0} + <div class="absolute right-2 top-1/2 -translate-y-1/2 z-10 flex"> + <Popover notClickable placement="bottom-end"> + <AlertTriangle size={16} class="text-yellow-500" /> + {#snippet text()} + <div class="flex flex-col gap-1 text-xs"> + <span>Some search terms were ignored because they could not be parsed:</span> + <ul class="list-disc pl-4"> + {#each queryParseErrors ?? [] as msg} + <li>{msg}</li> + {/each} + </ul> + </div> + {/snippet} + </Popover> + </div> + {/if} </div> + <!-- No width of its own: the preset labels run to "1000 last service logs", and a fixed box + narrower than that does not clip it — the control's overflow is visible, so it spills into + whatever sits next to it in the bar. --> + <TimeframeSelect + unifiedSize="sm" + items={serviceLogsTimeframes} + bind:value={timeframe} + {loading} + wrapperClasses="shrink-0" + onClick={() => { + minTs = undefined + maxTs = undefined + allLogs = undefined + const ts = timeframe.computeMinMax() + getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined) + }} + /> + <!-- The two labelled switches are the widest thing in this row, and the search field is the one + that pays for them: it is the only control that yields width. So they fold into a popover + well before the bar is tight, which keeps the field at its full width down to a much + narrower window. Folded, not dropped — the page below no longer carries a toolbar they + could fall back to, so hiding them would put them out of reach. --> + {#if compactSwitches} + <MeltPopover placement="bottom-end"> + {#snippet trigger()} + <Button + variant="subtle" + unifiedSize="sm" + iconOnly + startIcon={{ icon: SlidersHorizontal }} + title="Log listing options" + /> + {/snippet} + {#snippet content()} + <div class="flex flex-col gap-3 p-3"> + {@render logSwitches()} + </div> + {/snippet} + </MeltPopover> + {:else} + <div class="flex items-center gap-4 shrink-0"> + {@render logSwitches()} + </div> + {/if} +{/snippet} +{#snippet logSwitches()} + <Toggle + size="sm" + bind:checked={withError} + options={{ + right: 'With errors only', + rightTooltip: 'Only list the log files that contain at least one error line' + }} + on:change={() => { + allLogs = undefined + getAllLogs(minTs, maxTs) + }} + /> + <Toggle + size="sm" + bind:checked={autoRefresh} + disabled={searchTerm != ''} + on:change={(e) => { + if (e.detail) { + getAllLogs(maxTs, undefined) + } else { + timeout && clearTimeout(timeout) + } + }} + options={{ + right: 'Auto-refresh', + rightTooltip: 'Fetch new log files every 5 seconds. Paused while searching.' + }} + /> +{/snippet} + +<!-- The page's name is the breadcrumb's, so the band carries the hint beside it and the toolbar at + its far end; the page below starts straight into the logs. + No `separator`: the default draws the edge only while something is passing under the bar, and + the two panes below start lower than it with borders of their own, so at rest there is nothing + for the band to divide. --> +<PageHeaderContent afterName={serviceLogsHint} actions={serviceLogsActions} actionsFlexible /> + +<div class="flex flex-col grow min-h-0 min-w-0 gap-2 py-2"> {#if searchError} <Alert type="warning" title="Service logs search unavailable" size="xs"> {searchError} diff --git a/frontend/src/lib/components/SummaryPathDisplay.svelte b/frontend/src/lib/components/SummaryPathDisplay.svelte index 22925e98e7..93a0a744a6 100644 --- a/frontend/src/lib/components/SummaryPathDisplay.svelte +++ b/frontend/src/lib/components/SummaryPathDisplay.svelte @@ -1,6 +1,7 @@ <script lang="ts"> + import type { Snippet } from 'svelte' import { emptyString, isOwner } from '$lib/utils' - import { Alert, Button } from '$lib/components/common' + import { Alert, Button, CopyButton } from '$lib/components/common' import Popover from '$lib/components/meltComponents/Popover.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import Path from '$lib/components/Path.svelte' @@ -27,7 +28,26 @@ inheritedLabels?: string[] | undefined editable?: boolean onSaved?: (newPath: string) => void - kind?: 'flow' | 'script' + /** What is being renamed. An agent is a resource, so the path field is scoped to one and + * the write goes through the agent branch of the rename manager. */ + kind?: 'flow' | 'script' | 'agent' + /** Saves instead of the API, for an item that exists only locally (a draft). + * Returns an error message to keep the popover open. */ + saveOverride?: (next: { + path: string + summary: string + labels: string[] | undefined + }) => string | undefined + /** Header variant: no path line (the host shows it) and a lighter summary. */ + compact?: boolean + /** Rendered inside the trigger in place of the summary, for a host that hangs a second copy + * of this editor off something else it draws — the band's path segment. */ + label?: Snippet + /** The trigger's own classes, for a host whose row has a look of its own. */ + triggerClass?: string + /** Which field takes the cursor on open: the click says which half of the name the user came + * to change. `Path` is only reachable for an owner, so 'path' falls back to the summary. */ + focusField?: 'summary' | 'path' } let { @@ -37,41 +57,71 @@ inheritedLabels = undefined, editable = false, onSaved, - kind = 'flow' + kind = 'flow', + saveOverride = undefined, + compact = false, + label, + triggerClass = 'block min-w-0 max-w-full px-1 py-0.5 rounded text-left cursor-pointer hover:bg-surface-hover transition-colors', + focusField = 'summary' }: Props = $props() + // An agent lives at a resource path, so that is what the picker validates against; the + // placeholder still says "agent", which is what the reader is naming. + const pathKind = $derived(kind === 'agent' ? 'resource' : kind) + let editSummary = $state('') let editPath = $state('') let dirtyPath = $state(false) let popoverOpen = $state(false) - let ownPath = $state<string | undefined>(undefined) - // Derived: ownership answers about the operating workspace, whose user resolves asynchronously. + // Resolved before the popover opens, not on opening it: the path field only exists for an owner, + // and `openFocus` looks for it in the same flush the popover renders in. const own = $derived( - ownPath === undefined ? false : isOwner(ownPath, actingUser, $operatingWorkspace) + saveOverride !== undefined || isOwner(path ?? '', actingUser, $operatingWorkspace) ) let onBehalfOfEmail = $state<string | undefined>(undefined) let summaryInput: ReturnType<typeof TextInput> | undefined = $state() let labelsDirty = $state(false) let hasChanges = $derived(editSummary !== (summary ?? '') || (own && dirtyPath) || labelsDirty) - $effect(() => { - if (popoverOpen && onSaved) { - editSummary = summary ?? '' - editPath = path ?? '' - labelsDirty = false - ownPath = path ?? '' - onBehalfOfEmail = undefined - if (kind === 'flow' && $operatingWorkspace && path) { - checkFlowOnBehalfOf($operatingWorkspace, path).then((email) => { - onBehalfOfEmail = email - }) - } - } - }) + /** Seeds the fields from the item and asks who a flow runs on behalf of. Called on the open + * transition rather than from an effect watching `popoverOpen`: that effect also read + * `summary`, `path` and the workspace, so a save or a rename landing while the popover was + * open reseeded the fields from the props — over whatever the user had typed. */ + function onOpen() { + if (!onSaved) return + editSummary = summary ?? '' + editPath = path ?? '' + labelsDirty = false + onBehalfOfEmail = undefined + if (kind !== 'flow' || !$operatingWorkspace || !path) return + const askedFor = path + checkFlowOnBehalfOf($operatingWorkspace, path).then((email) => { + // The answer describes the path the popover opened on. Closed since, or moved to + // another path, it answers nothing on screen. + if (popoverOpen && path === askedFor) onBehalfOfEmail = email + }) + } + + /** The path's name field inside this popover. `Path` hardcodes `id="path"`, and melt resolves a + * string `openFocus` with a document-wide `querySelector`, so a popover portalled to the body + * would hand the keystrokes to whichever other `Path` the page happens to have mounted. */ + const pathField = () => + document.querySelector<HTMLInputElement>('[data-path-edit-path] #path') ?? null async function save(close: () => void) { const initialPath = path ?? '' const newPath = own ? editPath : initialPath + if (saveOverride) { + const error = saveOverride({ path: newPath, summary: editSummary, labels }) + if (error) { + sendUserToast(error, true) + return + } + labelsDirty = false + close() + onSaved?.(newPath) + return + } try { await updateItemPathAndSummary({ @@ -82,7 +132,7 @@ newSummary: editSummary, labels }) - sendUserToast(`${kind === 'flow' ? 'Flow' : 'Script'} updated`) + sendUserToast(`${kind.charAt(0).toUpperCase()}${kind.slice(1)} updated`) labelsDirty = false close() onSaved?.(newPath) @@ -92,45 +142,48 @@ } </script> -{#if editable || onSaved} +{#snippet editor()} + <!-- Without naming the path's own field melt lands on the row's first button, so a popover + opened from the path would not be typing into the path. --> <Popover - class="min-w-0 max-w-full" + class={triggerClass} placement="bottom-start" contentClasses="p-4" + triggerAttrs={{ title: 'Edit summary and path', 'aria-label': 'Edit summary and path' }} usePointerDownOutside excludeSelectors=".drawer" disableFocusTrap - openFocus={() => { - summaryInput?.focus() - return null - }} - bind:isOpen={popoverOpen} + openFocus={focusField === 'path' && own + ? pathField + : () => { + summaryInput?.focus() + return null + }} + bind:isOpen={ + () => popoverOpen, + (v) => { + const opening = v && !popoverOpen + popoverOpen = v + if (opening) onOpen() + } + } > {#snippet trigger()} - <div - class={'min-w-0 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'} - > - <span class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full" - >{path}</span + <!-- Both of the popover's arms show the summary and the path, and ownership decides + whether the path can be changed, not whether it is there. --> + {#if label} + {@render label()} + {:else} + <span + class="{compact + ? 'text-xs font-medium' + : 'text-sm font-semibold'} block truncate {emptyString(summary) + ? 'text-tertiary italic font-normal' + : 'text-emphasis'}" > - <div class="flex items-center gap-3 max-w-full"> - <span - class="text-sm font-semibold truncate {emptyString(summary) - ? 'text-tertiary italic font-normal' - : 'text-emphasis'}" - > - {emptyString(summary) ? 'Add a summary...' : summary} - </span> - {#if labels?.length} - <div class="flex items-center gap-0.5"> - {#each labels as label} - <Badge color="blue" verySmall class="px-1" title="Label: {label}">{label}</Badge> - {/each} - </div> - {/if} - <InheritedLabels labels={inheritedLabels} /> - </div> - </div> + {emptyString(summary) ? 'Add a summary...' : summary} + </span> + {/if} {/snippet} {#snippet content({ close })} <div class="flex flex-col gap-6 w-[480px]"> @@ -168,16 +221,18 @@ {/if} <Label label="Path"> {#if own} - <Path - autofocus={false} - bind:path={editPath} - bind:dirty={dirtyPath} - initialPath={path ?? ''} - namePlaceholder={kind} - {kind} - size="sm" - drawerOffset={4000} - /> + <div data-path-edit-path> + <Path + autofocus={false} + bind:path={editPath} + bind:dirty={dirtyPath} + initialPath={path ?? ''} + namePlaceholder={kind} + kind={pathKind} + size="sm" + drawerOffset={4000} + /> + </div> {:else} <span class="text-xs font-mono text-secondary">{path}</span> <p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p> @@ -216,23 +271,113 @@ </label> <div class="block text-primary"> <div class="pb-1 text-xs font-semibold text-emphasis">Path</div> - <Path - autofocus={false} - bind:path - bind:dirty={dirtyPath} - initialPath={path ?? ''} - namePlaceholder={kind} - {kind} - size="sm" - drawerOffset={4000} - /> + <div data-path-edit-path> + <Path + autofocus={false} + bind:path + bind:dirty={dirtyPath} + initialPath={path ?? ''} + namePlaceholder={kind} + kind={pathKind} + size="sm" + drawerOffset={4000} + /> + </div> </div> {/if} </div> {/snippet} </Popover> +{/snippet} + +{#if label && (editable || onSaved)} + <!-- The host draws the row this hangs in — the band's path segment — so only the trigger comes + from here: no path line above it, and no second copy of the labels. --> + {@render editor()} +{:else if editable || onSaved} + <!-- The name itself opens the popover, the same way the editors do it. Only the name: the whole + block was the trigger once, which meant a path or a label you only wanted to read answered + a click. --> + <div + class="min-w-0 truncate flex {compact + ? 'items-center px-1 py-0.5' + : 'flex-col items-start px-2 py-1'}" + > + {#if !compact} + <span class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full" + >{path}</span + > + {/if} + <div class="flex items-center gap-3 max-w-full min-w-0"> + {@render editor()} + <!-- Outside the trigger: a label is read, not clicked through to a rename. --> + {#if labels?.length} + <div class="flex items-center gap-0.5"> + {#each labels as labelText} + <Badge color="blue" verySmall class="px-1" title="Label: {labelText}">{labelText}</Badge + > + {/each} + </div> + {/if} + <InheritedLabels labels={inheritedLabels} /> + </div> + </div> +{:else if label} + <!-- Nothing to open, but the host still drew this into a slot of its own: give back what it + handed over rather than this component's own block, which belongs in a row it owns. --> + {@render label()} +{:else if compact} + <!-- Nothing to edit, but the band truncates the summary and, on a short bar, drops the path: + the same click shows both whole, to read and to copy. --> + <Popover + class={triggerClass} + placement="bottom-start" + contentClasses="p-4" + triggerAttrs={{ + title: emptyString(summary) ? path : summary, + 'aria-label': 'Show summary and path' + }} + usePointerDownOutside + excludeSelectors=".drawer" + > + {#snippet trigger()} + <span class="block truncate text-xs font-medium text-emphasis"> + {emptyString(summary) ? (path ?? '') : summary} + </span> + {/snippet} + {#snippet content()} + <div class="flex flex-col gap-4 w-[420px] max-w-[calc(100vw-2rem)]"> + <div> + <div class="pb-1 text-xs font-semibold text-emphasis">Summary</div> + {#if emptyString(summary)} + <p class="text-xs text-tertiary italic">No summary</p> + {:else} + <p class="text-xs text-primary break-words">{summary}</p> + {/if} + </div> + <div> + <div class="pb-1 text-xs font-semibold text-emphasis">Path</div> + <div class="flex items-center gap-1"> + <span class="min-w-0 text-xs font-mono text-secondary break-all">{path}</span> + <CopyButton value={path ?? ''} title="Copy path" /> + </div> + </div> + {#if labels?.length || inheritedLabels?.length} + <div class="flex flex-wrap items-center gap-0.5"> + {#each labels ?? [] as labelText} + <Badge color="blue" verySmall class="px-1" title="Label: {labelText}" + >{labelText}</Badge + > + {/each} + <InheritedLabels labels={inheritedLabels} /> + </div> + {/if} + <p class="text-2xs text-tertiary">You can't edit this {kind}'s summary or path.</p> + </div> + {/snippet} + </Popover> {:else} - <div class="min-w-0 truncate flex flex-col px-2"> + <div class="min-w-0 truncate flex items-center flex-col px-2"> {#if !emptyString(summary)} <span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span> {/if} diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 0fd7991597..eb5559a2aa 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -8,7 +8,6 @@ import InviteGlobalUser from '$lib/components/InviteGlobalUser.svelte' import { Button } from '$lib/components/common' import { sendUserToast } from '$lib/toast' - import { base } from '$lib/base' import SearchItems from './SearchItems.svelte' import { page } from '$app/state' import { replaceState } from '$app/navigation' @@ -18,7 +17,7 @@ import { truncate } from '$lib/utils' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' import { ArrowRightLeft, Ban, @@ -47,6 +46,8 @@ tabToAuthSubTab, categoryToTabMap, buildSearchableSettingItems, + listedNavigationGroups, + usesExternalOidcSigner, type SearchableSettingItem } from './instanceSettings' import TextInput from './text_input/TextInput.svelte' @@ -228,7 +229,14 @@ return instanceSettings?.buildFullDiff() ?? { original: '', modified: '' } } // --- Settings search --- - const searchableItems = buildSearchableSettingItems() + let externalOidcSigner = $state(false) + $effect(() => { + if ($enterpriseLicense) usesExternalOidcSigner().then((v) => (externalOidcSigner = v)) + }) + const navigationGroups = $derived( + listedNavigationGroups(instanceSettingsNavigationGroups, externalOidcSigner) + ) + const searchableItems = $derived(buildSearchableSettingItems(navigationGroups)) let scrollTimeout: ReturnType<typeof setTimeout> | undefined let highlightTimeout: ReturnType<typeof setTimeout> | undefined @@ -288,7 +296,7 @@ <Button variant="default" target="_blank" - href="{base}/?workspace=admins" + href="/?workspace=admins" endIcon={{ icon: ExternalLink }} > Admins workspace @@ -301,15 +309,11 @@ <!-- Sidebar Navigation --> <div class="w-52 shrink-0 h-full overflow-auto p-4 bg-surface flex flex-col"> <SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" /> - <SidebarNavigation - groups={instanceSettingsNavigationGroups} - selectedId={tab} - onNavigate={handleNavigate} - /> + <SidebarNavigation groups={navigationGroups} selectedId={tab} onNavigate={handleNavigate} /> {#if $workspaceStore !== 'admins'} <div class="mt-4 pt-2 border-t border-surface-hover"> <a - href="{base}/?workspace=admins" + href="/?workspace=admins" target="_blank" class="flex items-center gap-2 px-2 py-1.5 text-xs text-secondary hover:text-primary transition-colors" > @@ -372,7 +376,7 @@ </div> {/if} - <Tabs bind:selected={usersSubTab} class="mb-4"> + <Tabs bind:selected={usersSubTab} class="mb-4" wrapperClass="shrink-0"> <Tab value="users" label="Users" /> {#if extJwtTokens.length > 0} <Tab value="ext_jwt" label="External JWTs" /> @@ -623,7 +627,7 @@ in {#if workspace_id} <a - href="{base}/workspace_settings?tab=users&workspace={workspace_id}" + href="/workspace_settings?tab=users&workspace={workspace_id}" class="hover:underline" title={workspace_id} onclick={() => closeDrawer?.()} @@ -635,7 +639,7 @@ </span> {:else if groupRole} <a - href="{base}/groups" + href="/groups" class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline" title={groupRoleTooltip} onclick={() => closeDrawer?.()} @@ -655,7 +659,7 @@ displayName: 'Manage in workspace', icon: Settings, action: () => closeDrawer?.(), - href: `${base}/workspace_settings?tab=users&workspace=${workspace_id}` + href: `/workspace_settings?tab=users&workspace=${workspace_id}` } ]} /> diff --git a/frontend/src/lib/components/TimelineBar.svelte b/frontend/src/lib/components/TimelineBar.svelte index 1d8e05008d..66d350a11b 100644 --- a/frontend/src/lib/components/TimelineBar.svelte +++ b/frontend/src/lib/components/TimelineBar.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { msToSec } from '$lib/utils' - import { base } from '$lib/base' import { ExternalLink } from 'lucide-svelte' import Popover from './Popover.svelte' @@ -59,7 +58,7 @@ {#if tooltip} <span>{tooltip}</span> {:else} - <a href="{base}/run/{id}" class="inline-flex items-center gap-1" target="_blank" + <a href="/run/{id}" class="inline-flex items-center gap-1" target="_blank" >{id} <ExternalLink size={14} /></a > {/if} diff --git a/frontend/src/lib/components/UpdateDevWorkspaceModal.svelte b/frontend/src/lib/components/UpdateDevWorkspaceModal.svelte index c53b1436c8..44fdd6465f 100644 --- a/frontend/src/lib/components/UpdateDevWorkspaceModal.svelte +++ b/frontend/src/lib/components/UpdateDevWorkspaceModal.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import { Alert } from '$lib/components/common' - import { base } from '$lib/base' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' import { updateDevWorkspaceModal } from '$lib/utils/editInForkModal.svelte' @@ -80,7 +79,7 @@ // confirm button leads when the user can't deploy here, so the request still has somewhere to go. const compareHref = $derived( pending - ? `${base}/forks/compare?workspace_id=${encodeURIComponent(pending.devWorkspaceId)}` + + ? `/forks/compare?workspace_id=${encodeURIComponent(pending.devWorkspaceId)}` + `&mode=fork&dir=update` + `&${COMPARE_ITEMS_PARAM}=${encodeURIComponent(`${pending.itemType}:${pending.itemPath}`)}` : '' diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index be76185959..901bf119cc 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -13,7 +13,8 @@ import { VARIABLES_PATH } from './sessions/previewPaths' import Alert from './common/alert/Alert.svelte' import { sendUserToast } from '$lib/toast' - import { canWrite } from '$lib/utils' + import { canEditItem } from '$lib/editRights' + import { userWorkspaces } from '$lib/stores' import { Save } from 'lucide-svelte' import VariableForm from './VariableForm.svelte' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' @@ -137,8 +138,12 @@ if (!selected || !edit) return true const perms = extraPerms[selected] if (!perms || !acting.resolved(selected)) return undefined - return canWrite(editPath ?? '', perms, acting.in(selected)) + return canEditVariableIn(selected, perms) }) + function canEditVariableIn(ws: string, perms: Record<string, boolean>): boolean { + const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings + return canEditItem('variable', editPath ?? '', perms, acting.in(ws), settings) + } const dirtyWorkspaces = $derived( Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) @@ -301,7 +306,7 @@ const dirtyCanWrite = $derived( dirtyWorkspaces.every((ws) => { const perms = extraPerms[ws] - return !perms || canWrite(editPath ?? '', perms, acting.in(ws)) + return !perms || canEditVariableIn(ws, perms) }) ) diff --git a/frontend/src/lib/components/WorkerGroup.svelte b/frontend/src/lib/components/WorkerGroup.svelte index 65d00f9ba6..5e8957c31a 100644 --- a/frontend/src/lib/components/WorkerGroup.svelte +++ b/frontend/src/lib/components/WorkerGroup.svelte @@ -55,6 +55,7 @@ import TagList from './TagList.svelte' import DedicatedWorkersSelector from './DedicatedWorkersSelector.svelte' import { computeHashedTag } from './dedicated_worker' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import ObjectStoreConfigSettings, { type ObjectStoreConfig } from './ObjectStoreConfigSettings.svelte' @@ -104,6 +105,7 @@ autoscaling?: AutoscalingConfig native_mode?: boolean object_store_cache_config?: ObjectStoreConfig + paused?: boolean } = $state({}) function loadNConfig() { @@ -111,10 +113,9 @@ ? config.worker_tags != undefined || config.dedicated_worker != undefined || config.dedicated_workers != undefined - ? config - : { - worker_tags: [] - } + ? // a copy: editing the draft must not touch the saved config it is compared against + $state.snapshot(config) + : { ...$state.snapshot(config), worker_tags: [] } : { worker_tags: [] } @@ -212,6 +213,7 @@ periodic_script_interval_seconds?: number native_mode?: boolean object_store_cache_config?: ObjectStoreConfig + paused?: boolean } activeWorkers: number customTags: string[] | undefined @@ -268,8 +270,10 @@ if (!config && !nconfig) return false if (!config || !nconfig) return true - const cleaned1 = cleanValueProperties(config) - const cleaned2 = cleanValueProperties(nconfig) + // Compared without empty values: the draft is filled with empty defaults a saved + // config may lack, which are not an edit. + const cleaned1 = cleanWorkerGroupConfig(cleanValueProperties(config)) + const cleaned2 = cleanWorkerGroupConfig(cleanValueProperties(nconfig)) return ( orderedJsonStringify(replaceFalseWithUndefined(cleaned1)) !== @@ -298,6 +302,47 @@ let openDelete = $state(false) let openClean = $state(false) let openRestart = $state(false) + let pendingConfig: typeof nconfig | undefined = $state() + + // Mirrors `reload_worker_config` in backend/src/monitor.rs, which decides the restart. It + // compares the body that is sent rather than the draft, and only the fields a worker loads. + const RESTART_FIELDS = [ + 'dedicated_worker', + 'dedicated_workers', + 'init_bash', + 'periodic_script_bash', + 'periodic_script_interval_seconds', + 'native_mode' + ] as const + const OTHER_WORKER_FIELDS = [ + 'worker_tags', + 'priority_tags', + 'additional_python_paths', + 'pip_local_dependencies', + 'object_store_cache_config' + ] as const + function restartsWorkers(body: typeof nconfig): boolean { + if (activeWorkers === 0) return false + const saved: typeof nconfig = config ?? {} + const comparable = (c: typeof nconfig, k: keyof typeof nconfig) => + orderedJsonStringify(replaceFalseWithUndefined(cleanWorkerGroupConfig({ v: c[k] }))) + const changed = (k: keyof typeof nconfig) => comparable(saved, k) !== comparable(body, k) + const restartFieldChanged = RESTART_FIELDS.some(changed) + const onlyPauseChanged = + changed('paused') && + !restartFieldChanged && + !OTHER_WORKER_FIELDS.some(changed) && + !hasEnvVarChanges + const dedicated = + body.dedicated_worker != undefined || (body.dedicated_workers?.length ?? 0) > 0 + return !onlyPauseChanged && (dedicated || restartFieldChanged) + } + + async function saveConfig(body: typeof nconfig) { + await ConfigService.updateConfig({ name: 'worker__' + name, requestBody: body }) + sendUserToast('Configuration set') + dispatch('reload') + } // Compute hashed tags for display (actual tags used by the worker) let hashedDedicatedTags: Map<string, string> = $state(new Map()) @@ -399,6 +444,31 @@ </div> </ConfirmationModal> +<ConfirmationModal + open={pendingConfig != undefined} + title="Apply changes and restart workers" + confirmationText="Apply and restart" + on:canceled={() => { + pendingConfig = undefined + }} + on:confirmed={async () => { + const body = pendingConfig + pendingConfig = undefined + if (body) { + await saveConfig(body) + } + }} +> + <div class="flex flex-col w-full space-y-4"> + <span + >Applying these changes will restart the {pluralize(activeWorkers, 'worker')} of worker group '{name}'. + Workers finish their running job, shut down gracefully and are expected to be restarted by + their supervisor.</span + > + <span>Pausing or resuming a worker group alone does not restart its workers.</span> + </div> +</ConfirmationModal> + <ConfirmationModal open={openRestart} title="Restart workers" @@ -733,6 +803,32 @@ </div> {/if} + {#if nconfig !== undefined} + <div class="mt-8"></div> + <Label label="Pause"> + {#snippet header()} + <Tooltip> + {#snippet text()} + A paused worker group stops pulling jobs, including suspended jobs that are resumed. + Jobs already running finish, queued jobs stay in the queue until the group is resumed. + Workers pick the change up within about 10 seconds, without restarting. + {/snippet} + </Tooltip> + {/snippet} + <Toggle + size="sm" + options={{ right: 'Pause this worker group' }} + checked={nconfig?.paused === true} + on:change={(ev) => { + if (nconfig !== undefined) { + nconfig.paused = ev.detail ? true : undefined + } + }} + disabled={!canEditConfig} + /> + </Label> + {/if} + <div class="mt-8"></div> <Label @@ -1143,7 +1239,7 @@ <p class="text-xs text-tertiary">Requires a superadmin token.</p> <pre class="mt-1 p-2 bg-surface-secondary rounded text-xs overflow-x-auto whitespace-pre-wrap break-all" - >curl -X POST '{window.location.origin}/api/configs/update/worker__{name}' \ + >curl -X POST '{apiBaseUrl()}/api/configs/update/worker__{name}' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -d '{JSON.stringify(cleanWorkerGroupConfig(nconfig ?? {}), null, 2)}'</pre @@ -1216,9 +1312,19 @@ } }) - await ConfigService.updateConfig({ name: 'worker__' + name, requestBody: nconfig }) - sendUserToast('Configuration set') - dispatch('reload') + // A group without saved tags inherits them from the worker's environment, and + // an explicit empty list would override that: only send one the user asked for. + const inheritsTags = + config?.worker_tags == undefined && + config?.dedicated_worker == undefined && + config?.dedicated_workers == undefined && + nconfig.worker_tags?.length === 0 + const body = inheritsTags ? { ...nconfig, worker_tags: undefined } : nconfig + if (restartsWorkers(body)) { + pendingConfig = body + } else { + await saveConfig(body) + } }} disabled={(!hasChanges && !hasEnvVarChanges && @@ -1258,6 +1364,16 @@ {#if isNativeMode} <Badge color="blue" small>Native</Badge> {/if} + {#if config?.paused === true} + <Badge color="yellow" small> + Paused + <Tooltip> + {#snippet text()} + Workers of this group pull no new jobs. Queued jobs wait until the group is resumed. + {/snippet} + </Tooltip> + </Badge> + {/if} </div> {#if vcpus_memory?.vcpus} diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 868f215c20..4526281293 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { displayDate, msToSec, emptyString } from '$lib/utils' import { onDestroy } from 'svelte' import { getDbClockNow } from '$lib/forLater' @@ -315,7 +314,7 @@ <a target="_blank" class="inline-flex gap-2 items-baseline hover:underline whitespace-nowrap" - href="{base}/run/{k}" + href="/run/{k}" onclick={(e) => e.stopPropagation()} > {v.name ?? k} diff --git a/frontend/src/lib/components/WorkspaceDeployLayout.svelte b/frontend/src/lib/components/WorkspaceDeployLayout.svelte index 66beb18b73..de666451ee 100644 --- a/frontend/src/lib/components/WorkspaceDeployLayout.svelte +++ b/frontend/src/lib/components/WorkspaceDeployLayout.svelte @@ -38,6 +38,9 @@ deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }> allSelected?: boolean emptyMessage?: string + /** The list is empty because it has not arrived yet. An empty list and an unanswered one + * look alike from here, and "no changes" is a claim that needs an answer behind it. */ + loading?: boolean hideSelection?: boolean children?: Snippet @@ -70,6 +73,7 @@ deploymentStatus, allSelected = false, emptyMessage = 'No items to deploy', + loading = false, hideSelection = false, header, alerts, @@ -308,6 +312,11 @@ {/each} </div> </div> + {:else if loading} + <div class="flex items-center justify-center h-full gap-2 text-hint text-xs"> + <Loader2 class="animate-spin w-4 h-4" /> + Comparing… + </div> {:else} <div class="flex items-center justify-center h-full"> <div class="text-hint text-xs">{emptyMessage}</div> diff --git a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte index 2e0110f710..c77082fbf3 100644 --- a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte +++ b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte @@ -1,10 +1,11 @@ <script lang="ts"> import { workspaceStore, userWorkspaces } from '$lib/stores' import { workspaceIsFork } from '$lib/utils/workspaceHierarchy' - import { Button } from './common' - import { Pencil } from 'lucide-svelte' + import { Badge, Button } from './common' import { goto } from '$app/navigation' import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte' // Surfaces pending drafts (scripts/flows/apps) for the current workspace and // links to the compare page in draft mode. Mutually exclusive with @@ -26,26 +27,33 @@ }) } } + const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR) </script> {#if !isFork && draftCount > 0} - <!-- Side padding mirrors the page content container below, so the banner - stays aligned with it instead of bleeding to the viewport edges. --> - <div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2"> - <div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2"> - <div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2"> - <div class="flex items-center gap-3 min-w-0"> - <Pencil class="w-4 h-4 text-accent shrink-0" /> - <span class="text-xs font-medium text-blue-900 dark:text-blue-100"> - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - </span> - </div> - <!-- Same button as the sibling ForkWorkspaceBanner CTA (they sit on the - same home page), kept visually identical on purpose. --> - <Button variant="default" unifiedSize="sm" onclick={openDraftCompare}> - Review & deploy drafts - </Button> - </div> - </div> - </div> + <!-- In the band rather than a banner over the page: it is a standing fact about the workspace, + not an interruption, and one button says it and acts on it. --> + <PageHeaderContent actions={draftsAction} actionsOrder={-100} /> {/if} + +{#snippet draftsAction()} + <!-- On a phone the badge becomes the button: the count is the part that has to be seen, and + the bar has room for one thing. --> + {#if phone} + <Button + variant="subtle" + unifiedSize="sm" + onclick={openDraftCompare} + title="Review & deploy drafts" + > + <Badge color="blue" small>{draftCount}</Badge> + </Button> + {:else} + <Badge color="blue" small> + {draftCount} draft{draftCount !== 1 ? 's' : ''} + </Badge> + <Button variant="subtle" unifiedSize="sm" onclick={openDraftCompare}> + Review & deploy drafts + </Button> + {/if} +{/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemKindIcon.svelte b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte new file mode 100644 index 0000000000..9d07f6ef89 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte @@ -0,0 +1,19 @@ +<!-- +@component +The glyph the rest of the app gives a workspace item's kind (see RowIcon), at the caller's size. +--> +<script lang="ts"> + import { Code2, LayoutDashboard } from 'lucide-svelte' + import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' + import type { WorkspaceItemKind } from '$lib/components/workspacePicker' + + let { kind, size = 14 }: { kind: WorkspaceItemKind; size?: number } = $props() +</script> + +{#if kind === 'flow'} + <BarsStaggered {size} class="flex-shrink-0 text-teal-500" /> +{:else if kind === 'app'} + <LayoutDashboard {size} class="flex-shrink-0 text-orange-500" /> +{:else} + <Code2 {size} class="flex-shrink-0 text-blue-500" /> +{/if} diff --git a/frontend/src/lib/components/WorkspaceScopeTrigger.svelte b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte index 77fe52b421..37a27f3812 100644 --- a/frontend/src/lib/components/WorkspaceScopeTrigger.svelte +++ b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte @@ -2,8 +2,8 @@ import { userWorkspaces, type UserWorkspace } from '$lib/stores' import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy' import { forkAccentStyle } from '$lib/utils/forkColor' - import { Badge, Button } from '$lib/components/common' - import { devBadgeText } from '$lib/utils/devWorkspaceLabel' + import { Button } from '$lib/components/common' + import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte' import { Building, ChevronDown, EllipsisVertical, GitFork } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { type Item } from '$lib/utils' @@ -171,12 +171,12 @@ {#if pendingFork} <span class="shrink-0 opacity-70 font-normal">(new)</span> {:else if currentWs?.is_dev_workspace} - <Badge - color="dark-blue" - small - class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white" - >{devBadgeText(currentWs.dev_workspace_label)}</Badge - > + <DevWorkspaceBadge + label={currentWs.dev_workspace_label} + color={color ?? (pendingFork ? undefined : currentWs.color)} + fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white" + class="text-3xs px-1 py-0" + /> {/if} {/if} </Button> diff --git a/frontend/src/lib/components/aiEvals/EvalScorers.svelte b/frontend/src/lib/components/aiEvals/EvalScorers.svelte index d00e52d77e..e87745887b 100644 --- a/frontend/src/lib/components/aiEvals/EvalScorers.svelte +++ b/frontend/src/lib/components/aiEvals/EvalScorers.svelte @@ -21,7 +21,6 @@ Settings, Trash2 } from 'lucide-svelte' - import { base } from '$lib/base' import AddScorer from './AddScorer.svelte' import { kindLabel, parseThreshold, scorerLabel, type ScorerKind } from './evalUtils' @@ -304,8 +303,8 @@ <a class="flex items-center gap-1.5 text-xs min-w-0 hover:underline w-fit" href={settingsScorer.kind === 'agent' - ? `${base}/resources?path=${settingsScorer.path}&workspace=${workspace}` - : `${base}/scripts/get/${settingsScorer.path}?workspace=${workspace}`} + ? `/resources?path=${settingsScorer.path}&workspace=${workspace}` + : `/scripts/get/${settingsScorer.path}?workspace=${workspace}`} target="_blank" rel="noreferrer" title={`Open ${settingsScorer.path}`} diff --git a/frontend/src/lib/components/aiEvals/EvalsPane.svelte b/frontend/src/lib/components/aiEvals/EvalsPane.svelte index db765607e5..267d174f0f 100644 --- a/frontend/src/lib/components/aiEvals/EvalsPane.svelte +++ b/frontend/src/lib/components/aiEvals/EvalsPane.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { Button } from '$lib/components/common' import Select from '$lib/components/select/Select.svelte' import DataTable from '$lib/components/table/DataTable.svelte' @@ -768,7 +767,7 @@ {#if experiment?.run_job_id} <a class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2" - href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`} + href={`/run/${experiment.run_job_id}?workspace=${ws}`} target="_blank" > Open the job @@ -962,7 +961,7 @@ {#if openRow.job_id} <a class="text-2xs text-accent hover:underline inline-flex items-center gap-1" - href={`${base}/run/${openRow.job_id}?workspace=${ws}`} + href={`/run/${openRow.job_id}?workspace=${ws}`} target="_blank" > Open the case job diff --git a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts index 21371aa08b..ae3a82e97c 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts @@ -41,6 +41,8 @@ export type ColumnDef = { children?: ColumnDef[] // DBExplorer ignored?: boolean + /** False when the column cannot be filtered on, e.g. a nested value. */ + filterable?: boolean hideInsert?: boolean editable?: boolean overrideDefaultValue?: boolean diff --git a/frontend/src/lib/components/apps/components/helpers/eval.ts b/frontend/src/lib/components/apps/components/helpers/eval.ts index bacfb65b2d..0b6bdd55db 100644 --- a/frontend/src/lib/components/apps/components/helpers/eval.ts +++ b/frontend/src/lib/components/apps/components/helpers/eval.ts @@ -1,7 +1,6 @@ import type { World } from '../../rx' import { sendUserToast } from '$lib/toast' import { waitJob } from '$lib/components/waitJob' -import { base } from '$lib/base' import { appNavigateSameWindow } from '../../utils' import { OpenAPI } from '$lib/gen/core/OpenAPI' @@ -312,10 +311,10 @@ export async function eval_like( params.append('s3', input.s3 ?? '') if (input.storage) params.append('storage', input.storage) params.append('token', OpenAPI.TOKEN) - const s3href = `${base}/api/w/${workspaceId}/apps_u/download_s3_file/${appPath}?${params.toString()}` + const s3href = `/api/w/${workspaceId}/apps_u/download_s3_file/${appPath}?${params.toString()}` downloadFile(s3href, filename || input.s3) } else { - const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent( + const s3href = `/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent( input?.s3 ?? '' )}${input?.storage ? `&storage=${input.storage}` : ''}` downloadFile(s3href, filename || input.s3) diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 073d6c3e7d..ac0d2b1cb6 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -86,7 +86,8 @@ othersDraftsCount = 0, onOpenOthersDrafts, onRestore, - onDeploy + onDeploy, + ownsPageHeader = false }: AppEditorProps = $props() migrateApp(untrack(() => app)) @@ -885,6 +886,7 @@ {#if !$userStore?.operator} {#if $appStore} <AppEditorHeader + {ownsPageHeader} {newPath} {newApp} {labels} @@ -914,7 +916,7 @@ onHideBottomPanel={() => hideBottomPanel()} /> {#if $mode === 'preview'} - <SplitPanesWrapper class="border-t"> + <SplitPanesWrapper> <div class={twMerge( 'h-full w-full relative', @@ -959,7 +961,7 @@ </div> {/if} - <SplitPanesWrapper class="border-t"> + <SplitPanesWrapper> <Splitpanes id="o1" class="max-w-full overflow-hidden"> <Pane bind:size={leftPanelSize} minSize={5} maxSize={33}> <div diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 5ea5a804f1..4e620e2058 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -8,7 +8,7 @@ import { discardDraftAfterDeploy } from '$lib/userDraftToast' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import { isMac, type Item, userPathPrefix } from '$lib/utils' + import { isMac, type Item, userPathPrefix, emptyString } from '$lib/utils' import { random_adj } from '$lib/components/random_positive_adjetive' import { AlignHorizontalSpaceAround, @@ -32,7 +32,7 @@ Zap, Globe } from 'lucide-svelte' - import { getContext, onDestroy, untrack } from 'svelte' + import { getContext, onDestroy, untrack, type Snippet } from 'svelte' import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils' import type { App, AppEditorContext, AppViewerContext } from '../types' import { toStatic } from '../utils' @@ -52,6 +52,9 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte' import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' @@ -114,6 +117,8 @@ // the deploy wrote, for the next draft's fork base, and `head` what is deployed // now, with its author and time. onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } let { @@ -142,10 +147,11 @@ othersDraftsCount = 0, onOpenOthersDrafts, onRestore, - onDeploy + onDeploy, + ownsPageHeader = false }: Props = $props() - /** Mirror of the path the user is editing in the pen popover. Initialized + /** Mirror of the path the user is editing in the rename popover. Initialized * once from `newPath` (or a synthesized path for new apps) and only * updated by user input from then on — we deliberately do NOT sync from * `newPath` afterwards so the user's in-flight rename isn't clobbered by @@ -223,6 +229,18 @@ // the preview to the deployed version. const inSessionPane = !!getContext('aiChatManager') + // The header's buttons render under the page header, not under this component, so the contexts + // they look up have to travel with them. The app's own two carry everything the canvas toggles, + // the panel buttons, Debug runs and the preview switch read. + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state<string | undefined>(undefined) + + const headerContexts = new Map<any, any>([ + ['AppViewerContext', getContext('AppViewerContext')], + ['AppEditorContext', getContext('AppEditorContext')] + ]) + const loading = $state({ publish: false, save: false @@ -243,19 +261,36 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has + // given all it can; anywhere else this editor's own top bar is theirs alone. Folded, the canvas + // toggles stand down and the menu carries them and Debug runs, which keeps them reachable + // rather than merely out of the way. + const compactTopbar = $derived( + ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720 + ) + // The second fold holds the trail and Deploy. Which panels are open, the export and the + // editor/preview switch join the menu compact already shows. + const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) function closeSaveDrawer() { saveDrawerOpen = false } + // `parent_version` is the draft's stale-check base. Deployed, it would put a version + // id that changes on every deploy into git-synced files and version diffs. + function withoutDraftBase<T extends App | undefined>(a: T): T { + if (!a) return a + const { parent_version: _pv, ...rest } = a + return rest as T + } + async function createApp(path: string) { policy = await updatePolicy($app, policy) try { await AppService.createApp({ workspace: $workspaceStore!, requestBody: { - value: $app, + value: withoutDraftBase($app), path, summary: $summary, policy, @@ -314,7 +349,7 @@ orderedJsonStringify( replaceFalseWithUndefined({ summary: $summary, - value: $app, + value: withoutDraftBase($app), path: newEditedPath || savedApp.path, policy, custom_path: customPath, @@ -357,6 +392,7 @@ // Strip off extra information deployedValue = replaceFalseWithUndefined({ ...deployedApp, + value: withoutDraftBase(deployedApp.value as App), id: undefined, created_at: undefined, created_by: undefined, @@ -371,7 +407,7 @@ workspace: $workspaceStore!, path: $appPath!, requestBody: { - value: $app!, + value: withoutDraftBase($app!), summary: $summary, policy, path: npath, @@ -590,7 +626,49 @@ selectedJobId = $jobs[$jobs.length - 1] } $jobsDrawerOpen = true - }, + } + }, + // The canvas toggles stand down with the bar, and the breakpoint among them is the + // only way onto the mobile layout — so each one keeps an entry here. + { + displayName: $breakpoint === 'sm' ? 'Computer view' : 'Mobile view', + icon: $breakpoint === 'sm' ? Laptop2 : Smartphone, + action: () => ($breakpoint = $breakpoint === 'sm' ? 'lg' : 'sm') + }, + // The setting the mobile view is edited for, which otherwise lives beside the + // toggle group that stood down: without it here a deployed app could be put on + // the mobile layout at this width and never taken off it. + ...($breakpoint === 'sm' + ? [ + { + displayName: $app?.mobileViewOnSmallerScreens + ? 'Mobile view on small screens: on' + : 'Mobile view on small screens: off', + icon: Smartphone, + action: () => { + if ($app) $app.mobileViewOnSmallerScreens = !$app.mobileViewOnSmallerScreens + } + } + ] + : []), + { + displayName: $app?.fullscreen ? 'Centered canvas' : 'Full-width canvas', + icon: $app?.fullscreen ? AlignHorizontalSpaceAround : Expand, + action: () => { + if ($app) $app.fullscreen = !$app.fullscreen + } + }, + { + displayName: + $app?.darkMode === undefined + ? 'Theme: automatic' + : $app.darkMode + ? 'Theme: dark' + : 'Theme: light', + icon: $app?.darkMode === undefined ? SunMoon : $app.darkMode ? Moon : Sun, + // Cycles auto → light → dark, the order the toggle group reads in. + action: () => + setTheme($app?.darkMode === undefined ? false : $app.darkMode ? undefined : true), separatorBottom: true } ] @@ -676,7 +754,7 @@ deployed: deployedValue ?? savedApp, current: { summary: $summary, - value: $app, + value: withoutDraftBase($app), path: newEditedPath || savedApp.path, policy, custom_path: customPath, @@ -770,7 +848,7 @@ bind:deployedValue currentValue={{ summary: $summary, - value: $app, + value: withoutDraftBase($app), path: newEditedPath || savedApp?.path, policy, custom_path: customPath, @@ -821,7 +899,7 @@ deployed: deployedValue ?? savedApp, current: { summary: $summary, - value: $app, + value: withoutDraftBase($app), path: newEditedPath || savedApp.path, policy, custom_path: customPath, @@ -913,141 +991,234 @@ <AppReportsDrawer bind:open={appReportingDrawerOpen} appPath={$appPath ?? ''} /> -<div - bind:clientWidth={topbarWidth} - class="flex flex-row justify-between gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto max-h-12 h-12 shrink-0" -> - <!-- Identity block shrinks/truncates first (min-w-0) so the cloud indicator +{#if ownsPageHeader} + <!-- The editor's own top bar is the page header on this route: the app's path and summary are + the breadcrumb's, and everything else the bar carried rides along as the header's actions. --> + <PageHeaderContent + item={{ + // The path being edited, not the stored one: a brand-new app is parked at a + // `draft_<uuid>` placeholder, and the trail would name that instead of the path Deploy + // will create. Same fallback chain the rename sites read. + // Frozen while the rename popover is open so the trail holds still as the user types. + kind: 'app', + path: pathSnapshot ?? (newEditedPath || $appPath || newPath || undefined), + summary: $summary, + summaryContent: appSummary, + pathTrigger: appPathTrigger + }} + actions={appHeaderActions} + actionFolds={2} + contexts={headerContexts} + separator="always" + /> +{:else} + <div + bind:clientWidth={topbarWidth} + class="flex flex-row justify-between gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto max-h-12 h-12 shrink-0" + > + <!-- Identity block shrinks/truncates first (min-w-0) so the cloud indicator and the pinned action groups (shrink-0 below) stay visible on narrow widths instead of the breadcrumb overflowing and hiding them. Kept at min-w-0 (not flex-1) so justify-between still positions the panel-hide buttons between the identity and the actions. --> - <div class="flex flex-row gap-2 items-center min-w-0"> - <div class="min-w-0 overflow-hidden"> - <EditorHeader - bind:summary={$summary} - bind:path={newEditedPath} - savedPath={$appPath || newPath || undefined} - kind="app" - onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} - /> - </div> - <div class="flex gap-2 shrink-0 {compactTopbar ? 'hidden' : ''}"> - {#if $app} - <ToggleButtonGroup - selected={$app.fullscreen ? 'true' : 'false'} - on:selected={({ detail }) => { - $app.fullscreen = detail === 'true' - }} - > - {#snippet children({ item })} - <ToggleButton - icon={AlignHorizontalSpaceAround} - iconOnly - value={'false'} - tooltip="The max width is 1168px and the content stay centered instead of taking the full page width" - {item} - size="md" - /> - <ToggleButton - tooltip="The width is of the app if the full width of its container" - icon={Expand} - iconOnly - value={'true'} - {item} - size="md" - /> - {/snippet} - </ToggleButtonGroup> - {/if} - {#if $app} - <ToggleButtonGroup - on:selected={({ detail }) => { - const theme = detail === 'dark' ? true : detail === 'sun' ? false : undefined - setTheme(theme) - }} - selected={$app.darkMode === undefined ? 'auto' : $app.darkMode ? 'dark' : 'sun'} - > - {#snippet children({ item })} - <ToggleButton - icon={SunMoon} - iconOnly - value={'auto'} - tooltip="The app mode between dark/light is automatic" - {item} - size="md" - /> - <ToggleButton - icon={Sun} - iconOnly - value={'sun'} - tooltip="Force light mode" - {item} - size="md" - /> - <ToggleButton - tooltip="Force dark mode" - icon={Moon} - iconOnly - value={'dark'} - {item} - size="md" - /> - {/snippet} - </ToggleButtonGroup> - {/if} - <div class="flex flex-row gap-2"> - <ToggleButtonGroup bind:selected={$breakpoint}> - {#snippet children({ item })} - <ToggleButton - tooltip="Computer View" - icon={Laptop2} - iconOnly - value={'lg'} - {item} - size="md" - /> - <ToggleButton - tooltip="Mobile View" - icon={Smartphone} - iconOnly - value={'sm'} - {item} - size="md" - /> - {#if $breakpoint === 'sm'} - <Toggle - size="xs" - options={{ - right: 'Enable mobile view for smaller screens', - rightTooltip: - 'Desktop view is enabled by default. Enable this to customize the layout of the components for the mobile view' - }} - textClass="text-2xs whitespace-nowrap white !w-full" - bind:checked={$app.mobileViewOnSmallerScreens} - class="flex flex-row px-2 items-center" - /> - {/if} - {/snippet} - </ToggleButtonGroup> - </div> - </div> - {#if $workspaceStore} - <div class="ml-4"> - <AutosaveIndicator - workspace={$workspaceStore} - itemKind="app" - path={userDraftPath} - draftOnly={newApp} - {onResetToDeployed} - {loadedFromDraft} - {othersDraftsCount} - {onOpenOthersDrafts} + <div class="flex flex-row gap-2 items-center min-w-0"> + <div class="min-w-0 overflow-hidden"> + <EditorHeader + bind:summary={$summary} + bind:path={newEditedPath} + savedPath={$appPath || newPath || undefined} + kind="app" + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} /> </div> - {/if} - </div> + {@render canvasToggles()} + {@render autosaveIndicator()} + </div> + {@render panelButtons()} + {@render awarenessMark()} + {@render appActions()} + </div> +{/if} + +<!-- The summary's editor again, hung off the band's path segment so it opens under the path, with + the cursor in the path field. `bind:` cannot be spread, so the slots are written out twice; + both instances bind the same ones and only one is ever open. --> +{#snippet appPathTrigger(pathLabel: Snippet, triggerClass: string)} + <PathEditPopover + label={pathLabel} + {triggerClass} + focusField="path" + bind:summary={$summary} + bind:path={newEditedPath} + bind:snapshotPath={pathSnapshot} + savedPath={$appPath || newPath || undefined} + kind="app" + /> +{/snippet} + +{#snippet appSummary()} + <!-- Not edited in place: clicking the name opens the summary and the path together, so the band + reads as a name rather than a form. `title` for one it truncates. --> + <div class="flex items-center gap-1 min-w-0"> + {#snippet summaryText()} + <span + class="min-w-0 truncate text-xs {emptyString($summary) + ? 'text-tertiary italic font-normal' + : 'font-medium text-emphasis'}" + title={$summary}>{emptyString($summary) ? 'Add a summary...' : $summary}</span + > + {/snippet} + <PathEditPopover + label={summaryText} + bind:summary={$summary} + bind:path={newEditedPath} + bind:snapshotPath={pathSnapshot} + savedPath={$appPath || newPath || undefined} + kind="app" + /> + </div> +{/snippet} + +{#snippet appHeaderActions()} + <!-- The bar's own order kept: what the canvas looks like, how the draft is doing, which panels + are open, then what to do with the app. The set scrolls rather than pushing past the end of + the bar — this editor carries the widest action row of any page, and the row it replaced + scrolled for the same reason. --> + <div + class="flex flex-row items-center gap-2 min-w-0 overflow-x-auto scrollbar-hidden whitespace-nowrap" + > + {@render canvasToggles()} + {@render autosaveIndicator()} + {#if !phoneTopbar} + {@render panelButtons()} + {@render awarenessMark()} + {/if} + {@render appActions()} + </div> +{/snippet} + +{#snippet canvasToggles()} + <div class="flex gap-2 shrink-0 {compactTopbar ? 'hidden' : ''}"> + {#if $app} + <ToggleButtonGroup + selected={$app.fullscreen ? 'true' : 'false'} + on:selected={({ detail }) => { + $app.fullscreen = detail === 'true' + }} + > + {#snippet children({ item })} + <ToggleButton + icon={AlignHorizontalSpaceAround} + iconOnly + value={'false'} + tooltip="The max width is 1168px and the content stay centered instead of taking the full page width" + {item} + size="md" + /> + <ToggleButton + tooltip="The width is of the app if the full width of its container" + icon={Expand} + iconOnly + value={'true'} + {item} + size="md" + /> + {/snippet} + </ToggleButtonGroup> + {/if} + {#if $app} + <ToggleButtonGroup + on:selected={({ detail }) => { + const theme = detail === 'dark' ? true : detail === 'sun' ? false : undefined + setTheme(theme) + }} + selected={$app.darkMode === undefined ? 'auto' : $app.darkMode ? 'dark' : 'sun'} + > + {#snippet children({ item })} + <ToggleButton + icon={SunMoon} + iconOnly + value={'auto'} + tooltip="The app mode between dark/light is automatic" + {item} + size="md" + /> + <ToggleButton + icon={Sun} + iconOnly + value={'sun'} + tooltip="Force light mode" + {item} + size="md" + /> + <ToggleButton + tooltip="Force dark mode" + icon={Moon} + iconOnly + value={'dark'} + {item} + size="md" + /> + {/snippet} + </ToggleButtonGroup> + {/if} + <div class="flex flex-row gap-2"> + <ToggleButtonGroup bind:selected={$breakpoint}> + {#snippet children({ item })} + <ToggleButton + tooltip="Computer View" + icon={Laptop2} + iconOnly + value={'lg'} + {item} + size="md" + /> + <ToggleButton + tooltip="Mobile View" + icon={Smartphone} + iconOnly + value={'sm'} + {item} + size="md" + /> + {#if $breakpoint === 'sm'} + <Toggle + size="xs" + options={{ + right: 'Enable mobile view for smaller screens', + rightTooltip: + 'Desktop view is enabled by default. Enable this to customize the layout of the components for the mobile view' + }} + textClass="text-2xs whitespace-nowrap white !w-full" + bind:checked={$app.mobileViewOnSmallerScreens} + class="flex flex-row px-2 items-center" + /> + {/if} + {/snippet} + </ToggleButtonGroup> + </div> + </div> +{/snippet} + +{#snippet autosaveIndicator()} + {#if $workspaceStore} + <div class="ml-4"> + <AutosaveIndicator + workspace={$workspaceStore} + itemKind="app" + path={userDraftPath} + draftOnly={newApp} + {onResetToDeployed} + {loadedFromDraft} + {othersDraftsCount} + {onOpenOthersDrafts} + /> + </div> + {/if} +{/snippet} + +{#snippet panelButtons()} {#if $mode !== 'preview'} <div class="flex gap-1 shrink-0"> <HideButton @@ -1085,11 +1256,17 @@ /> </div> {/if} +{/snippet} + +{#snippet awarenessMark()} {#if $enterpriseLicense && $appPath != '' && !inSessionPane} <div class="shrink-0"> <Awareness /> </div> {/if} +{/snippet} + +{#snippet appActions()} <div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0"> <Dropdown items={moreItems} /> @@ -1131,14 +1308,19 @@ </div> </Button> </div> + <!-- Draws nothing in the bar: it is the export drawer, opened from the menu's Export + entries through this instance. The preview switch keeps its place on a phone, since + seeing the app is most of what an app editor is good for on one. --> <AppExportButton bind:this={appExport} /> - <PreviewToggle loading={loading.save} /> + <PreviewToggle loading={loading.save} iconOnly={phoneTopbar} /> <Button variant="accent" loading={loading.save} startIcon={{ icon: Save }} on:click={save} - unifiedSize="md" + iconOnly={phoneTopbar} + title="Deploy" + unifiedSize={phoneTopbar ? 'sm' : 'md'} dropdownItems={$appPath != '' ? () => [ { @@ -1163,4 +1345,4 @@ Deploy </Button> </div> -</div> +{/snippet} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index d062247fa4..7b25dc234e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -3,6 +3,7 @@ import Badge from '$lib/components/common/badge/Badge.svelte' import Toggle from '$lib/components/Toggle.svelte' import { enterpriseLicense, userStore } from '$lib/stores' + import { useOperatorBuilderApps } from '$lib/operatorWriteRights' import { Loader2 } from 'lucide-svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -15,7 +16,6 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import Path from '$lib/components/Path.svelte' import { computeSecretUrl } from './appDeploy.svelte' - import { base } from '$lib/base' import { isCloudHosted } from '$lib/cloud' import EEOnly from '$lib/components/EEOnly.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' @@ -34,6 +34,7 @@ const operatingWorkspaceStore = useOperatingWorkspace() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) + const operatorBuilderApps = useOperatorBuilderApps() const WM_DEPLOYERS_GROUP = 'wm_deployers' @@ -185,7 +186,7 @@ let secretUrl: string | undefined = $state(undefined) let secretUrlHref = $derived(secretUrl ? computeSecretUrl(secretUrl) : undefined) let fullCustomUrl = $derived( - `${window.location.origin}${base}/a/${ + `${window.location.origin}/a/${ isCloudHosted() || globalWorkspacedRoute ? opWs + '/' : '' }${customPath}` ) @@ -364,7 +365,7 @@ <div class="my-6"> <Toggle options={{ right: "Isolate the app from the viewer's browser session" }} - checked={policy.sandbox == true} + checked={$operatorBuilderApps || policy.sandbox == true} on:change={(e) => { policy.sandbox = e.detail || undefined // Counted where the toggle is flipped rather than where the policy is @@ -387,8 +388,13 @@ setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') } }} - disabled={!savedApp} + disabled={!savedApp || $operatorBuilderApps} /> + {#if $operatorBuilderApps} + <div class="text-xs text-secondary mt-1"> + Required for apps built by operators, and cannot be turned off. + </div> + {/if} <div class="text-xs text-secondary mt-1"> Controls what the app's browser-side code can reach in each viewer's browser — distinct from the on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code diff --git a/frontend/src/lib/components/apps/editor/AppReportsDrawerInner.svelte b/frontend/src/lib/components/apps/editor/AppReportsDrawerInner.svelte index d77deb6579..672d8dd6e6 100644 --- a/frontend/src/lib/components/apps/editor/AppReportsDrawerInner.svelte +++ b/frontend/src/lib/components/apps/editor/AppReportsDrawerInner.svelte @@ -15,7 +15,6 @@ WorkspaceService } from '$lib/gen' import { workspaceStore } from '$lib/stores' - import { base } from '$lib/base' import { emptyString, formatCron, sendUserToast, tryEvery } from '$lib/utils' import SchemaForm from '$lib/components/SchemaForm.svelte' import Button from '$lib/components/common/button/Button.svelte' @@ -644,7 +643,7 @@ <p class="text-clip grow min-w-0"> The workspace needs to be connected to Slack to use this feature. You can <a target="_blank" - href="{base}/workspace_settings?tab=slack">configure it here</a + href="/workspace_settings?tab=slack">configure it here</a >. </p> <Button diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index e004c17a04..3384ad3e87 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -9,31 +9,37 @@ * untrusted markup/JS must not run with the member's full session — hence the * scoped embed token / opaque isolation. */ - import { base } from '$lib/base' import { sendUserToast } from '$lib/toast' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte' + import type { Item } from '$lib/utils' import { Button } from '$lib/components/common' import { AppService, OpenAPI } from '$lib/gen' - import type { UserExt } from '$lib/stores' - import { canWrite } from '$lib/utils' + import { userStore, type UserExt } from '$lib/stores' import { getUserExt } from '$lib/user' - import { Pen } from 'lucide-svelte' + import { ExternalLink, Pen } from 'lucide-svelte' import { page } from '$app/state' - import { - setOperatingWorkspace, - useOperatingUser - } from '$lib/components/operatingWorkspace.svelte' + import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte' + import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useEditRights } from '$lib/operatorWriteRights' let { workspace, path, onEdit, onLoadState, - syncHashToUrl = true + syncHashToUrl = true, + ownsPageHeader = false }: { workspace: string path: string + /** True for the route's own page: the app becomes what the page header names. A host that + * renders this inside something else — a session's preview panel, whose band names the + * session — leaves it false, and the app keeps its Edit over its own canvas. */ + ownsPageHeader?: boolean /** Handle Edit in place instead of following `editHref`. An AI session shows * this viewer inside a preview tab, where a plain link would navigate the whole * page out of the session rather than flipping the tab to its editor. */ @@ -48,7 +54,6 @@ // The app, its permission check and everything this viewer renders belong to `workspace`, // which on a session preview tab is the session's and not the one the nav points at. setOperatingWorkspace(() => workspace) - const operatingUser = useOperatingUser() /** This workspace's membership for the viewer, which is what the app's `ctx.username` / * `ctx.groups` must describe — they sit beside `ctx.workspace` in the same object. */ @@ -62,15 +67,23 @@ let appPerms = $state<{ path: string; extraPerms: Record<string, boolean> } | undefined>( undefined ) - const canWriteApp = $derived( - !!appPerms && canWrite(appPerms.path, appPerms.extraPerms, operatingUser.current) - ) /** Raw vs low-code, read from the app itself rather than from the route: * both kinds render here and either route serves either kind (links to a raw * app point at /apps/get all over the app), so only the app can say which * editor the Edit button must open. */ let isRawApp = $state(false) - let editHref = $derived(`${base}/${isRawApp ? 'apps_raw' : 'apps'}/edit/${path}?nodraft=true`) + const editRights = useEditRights() + const canWriteApp = $derived( + !!appPerms && + editRights.canEditItem(isRawApp ? 'raw_app' : 'app', appPerms.path, appPerms.extraPerms) + ) + /** The app's execution mode, from the same lite read as the permissions and for the same + * reason: a sandboxed low-code app loads inside the opaque iframe, so `app` here stays + * undefined and cannot say whether the app answers at a public url. `policy.execution_mode` is + * where the lite payload carries it; the flat field beside it is the apps *list* row's + * spelling (ListableApp, which is what AppRow reads). */ + let executionMode = $state<string | undefined>(undefined) + let editHref = $derived(`/${isRawApp ? 'apps_raw' : 'apps'}/edit/${path}?nodraft=true`) let refresh: (() => void) | undefined // The opaque iframe loads the dedicated cookieless, chrome-less viewer route. @@ -80,11 +93,16 @@ // page's URL (wm_embed_hash relay), and re-deriving the iframe src from them // would reload the app on its every navigation. const initialSearchHash = page.url.search + page.url.hash - let viewerUrl = $derived(`${base}/app_embed/${workspace}/${path}${initialSearchHash}`) + let viewerUrl = $derived(`/app_embed/${workspace}/${path}${initialSearchHash}`) const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true' const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true' + const showEdit = $derived(canWriteApp && !hideEditBtn) + // Decides where Edit goes, not whether it appears: without the workspace navigation there is + // no band to put it in. Same rule the layout hides the sidebar by. + const menuHidden = $derived(isMenuHidden(page.url)) + // Embedder side: mint a scoped embed token (by path) from the member's session. async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> { const headers: Record<string, string> = {} @@ -155,48 +173,142 @@ const lite: any = await AppService.getAppLiteByPath({ workspace, path }) appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} } isRawApp = !!lite?.raw_app + executionMode = lite?.policy?.execution_mode ?? lite?.execution_mode } catch (_) { appPerms = undefined } } + /** Whether the app answers at a public url at all. Read from the app this viewer has already + * loaded, the way the apps table decides the same thing (AppRow) — the secret itself is a + * request, and asking for one on every app view to decide whether to draw a button is a + * request per view that most views never use. */ + const hasPublicUrl = $derived(executionMode === 'anonymous') + /** Resolved when the button is used, not when it is drawn. */ + async function openPublicUrl() { + // The tab is opened inside the click itself and filled once the secret arrives. Safari only + // lets a gesture open the tab it opens synchronously, so opening it after the await is + // blocked there — silently, since a blocked popup reports nothing. + const tab = window.open('about:blank', '_blank') + try { + const secret = await AppService.getPublicSecretOfApp({ workspace, path }) + if (!secret) { + tab?.close() + sendUserToast('This app has no public url', true) + return + } + // Built from this viewer's workspace rather than the navigation one, like everything + // else here: the two differ inside a session's preview panel. + const url = `${window.location.origin}/public/${workspace}/${secret}` + if (tab) tab.location.href = url + else sendUserToast('Allow pop-ups for this site to open the app’s public url', true) + } catch (e: any) { + tab?.close() + sendUserToast('Could not open the public url: ' + (e?.body ?? e?.message ?? e), true) + } + } + $effect(() => { if (workspace && path) loadPerms() }) + + // Both of this page's buttons carry a label, and a phone's bar has room for neither beside the + // app's path — so below that width they become one menu. Unmeasured (0) counts as wide: the bar + // measures itself on mount, and starting compact would pop them out a frame later. + const compact = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR) + const compactItems: Item[] = $derived([ + ...(hasPublicUrl + ? [ + { + displayName: 'Public url', + icon: ExternalLink, + action: () => openPublicUrl() + } + ] + : []), + { + displayName: 'Edit', + icon: Pen, + href: onEdit ? undefined : editHref, + action: onEdit ? () => onEdit() : undefined + } + ]) </script> -<PublicAppFrame - {fetchEmbedToken} - {viewerUrl} - onViewerReady={(_token, requestTokenRefresh) => { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - <PublicApp - {app} - {workspace} - user={appUser} - {notExists} - {noPermission} - jwtError={false} - inWorkspace - {hideRefreshBar} - {syncHashToUrl} - onLoginSuccess={() => loadApp()} - ></PublicApp> - {/snippet} -</PublicAppFrame> +<div class="h-full"> + <PublicAppFrame + {fetchEmbedToken} + {viewerUrl} + onViewerReady={(_token, requestTokenRefresh) => { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + <PublicApp + {app} + {workspace} + user={appUser ?? $userStore} + {notExists} + {noPermission} + jwtError={false} + inWorkspace + {hideRefreshBar} + {syncHashToUrl} + onLoginSuccess={() => loadApp()} + ></PublicApp> + {/snippet} + </PublicAppFrame> +</div> -{#if canWriteApp && !hideEditBtn} - <div id="app-edit-btn" class="absolute bottom-4 z-50 right-4"> - <Button - size="sm" - startIcon={{ icon: Pen }} - variant="subtle" - href={onEdit ? undefined : editHref} - on:click={() => onEdit?.()}>Edit</Button - > +<!-- The band names the app for whoever opened it, write access or not: the route alone registers + no item, and the breadcrumb would fall back to the section name "Apps". --> +{#if ownsPageHeader} + <PageHeaderContent + item={{ kind: 'app', path }} + actions={showEdit && !menuHidden ? editAction : undefined} + separator="always" + /> +{/if} + +<!-- Edit is the bar's action when this viewer owns the bar, and floats over the canvas otherwise: + an embed has no bar — it would cost the app 44px of the iframe to carry one button — and + inside a session's preview panel the bar belongs to the session, so an Edit up there would + sit beside the session's name and act on the panel below it. --> +{#if showEdit && (menuHidden || !ownsPageHeader)} + <div class="absolute bottom-4 right-4 z-50"> + {@render editAction()} </div> {/if} + +{#snippet editAction()} + {#if compact} + <!-- Too little bar to seat both: they fold into one menu rather than losing their labels, + which are what say where each one goes. --> + <DropdownV2 items={compactItems} placement="bottom-end" size="sm" /> + {:else} + {#if hasPublicUrl} + <!-- The app on its own, at the url anyone it is shared with uses. A new tab rather than + this one: the viewer here is the same app, so replacing it would look like nothing + happened. The secret is fetched by the click, so the url exists only once asked for. --> + <Button + unifiedSize="sm" + variant="subtle" + startIcon={{ icon: ExternalLink }} + onclick={openPublicUrl} + title="Open the app's public url in a new tab">Public url</Button + > + {/if} + <!-- `onEdit` wins over the href: a host that embeds this viewer opens its own editor rather + than navigating the frame to one. --> + <Button + unifiedSize="sm" + startIcon={{ icon: Pen }} + variant="default" + href={onEdit ? undefined : editHref} + on:click={() => onEdit?.()} + title="Edit this app" + id="app-edit-btn">Edit</Button + > + {/if} +{/snippet} diff --git a/frontend/src/lib/components/apps/editor/PreviewToggle.svelte b/frontend/src/lib/components/apps/editor/PreviewToggle.svelte index 7a72ac62b3..4e20301043 100644 --- a/frontend/src/lib/components/apps/editor/PreviewToggle.svelte +++ b/frontend/src/lib/components/apps/editor/PreviewToggle.svelte @@ -8,9 +8,11 @@ interface Props { loading?: boolean + /** Drops the two labels, for a bar with no room for them: the pen and the eye carry it. */ + iconOnly?: boolean } - let { loading = false }: Props = $props() + let { loading = false, iconOnly = false }: Props = $props() const { mode, jobs, jobsById } = getContext<AppViewerContext>('AppViewerContext') </script> @@ -24,7 +26,8 @@ > {#snippet children({ item })} <ToggleButton - label="Editor" + label={iconOnly ? undefined : 'Editor'} + tooltip={iconOnly ? 'Editor mode' : undefined} value="dnd" icon={Pen} disabled={loading} @@ -33,7 +36,7 @@ {item} /> <ToggleButton - label="Preview" + label={iconOnly ? undefined : 'Preview'} value="preview" icon={Eye} tooltip="Preview mode" diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 58b6eaf838..64da63e727 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -1,28 +1,15 @@ <script lang="ts"> import { applyDarkModeVariant } from '$lib/darkModeVariant' - import { enterpriseLicense, userStore, type UserExt } from '$lib/stores' - import { base } from '$app/paths' - import { page } from '$app/state' - import Login from '$lib/components/Login.svelte' + import type { UserExt } from '$lib/stores' import { isCloudHosted } from '$lib/cloud' - import Alert from '$lib/components/common/alert/Alert.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte' - import { getContext, setContext } from 'svelte' - import { - EMBED_NAV_CONTEXT_KEY, - IS_APP_PUBLIC_CONTEXT_KEY, - type EditorBreakpoint, - type EmbedNav - } from '../types' - import { UserService, type AppWithLastVersion, type GlobalWhoamiResponse } from '$lib/gen' - import { urlParamsToObject } from '$lib/utils' - import { goto } from '$app/navigation' - import { loadAppPreview } from './loadAppPreview' + import { setContext } from 'svelte' + import { IS_APP_PUBLIC_CONTEXT_KEY } from '../types' + import type { AppWithLastVersion } from '$lib/gen/types.gen' import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte' import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy' - import { twMerge } from 'tailwind-merge' - import { writable } from 'svelte/store' + import { loadAppCss } from './publicAppApi' let { notExists, @@ -33,6 +20,7 @@ app, workspace, user = undefined, + license = undefined, inWorkspace = false, hideRefreshBar = false, syncHashToUrl = true @@ -46,11 +34,13 @@ onLoginSuccess: () => void app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined workspace: string | undefined - /** The viewer's membership in `workspace`, for the app's `ctx`. Without it `ctx` falls - * back to `userStore`, which describes the workspace the page is navigated to — the - * same one on every standalone viewer route, but not inside an AI session's preview - * tab, where the app belongs to the session's workspace. */ + /** The viewer's membership in `workspace`, for the app's `ctx`. Callers pass it + * explicitly — `$userStore` describes the workspace the page is navigated to, which + * is not the app's inside an AI session's preview tab. */ user?: UserExt | undefined + /** The instance license, which fades the "Powered by" badge and lets a low-code app's + * custom CSS apply. */ + license?: string | undefined /** * In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity * with the pre-sandbox member viewer — no "Powered by Windmill" badge, no @@ -65,23 +55,11 @@ // Use workspace from props or from app.workspace_id (for custom path responses) let effectiveWorkspace = $derived(workspace ?? app?.workspace_id) - // `ctx.username` / `ctx.groups` are per-workspace, and they sit next to `ctx.workspace` - // in the same object, so they have to come from the same workspace it names. - let ctxUser = $derived(user ?? $userStore) - // On the public surfaces (untrusted distribution) runnable-authored html/svg needs // the viewer's approval before it renders, unless the app sandbox isolates it. The // in-workspace viewer renders it verbatim. See getAppMarkupTrust. setContext(IS_APP_PUBLIC_CONTEXT_KEY, !inWorkspace) - // WIN-2006: inside the opaque viewer iframe, navigations to other routes - // (navbar "app" items) must happen on the TOP page — the iframe is cookieless, - // so navigating it would just show a login screen. PublicAppFrame provides the - // relay; outside the opaque viewer this is undefined and goto works directly. - const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY) - - const breakpoint = writable<EditorBreakpoint>('lg') - const darkMode = window.localStorage.getItem('dark-mode') ?? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') @@ -93,135 +71,75 @@ } // This route bypasses the (root) layout, so restore the variant class too. applyDarkModeVariant() - - let globalUser = $state<GlobalWhoamiResponse | undefined>(undefined) - async function loadGlobalUser() { - try { - globalUser = await UserService.globalWhoami() - } catch (error) { - console.error(error) - } - // const user = await fetch('/api/global/user') - // console.log(user) - } - - // Only the no-access page reads it, to tell a signed-in non-member which - // workspace the app belongs to. - $effect(() => { - if (noPermission && !guestAppPath && !$userStore && !globalUser) { - loadGlobalUser() - } - }) </script> +<!-- Only the raw-app branch is imported statically: everything else waits for app.css and + its own chunk, see publicAppApi.ts. --> {#if !inWorkspace} - <div - class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted() - ? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100' - : ''}" - > - <a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center" - >Powered by  <WindmillIcon /> Windmill</a + {#await loadAppCss() then} + <div + class="z-50 text-xs fixed bottom-1 right-2 {license && !isCloudHosted() + ? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100' + : ''}" > - </div> + <a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center" + >Powered by  <WindmillIcon /> Windmill</a + > + </div> + {/await} {/if} -{#if notExists} - <div class="px-4 mt-20" - ><Alert type="error" title="Not found" - >There was an error loading the app, is the url correct? <a href={base}>Go to Windmill</a> - </Alert></div - > -{:else if noPermission} - {#if guestAppPath && !$userStore} - <div class="px-4 mt-20 w-full text-center font-bold text-xl"> Sign in to open this app </div> - <div class="text-center mt-8 text-sm text-primary"> - You do not need a Windmill account. Signing in lets you open this app and nothing else. - </div> - {:else} - <div class="px-4 mt-20 w-full text-center font-bold text-xl"> - This app requires read access - </div> - <div class="text-center mt-8 text-sm text-primary"> - {#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace} - You are logged in but are not a member of the workspace <span class="text-xl font-bold" - >{effectiveWorkspace}</span - > this app is part of - {:else}You must be logged in and have read access to this app{/if}</div - > - {/if} - <div class="px-2 mx-auto mt-20 max-w-xl w-full"> - {#if !jwtError} - <Login - {onLoginSuccess} - popup - guestApp={guestAppPath} - rd={page.url.pathname + page.url.search + page.url.hash} - /> - {/if} - </div> +{#if notExists || noPermission} + {#await Promise.all([loadAppCss(), import('./PublicAppAccess.svelte')]) then [, { default: PublicAppAccess }]} + <PublicAppAccess + {notExists} + {jwtError} + {guestAppPath} + {user} + workspace={effectiveWorkspace} + {onLoginSuccess} + /> + {/await} {:else if app} {#key app} {#if app.raw_app && effectiveWorkspace} - <RawAppPreview - workspace={effectiveWorkspace} - user={ctxUser} - secret={app.bundle_secret} - path={app.path} - runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>} - {syncHashToUrl} - /> + <!-- The bundle's iframe is full-height, so every host has to give this box a height to + resolve against: in the workspace the layout hands one down, and the share, embed + and custom-path routes floor themselves at the viewport. An iframe with no height + above it falls back to 150px. Styled inline: app.css may not be loaded here. --> + <div style="height: 100%; width: 100%"> + <RawAppPreview + workspace={effectiveWorkspace} + {user} + secret={app.bundle_secret} + path={app.path} + runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>} + {syncHashToUrl} + /> + </div> {:else if app.raw_app && !effectiveWorkspace} - <div class="px-4 mt-20"> - <Alert type="error" title="Configuration error"> - Unable to load raw app: workspace information is missing. - </Alert> - </div> + {#await Promise.all([loadAppCss(), import('$lib/components/common/alert/Alert.svelte')]) then [, { default: Alert }]} + <div class="px-4 mt-20"> + <Alert type="error" title="Configuration error"> + Unable to load raw app: workspace information is missing. + </Alert> + </div> + {/await} {:else} - <div - class={twMerge( - // `flex-col` matches the pre-sandbox in-workspace viewer exactly; - // the public viewer always used a plain `flex` wrapper. - inWorkspace - ? 'min-h-screen h-full w-full flex flex-col' - : 'min-h-screen h-full w-full flex', - app?.value?.['css']?.['app']?.['viewer']?.class, - 'wm-app-viewer' - )} - style={app?.value?.['css']?.['app']?.['viewer']?.style} - > - {#await loadAppPreview()} - <Skeleton layout={[[4], 0.5, [50]]} /> - {:then Module} - <Module.default - noBackend={false} - {hideRefreshBar} - context={{ - email: ctxUser?.email, - name: ctxUser?.name, - groups: ctxUser?.groups, - username: ctxUser?.username, - query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), - hash: page.url.hash.substring(1) - }} - workspace={effectiveWorkspace} - summary={app.summary} - app={app.value} - appPath={app.path} - {breakpoint} - policy={app.policy} - isEditor={false} - replaceStateFn={(path) => goto(path)} - gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} - /> - {:catch} - <div class="px-4 mt-20 w-full"> - <Alert type="error" title="Could not load the app">Reload the page to try again.</Alert> - </div> - {/await} - </div> + {#await Promise.all([loadAppCss(), import('./PublicLowCodeApp.svelte')]) then [, { default: PublicLowCodeApp }]} + <PublicLowCodeApp + {app} + workspace={effectiveWorkspace} + {user} + {license} + {inWorkspace} + {hideRefreshBar} + /> + {/await} {/if} {/key} {:else} - <Skeleton layout={[[4], 0.5, [50]]} /> + {#await loadAppCss() then} + <Skeleton layout={[[4], 0.5, [50]]} /> + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte new file mode 100644 index 0000000000..5e1d80b893 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte @@ -0,0 +1,80 @@ +<script lang="ts"> + import { page } from '$app/state' + import Login from '$lib/components/Login.svelte' + import Alert from '$lib/components/common/alert/Alert.svelte' + import { UserService, type GlobalWhoamiResponse } from '$lib/gen' + import type { UserExt } from '$lib/stores' + import { dropPrefetched } from './publicAppApi' + + let { + notExists, + jwtError, + guestAppPath, + user, + workspace, + onLoginSuccess + }: { + notExists: boolean + jwtError: boolean + guestAppPath: string | undefined + user: UserExt | undefined + workspace: string | undefined + onLoginSuccess: () => void + } = $props() + + let globalUser = $state<GlobalWhoamiResponse | undefined>(undefined) + async function loadGlobalUser() { + try { + globalUser = await UserService.globalWhoami() + } catch (error) { + console.error(error) + } + } + + // Only the no-access page reads it, to tell a signed-in non-member which + // workspace the app belongs to. + $effect(() => { + if (!notExists && !guestAppPath && !user && !globalUser) { + loadGlobalUser() + } + }) +</script> + +{#if notExists} + <div class="px-4 mt-20" + ><Alert type="error" title="Not found" + >There was an error loading the app, is the url correct? <a href="/">Go to Windmill</a> + </Alert></div + > +{:else} + {#if guestAppPath && !user} + <div class="px-4 mt-20 w-full text-center font-bold text-xl"> Sign in to open this app </div> + <div class="text-center mt-8 text-sm text-primary"> + You do not need a Windmill account. Signing in lets you open this app and nothing else. + </div> + {:else} + <div class="px-4 mt-20 w-full text-center font-bold text-xl"> + This app requires read access + </div> + <div class="text-center mt-8 text-sm text-primary"> + {#if user}You are logged in but have no read access to this app{:else if globalUser && workspace} + You are logged in but are not a member of the workspace <span class="text-xl font-bold" + >{workspace}</span + > this app is part of + {:else}You must be logged in and have read access to this app{/if}</div + > + {/if} + <div class="px-2 mx-auto mt-20 max-w-xl w-full"> + {#if !jwtError} + <Login + onLoginSuccess={() => { + dropPrefetched() + onLoginSuccess() + }} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> + {/if} + </div> +{/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index e994767488..eacb3652e1 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -23,19 +23,42 @@ * bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`. */ import { BROWSER } from 'esm-env' - import { OpenAPI, UserService } from '$lib/gen' + import { OpenAPI } from '$lib/gen/core/OpenAPI' import { page } from '$app/state' import { onDestroy, onMount, setContext, type Snippet } from 'svelte' - import Alert from '$lib/components/common/alert/Alert.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' - import { base } from '$app/paths' import { goto } from '$app/navigation' - import Login from '$lib/components/Login.svelte' - import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils' + import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils/queryParams' import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types' import { loadAppPreview } from './loadAppPreview' - import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte' - import { hasStoredSdkConsent, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes' + import { dropPrefetched, loadAppCss } from './publicAppApi' + import { + hasStoredSdkConsent, + sdkConsentCovers, + storeSdkConsent + } from '$lib/components/raw_apps/sdkScopes' + + // Everything but a rendered app (sign-in, errors, the SDK consent prompt) loads on + // demand: a raw app that renders right away needs none of it. See publicAppApi.ts. + let frameUi: + | Promise<{ + Alert: typeof import('$lib/components/common/alert/Alert.svelte').default + Login: typeof import('$lib/components/Login.svelte').default + RawAppSdkConsent: typeof import('$lib/components/raw_apps/RawAppSdkConsent.svelte').default + }> + | undefined + function loadFrameUi() { + return (frameUi ??= Promise.all([ + loadAppCss(), + import('$lib/components/common/alert/Alert.svelte'), + import('$lib/components/Login.svelte'), + import('$lib/components/raw_apps/RawAppSdkConsent.svelte') + ]).then(([, alert, login, consent]) => ({ + Alert: alert.default, + Login: login.default, + RawAppSdkConsent: consent.default + }))) + } type EmbedToken = { token?: string | null @@ -231,7 +254,8 @@ if (offerSignIn && accountSession === 'unknown') { // Workspace-less, so it answers for an account and never for a guest // (pinned to its workspace) or for nobody. - UserService.getCurrentEmail() + import('$lib/gen') + .then(({ UserService }) => UserService.getCurrentEmail()) .then(() => (accountSession = 'held')) .catch(() => (accountSession = 'none')) } @@ -245,8 +269,12 @@ let staleGuestLogoutFailed = $state(false) $effect(() => { if (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared) { - UserService.logout() - .then(() => (staleGuestCleared = true)) + import('$lib/gen') + .then(({ UserService }) => UserService.logout()) + .then(() => { + dropPrefetched() + staleGuestCleared = true + }) .catch(() => (staleGuestLogoutFailed = true)) } }) @@ -388,7 +416,7 @@ appPath = resp.app_path ?? appPath workspaceId = resp.workspace_id ?? workspaceId const granted = resp.sdk_scopes ?? [] - if (!granted.every((s) => approved.includes(s))) { + if (!sdkConsentCovers(approved, granted)) { // The app was redeployed with more scopes between the prompt and the // mint. Never inject a token carrying scopes the viewer wasn't shown: // drop it and ask again with the new set. @@ -423,7 +451,10 @@ // Render the app directly on this origin: same-origin when unsandboxed // (the default), or a single opaque bundle iframe when it's a sandboxed // raw app. A low-code app's runtime downloads alongside the app payload. - if (!isRaw) loadAppPreview().catch(() => {}) + if (!isRaw) { + loadAppCss().catch(() => {}) + loadAppPreview().catch(() => {}) + } onViewerReady?.(undefined, requestTokenRefresh) } else { // Sandboxed low-code: hand the scoped token to the opaque viewer iframe. @@ -533,6 +564,7 @@ onMount(() => { if (isViewer) { // Only a sandboxed low-code app is ever framed as a viewer. + loadAppCss().catch(() => {}) loadAppPreview().catch(() => {}) window.addEventListener('message', handleViewerMessage) installHashRelay() @@ -565,105 +597,120 @@ {#if viewerReady} {@render viewer()} {:else if viewerOrphaned} - <div class="px-4 mt-20 max-w-xl mx-auto"> - <Alert type="info" title="Open this app from Windmill"> - This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own - page, use the app's public URL without the <code>wm_embed</code> parameter. - </Alert> - </div> + {#await loadFrameUi() then { Alert }} + <div class="px-4 mt-20 max-w-xl mx-auto"> + <Alert type="info" title="Open this app from Windmill"> + This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your + own page, use the app's public URL without the <code>wm_embed</code> parameter. + </Alert> + </div> + {/await} {:else} - <Skeleton layout={[[4], 0.5, [50]]} /> + {#await loadAppCss() then} + <Skeleton layout={[[4], 0.5, [50]]} /> + {/await} {/if} {:else if status === 'loading'} - <Skeleton layout={[[4], 0.5, [50]]} /> -{:else if status === 'notExists' && !offerSignIn} - <div class="px-4 mt-20"> - <Alert type="error" title="Not found"> - There was an error loading the app, is the url correct? - <a href={base}>Go to Windmill</a> - </Alert> - </div> -{:else if status === 'sdkPrompt'} - <!-- Raw-app frontend SDK: ask before the app's code runs, so the viewer sees - what it will be able to do with their identity. --> - <RawAppSdkConsent - scopes={sdkScopes ?? []} - onContinue={onSdkConsentContinue} - onDecline={onSdkConsentDecline} - /> -{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} - <Skeleton layout={[[4], 0.5, [50]]} /> -{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} - <div class="px-4 mt-20"> - <Alert type="error" title="Could not check access"> - The app could not be reached to find out who may open it. Reload to try again. - </Alert> - </div> -{:else if offerSignIn} - <!-- Login happens here, on the embedder (main) window, so the session cookie - is set on the main origin only and never reaches the opaque iframe. --> - {#if signInDidNotHelp} - <!-- Offering the same sign-in again would loop: they are signed in, and this - app still will not open for them. Say why and stop. --> - <div class="px-4 mt-20 w-full text-center font-bold text-xl"> - You are signed in, but this app is not open to you - </div> - <div class="text-center mt-8 text-sm text-primary"> - It is open to the people it was shared with{guestAppPath - ? ', and to guests who have no Windmill account' - : ''}. Ask the person who shared it to give your account access. - </div> + {#await loadAppCss() then} + <Skeleton layout={[[4], 0.5, [50]]} /> + {/await} +{:else if status === 'ready'} + {#if unsandboxed} + <!-- Same-origin (full session): the app was not opted into sandbox isolation + (the default). Rendered directly here; RawAppPreview reads + IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. --> + {@render viewer()} + {:else if isRaw} + <!-- Variant A: sandboxed raw app rendered directly on the real origin. The + untrusted author bundle stays isolated in its own opaque iframe (inside + RawAppPreview); no opaque viewer and no embed token are needed. --> + {@render viewer()} {:else} - {#if guestAppPath} - <div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div> - <div class="text-center mt-8 text-sm text-primary"> - You do not need a Windmill account. Signing in lets you open this app and nothing else. - </div> - {:else} - <div class="px-4 mt-20 w-full text-center font-bold text-xl"> - This app requires read access - </div> - {/if} - {#if signInError} - <div class="px-2 mx-auto mt-8 max-w-xl w-full"> - <Alert type="error" title="Could not sign you in">{signInError}</Alert> - </div> - {/if} - <div class="px-2 mx-auto mt-20 max-w-xl w-full"> - <Login - onLoginSuccess={() => { - signInError = undefined - accountSession = 'unknown' - initEmbedder() - }} - onLoginError={(message) => (signInError = message)} - popup - guestApp={guestAppPath} - rd={page.url.pathname + page.url.search + page.url.hash} - /> - </div> + <!-- referrerpolicy: the embedder page URL can carry a viewer credential (the + JWT path segment of share links); without this, the same-origin iframe + navigation would expose it to app-authored code via document.referrer. + Styled inline: it can render before app.css has loaded. --> + <iframe + bind:this={iframeEl} + src={buildViewerUrl()} + title="App" + style="display: block; width: 100%; height: 100%; border: 0" + sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation" + allow="clipboard-read; clipboard-write; fullscreen" + referrerpolicy="no-referrer" + ></iframe> {/if} -{:else if unsandboxed} - <!-- Same-origin (full session): the app was not opted into sandbox isolation - (the default). Rendered directly here; RawAppPreview reads - IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. --> - {@render viewer()} -{:else if isRaw} - <!-- Variant A: sandboxed raw app rendered directly on the real origin. The - untrusted author bundle stays isolated in its own opaque iframe (inside - RawAppPreview); no opaque viewer and no embed token are needed. --> - {@render viewer()} {:else} - <!-- referrerpolicy: the embedder page URL can carry a viewer credential (the - JWT path segment of share links); without this, the same-origin iframe - navigation would expose it to app-authored code via document.referrer. --> - <iframe - bind:this={iframeEl} - src={buildViewerUrl()} - title="App" - class="w-full h-screen border-0 block" - sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation" - allow="clipboard-read; clipboard-write; fullscreen" - referrerpolicy="no-referrer" - ></iframe> + {#await loadFrameUi() then { Alert, Login, RawAppSdkConsent }} + {#if status === 'notExists' && !offerSignIn} + <div class="px-4 mt-20"> + <Alert type="error" title="Not found"> + There was an error loading the app, is the url correct? + <a href="/">Go to Windmill</a> + </Alert> + </div> + {:else if status === 'sdkPrompt'} + <!-- Raw-app frontend SDK: ask before the app's code runs, so the viewer sees + what it will be able to do with their identity. --> + <RawAppSdkConsent + scopes={sdkScopes ?? []} + onContinue={onSdkConsentContinue} + onDecline={onSdkConsentDecline} + /> + {:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} + <Skeleton layout={[[4], 0.5, [50]]} /> + {:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} + <div class="px-4 mt-20"> + <Alert type="error" title="Could not check access"> + The app could not be reached to find out who may open it. Reload to try again. + </Alert> + </div> + {:else if offerSignIn} + <!-- Login happens here, on the embedder (main) window, so the session cookie + is set on the main origin only and never reaches the opaque iframe. --> + {#if signInDidNotHelp} + <!-- Offering the same sign-in again would loop: they are signed in, and this + app still will not open for them. Say why and stop. --> + <div class="px-4 mt-20 w-full text-center font-bold text-xl"> + You are signed in, but this app is not open to you + </div> + <div class="text-center mt-8 text-sm text-primary"> + It is open to the people it was shared with{guestAppPath + ? ', and to guests who have no Windmill account' + : ''}. Ask the person who shared it to give your account access. + </div> + {:else} + {#if guestAppPath} + <div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div + > + <div class="text-center mt-8 text-sm text-primary"> + You do not need a Windmill account. Signing in lets you open this app and nothing else. + </div> + {:else} + <div class="px-4 mt-20 w-full text-center font-bold text-xl"> + This app requires read access + </div> + {/if} + {#if signInError} + <div class="px-2 mx-auto mt-8 max-w-xl w-full"> + <Alert type="error" title="Could not sign you in">{signInError}</Alert> + </div> + {/if} + <div class="px-2 mx-auto mt-20 max-w-xl w-full"> + <Login + onLoginSuccess={() => { + dropPrefetched() + signInError = undefined + accountSession = 'unknown' + initEmbedder() + }} + onLoginError={(message) => (signInError = message)} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> + </div> + {/if} + {/if} + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte new file mode 100644 index 0000000000..f74be67166 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte @@ -0,0 +1,91 @@ +<script lang="ts"> + import { goto } from '$app/navigation' + import { page } from '$app/state' + import Alert from '$lib/components/common/alert/Alert.svelte' + import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' + import type { AppWithLastVersion } from '$lib/gen' + import { enterpriseLicense, userStore, type UserExt } from '$lib/stores' + import { urlParamsToObject } from '$lib/utils' + import { getContext } from 'svelte' + import { writable } from 'svelte/store' + import { twMerge } from 'tailwind-merge' + import { EMBED_NAV_CONTEXT_KEY, type EditorBreakpoint, type EmbedNav } from '../types' + import { loadAppPreview } from './loadAppPreview' + + let { + app, + workspace, + user, + license, + inWorkspace, + hideRefreshBar + }: { + app: AppWithLastVersion & { value: any } + workspace: string | undefined + user: UserExt | undefined + license: string | undefined + inWorkspace: boolean + hideRefreshBar: boolean + } = $props() + + // The public routes hold the viewer and license as props, while low-code components + // read them from the stores (the license gates the app's custom CSS, for one). + $effect(() => { + if (inWorkspace) return + userStore.set(user) + if (license) enterpriseLicense.set(license) + }) + + // WIN-2006: inside the opaque viewer iframe, navigations to other routes + // (navbar "app" items) must happen on the TOP page — the iframe is cookieless, + // so navigating it would just show a login screen. PublicAppFrame provides the + // relay; outside the opaque viewer this is undefined and goto works directly. + const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY) + + const breakpoint = writable<EditorBreakpoint>('lg') +</script> + +<div + class={twMerge( + // `flex-col` matches the pre-sandbox in-workspace viewer exactly; + // the public viewer always used a plain `flex` wrapper. `min-h-full`, not + // `h-full`: the box has to reach the bottom of what the host gives it so the + // app's own background covers the window, and still grow with a grid taller + // than that. A viewport floor instead would overhang the page box by the + // height of the page header band. + inWorkspace ? 'min-h-full w-full flex flex-col' : 'min-h-full w-full flex', + app?.value?.['css']?.['app']?.['viewer']?.class, + 'wm-app-viewer' + )} + style={app?.value?.['css']?.['app']?.['viewer']?.style} +> + {#await loadAppPreview()} + <Skeleton layout={[[4], 0.5, [50]]} /> + {:then Module} + <Module.default + noBackend={false} + {hideRefreshBar} + context={{ + email: user?.email, + name: user?.name, + groups: user?.groups, + username: user?.username, + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), + hash: page.url.hash.substring(1) + }} + {workspace} + summary={app.summary} + app={app.value} + appPath={app.path} + {breakpoint} + policy={app.policy} + isEditor={false} + replaceStateFn={(path) => goto(path)} + gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} + /> + {:catch} + <div class="px-4 mt-20 w-full"> + <Alert type="error" title="Could not load the app">Reload the page to try again.</Alert> + </div> + {/await} +</div> diff --git a/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts b/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts index f799cb864a..5c22e3f475 100644 --- a/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts +++ b/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts @@ -1,7 +1,6 @@ -import { base } from '$lib/base' import { workspaceStore } from '$lib/stores' import { get } from 'svelte/store' export function computeSecretUrl(secretUrl: string) { - return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}` + return `${window.location.origin}/public/${get(workspaceStore)}/${secretUrl}` } diff --git a/frontend/src/lib/components/apps/editor/commonAppUtils.ts b/frontend/src/lib/components/apps/editor/commonAppUtils.ts index 564a94e9b1..b384fafce5 100644 --- a/frontend/src/lib/components/apps/editor/commonAppUtils.ts +++ b/frontend/src/lib/components/apps/editor/commonAppUtils.ts @@ -17,6 +17,8 @@ export type TriggerableV2 = { delete_after_secs?: number sensitive_inputs?: string[] tag?: string + /** Data table connections naming no role are made as the app's viewer. Inline runnables only. */ + viewer_data_access?: boolean } export async function hash(message) { diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte index a4bed4affb..1d3d46fc35 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte @@ -15,6 +15,7 @@ import WorkspaceScriptList from '../settingsPanel/mainInput/WorkspaceScriptList.svelte' import RunnableSelector from '../settingsPanel/mainInput/RunnableSelector.svelte' import { defaultScripts } from '$lib/stores' + import { useOperatorBuilderApps } from '$lib/operatorWriteRights' import DefaultScripts from '$lib/components/DefaultScripts.svelte' import type { Preview } from '$lib/gen' import type { InlineScript } from '../../sharedTypes' @@ -38,6 +39,7 @@ let picker: Drawer | undefined = $state(undefined) const dispatch = createEventDispatcher() + const operatorBuilderApps = useOperatorBuilderApps() async function inferInlineScriptSchema( language: Preview['language'], @@ -104,7 +106,9 @@ <Tabs bind:selected={tab}> <Tab value="workspacescripts" label="Workspace Scripts" icon={Building} /> - <Tab value="hubscripts" label="Hub Scripts" icon={Globe2} /> + {#if !$operatorBuilderApps} + <Tab value="hubscripts" label="Hub Scripts" icon={Globe2} /> + {/if} </Tabs> <div class="my-2"></div> <div class="flex flex-col gap-y-16"> @@ -123,21 +127,25 @@ <div class="flex flex-col px-4 gap-2 text-sm" id="app-editor-empty-runnable"> <div class="mt-2 flex justify-between gap-4" id="app-editor-runnable-header"> - <div class="font-bold items-baseline truncate">Choose a language</div> + <div class="font-bold items-baseline truncate"> + {$operatorBuilderApps ? 'Choose a script or flow' : 'Choose a language'} + </div> <div class="flex gap-2"> {#if showScriptPicker} <RunnableSelector {unusedInlineScripts} {rawApps} on:pick hideCreateScript /> {/if} - <Button - on:click={() => picker?.openDrawer()} - size="xs" - variant="border" - color="light" - startIcon={{ icon: GitFork }} - btnClasses="truncate" - > - Fork other script - </Button> + {#if !$operatorBuilderApps} + <Button + on:click={() => picker?.openDrawer()} + size="xs" + variant="border" + color="light" + startIcon={{ icon: GitFork }} + btnClasses="truncate" + > + Fork other script + </Button> + {/if} <Button on:click={() => dispatch('delete')} @@ -151,7 +159,9 @@ </div> </div> - <div class="flex flex-row w-full gap-8"> + <!-- Operators with builder rights compose runnables that already exist: the language pickers + below all author code, which the backend refuses from them. --> + <div class="flex flex-row w-full gap-8" class:hidden={$operatorBuilderApps}> <div id="app-editor-backend-runnables"> <div class="mb-1 text-sm font-semibold flex gap-4">Backend <DefaultScripts /> </div> diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptEditor.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptEditor.svelte index b1af1c20f7..0f62f0b4cd 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptEditor.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptEditor.svelte @@ -6,7 +6,8 @@ import type { Preview } from '$lib/gen' import { createEventDispatcher, getContext, onMount, untrack } from 'svelte' import type { AppViewerContext } from '../../types' - import { Maximize2, Trash2 } from 'lucide-svelte' + import { Maximize2, Paintbrush, Trash2 } from 'lucide-svelte' + import { getModifierKey } from '$lib/utils' import InlineScriptEditorDrawer from './InlineScriptEditorDrawer.svelte' import { inferArgs, parseOutputs } from '$lib/infer' import type { Schema } from '$lib/common' @@ -305,18 +306,17 @@ {/if} <Button - variant="default" + title={`Format (${getModifierKey()}S)`} + aria-label="Format" + variant="subtle" unifiedSize="sm" on:click={async () => { editor?.format() simpleEditor?.format() }} - shortCut={{ - key: 'S' - }} - > - Format - </Button> + endIcon={{ icon: Paintbrush }} + iconOnly + /> <RunButton bind:runLoading {id} inlineScript={!transformer ? inlineScript : undefined} /> </div> </div> diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte index 293d83b4ae..45dbe82c14 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte @@ -3,7 +3,6 @@ const bubble = createBubbler() import { Button, Drawer, DrawerContent } from '$lib/components/common' - import { base } from '$lib/base' import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import FlowModuleScript from '$lib/components/flows/content/FlowModuleScript.svelte' @@ -19,6 +18,7 @@ CtxAppInput } from '../../inputType' import type { AppViewerContext } from '../../types' + import { useOperatorBuilderApps } from '$lib/operatorWriteRights' import { createEventDispatcher } from 'svelte' import { deepEqual } from 'fast-equals' import { computeFields } from './utils' @@ -37,6 +37,7 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderApps = useOperatorBuilderApps() interface Props { runnable: RunnableByPath @@ -325,7 +326,7 @@ startIcon={{ icon: GitFork }} endIcon={{ icon: ExternalLink }} target="_blank" - href="{base}/flows/add?hub={hubFlowId}" + href="/flows/add?hub={hubFlowId}" > Fork </Button> @@ -346,7 +347,7 @@ startIcon={{ icon: Eye }} endIcon={{ icon: ExternalLink }} target="_blank" - href="{base}/flows/get/{runnable.path}?workspace={$operatingWorkspace}" + href="/flows/get/{runnable.path}?workspace={$operatingWorkspace}" > Details </Button> @@ -362,16 +363,18 @@ > Edit </Button> - <Button - size="xs" - variant="default" - startIcon={{ icon: GitFork }} - on:click={() => { - fork(runnable.path) - }} - > - Fork - </Button> + {#if !$operatorBuilderApps} + <Button + size="xs" + variant="default" + startIcon={{ icon: GitFork }} + on:click={() => { + fork(runnable.path) + }} + > + Fork + </Button> + {/if} {/if} <Popover floatingConfig={{ diff --git a/frontend/src/lib/components/apps/editor/publicAppApi.ts b/frontend/src/lib/components/apps/editor/publicAppApi.ts new file mode 100644 index 0000000000..674cb0b622 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/publicAppApi.ts @@ -0,0 +1,82 @@ +// The public app routes (`/public/...`, `/a/...`) render a raw app as little more than its +// bundle's iframe, so they must not statically import the generated client, `$lib/stores` +// or `$lib/utils`: each drags in most of the app shell. `vite.config.js` +// (`assertLeanPublicAppRoutes`) fails the build if they do. Anything heavier is imported +// lazily by the branch that needs it. +import { OpenAPI } from '$lib/gen/core/OpenAPI' +import type { AppWithLastVersion, User } from '$lib/gen/types.gen' +import type { UserExt } from '$lib/stores' +import { mapUserToUserExt } from '$lib/userExt' + +export type PublicAppValue = AppWithLastVersion & { value: any; workspace_id?: string } + +declare global { + interface Window { + /** Responses `app.html` requested before any module loaded, keyed by URL. */ + __wmPublicAppPrefetch?: Record<string, Promise<Response>> + } +} + +/** Carries `status` like the generated client's `ApiError`, which callers branch on. */ +export class PublicApiError extends Error { + constructor(public status: number) { + super(`Request failed with status ${status}`) + } +} + +/** Prefetched responses answer for the credential the page loaded with; call this when it + * changes (a sign-in or logout on the page) so later reads go to the network. */ +export function dropPrefetched(): void { + delete window.__wmPublicAppPrefetch +} + +function takePrefetched(url: string): Promise<Response> | undefined { + const prefetched = window.__wmPublicAppPrefetch + const res = prefetched?.[url] + if (res) delete prefetched![url] + return res +} + +/** GET an API path with the page credential: the bearer token in `OpenAPI.TOKEN` when the + * share link carries one, the session cookie otherwise. A prefetched response is only + * taken in the cookie case, which is the only credential `app.html` sends. */ +export async function publicGet<T>(path: string): Promise<T> { + const url = `${OpenAPI.BASE}${path}` + const token = typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined + const res = await ((!token && takePrefetched(url)) || + fetch(url, { + // As the generated client does: the opaque viewer frame is cross-origin to the + // API, whose `Access-Control-Allow-Origin: *` refuses a credentialed request. + credentials: OpenAPI.WITH_CREDENTIALS ? OpenAPI.CREDENTIALS : undefined, + headers: token ? { Authorization: `Bearer ${token}` } : {} + })) + if (!res.ok) throw new PublicApiError(res.status) + return (res.headers.get('content-type')?.includes('json') ? res.json() : res.text()) as T +} + +export async function getUserExtOrUndefined(workspace: string): Promise<UserExt | undefined> { + try { + return mapUserToUserExt( + await publicGet<User>(`/w/${encodeURI(workspace)}/users/whoami`), + workspace + ) + } catch { + return undefined + } +} + +/** Only fades the "Powered by" badge, so a failure is not worth surfacing. */ +export async function getLicenseOrUndefined(): Promise<string | undefined> { + try { + return (await publicGet<string>('/ee_license')) || undefined + } catch { + return undefined + } +} + +let appCss: Promise<unknown> | undefined +/** The app-wide stylesheet, which these routes need only off the raw-app path: sign-in, + * errors, skeletons and low-code apps. A raw app's bundle brings its own styles. */ +export function loadAppCss(): Promise<unknown> { + return (appCss ??= import('$lib/assets/app.css')) +} diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte index 08b8ac6219..44f2adb9ac 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte @@ -12,8 +12,10 @@ import { emptySchema } from '$lib/utils' import { loadSchema } from '$lib/infer' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useOperatorBuilderApps } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderApps = useOperatorBuilderApps() import { buildPathRunnableSelection } from './runnableSelectorUtils' type TabType = 'hubscripts' | 'hubflows' | 'workspacescripts' | 'workspaceflows' | 'inlinescripts' @@ -163,10 +165,12 @@ <Tab value="workspacescripts" label="Workspace Scripts" icon={Building} /> {/if} <Tab value="workspaceflows" label="Workspace Flows" icon={Building} /> - {#if !onlyFlow} - <Tab value="hubscripts" label="Hub Scripts" icon={Globe2} /> + {#if !$operatorBuilderApps} + {#if !onlyFlow} + <Tab value="hubscripts" label="Hub Scripts" icon={Globe2} /> + {/if} + <Tab value="hubflows" label="Hub Flows" icon={Globe2} /> {/if} - <Tab value="hubflows" label="Hub Flows" icon={Globe2} /> </Tabs> <div class="my-2"></div> <div class="flex flex-col gap-y-16"> diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 75de2d7faf..a72d54507e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -188,6 +188,8 @@ export interface AppEditorProps { // with its author and time. `version` and `head` differ when another deploy landed // beside this one, which is when the out-of-date prompt has something to say. onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } export type App = { diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte new file mode 100644 index 0000000000..49a222148b --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte @@ -0,0 +1,150 @@ +<!-- +@component +The menu that adds a pipeline script started by a data source (a schedule, a +webhook, a queue…): the add node's, and the canvas's right-click one. +--> +<script lang="ts"> + import type { Snippet } from 'svelte' + import PipelineInsertMenu, { + type PipelineInsertOptions, + type PipelineInsertPick + } from './PipelineInsertMenu.svelte' + import { + Clock, + Webhook, + Mail, + Zap, + Radio, + MessageSquare, + Database, + Send, + CloudCog, + Upload + } from 'lucide-svelte' + import type { ScriptLang } from '$lib/gen' + import type { NativeTriggerKind } from './types' + import { PIPELINE_LANGUAGES } from './pipelineLanguages' + import type { PipelineOutputKind } from './pipelineTemplates' + + interface Props { + onAddPipelineScript: ( + language: ScriptLang, + path: string, + source: { kind: NativeTriggerKind; path: string | undefined }, + outputKind: PipelineOutputKind, + aiPrompt?: string, + options?: PipelineInsertOptions + ) => void + pathPrefix: string + defaultPathSuffix: string + trigger: Snippet<[{ open: boolean }]> + /** Opens the menu each time it changes. */ + openSignal?: number + } + let { + onAddPipelineScript, + pathPrefix, + defaultPathSuffix, + trigger, + openSignal + }: Props = $props() + + function handlePick(pick: PipelineInsertPick) { + if (!pick.language || !pick.path) return + // Native trigger annotation is marker-only — the binding lives on the + // trigger row's own `script_path`, which is created separately. + onAddPipelineScript( + pick.language as ScriptLang, + pick.path, + { kind: pick.kindId as NativeTriggerKind, path: undefined }, + (pick.outputKind ?? 'none') as PipelineOutputKind, + pick.aiPrompt, + { schedule: pick.schedule, outputAsset: pick.outputAsset } + ) + } +</script> + +<PipelineInsertMenu + kinds={[ + { + id: 'data_upload', + label: 'On data upload', + description: 'UI-first: run by uploading a file via the S3 picker', + icon: Upload + }, + { + id: 'schedule', + label: 'On schedule', + description: 'Triggered by a schedule you create', + icon: Clock + }, + { + id: 'webhook', + label: 'On webhook', + description: 'Triggered by an HTTP webhook', + icon: Webhook + }, + { + id: 'email', + configuredAfterCreate: true, + label: 'On email', + description: 'Triggered by incoming email', + icon: Mail + }, + { + id: 'kafka', + configuredAfterCreate: true, + label: 'On Kafka', + description: 'Triggered by a Kafka message', + icon: Zap + }, + { + id: 'mqtt', + configuredAfterCreate: true, + label: 'On MQTT', + description: 'Triggered by an MQTT message', + icon: Radio + }, + { + id: 'amqp', + configuredAfterCreate: true, + label: 'On AMQP', + description: 'Triggered by an AMQP (RabbitMQ) message', + icon: Radio + }, + { + id: 'nats', + configuredAfterCreate: true, + label: 'On NATS', + description: 'Triggered by a NATS message', + icon: MessageSquare + }, + { + id: 'postgres', + configuredAfterCreate: true, + label: 'On Postgres', + description: 'Triggered by a Postgres event', + icon: Database + }, + { + id: 'sqs', + configuredAfterCreate: true, + label: 'On SQS', + description: 'Triggered by an SQS message', + icon: Send + }, + { + id: 'gcp', + configuredAfterCreate: true, + label: 'On GCP Pub/Sub', + description: 'Triggered by a Pub/Sub message', + icon: CloudCog + } + ]} + languages={PIPELINE_LANGUAGES as any} + {pathPrefix} + {defaultPathSuffix} + onPick={handlePick} + {openSignal} + {trigger} +/> diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte new file mode 100644 index 0000000000..2a54fe11d4 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte @@ -0,0 +1,73 @@ +<!-- +@component +The menu adding a pipeline script downstream of an asset: opened from the asset's +"+", or where that "+" was dropped on the blank canvas. +--> +<script lang="ts" module> + import type { ScriptLang } from '$lib/gen' + import type { AssetKind } from '$lib/components/assets/lib' + import type { PipelineInsertOptions } from './PipelineInsertMenu.svelte' + import type { PipelineOutputKind } from './pipelineTemplates' + + export type AddScriptForAsset = ( + asset: { kind: AssetKind; path: string }, + language: ScriptLang, + scriptPath: string, + outputKind: PipelineOutputKind, + aiPrompt?: string, + options?: PipelineInsertOptions + ) => void +</script> + +<script lang="ts"> + import { Code2 } from 'lucide-svelte' + import type { Snippet } from 'svelte' + import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte' + import { PIPELINE_LANGUAGES } from './pipelineLanguages' + + let { + asset, + onAddScript, + pathPrefix = '', + defaultPathSuffix = '', + openSignal, + trigger + }: { + asset: { kind: AssetKind; path: string } + onAddScript: AddScriptForAsset + pathPrefix?: string + defaultPathSuffix?: string + openSignal?: number + trigger: Snippet<[{ open: boolean }]> + } = $props() + + function handlePick(pick: PipelineInsertPick) { + if (pick.kindId === 'pipeline_script' && pick.language && pick.path) { + onAddScript( + asset, + pick.language as ScriptLang, + pick.path, + (pick.outputKind ?? 'none') as PipelineOutputKind, + pick.aiPrompt, + { outputAsset: pick.outputAsset } + ) + } + } +</script> + +<PipelineInsertMenu + kinds={[ + { + id: 'pipeline_script', + label: 'Add downstream pipeline script', + description: 'Triggered when this asset changes', + icon: Code2 + } + ]} + languages={PIPELINE_LANGUAGES as any} + {pathPrefix} + {defaultPathSuffix} + {openSignal} + onPick={handlePick} + {trigger} +/> diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte new file mode 100644 index 0000000000..8ebd24455c --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte @@ -0,0 +1,23 @@ +<!-- +@component +The "+" on an asset's bottom edge: a circle at rest, widened into a labelled pill +while its menu is open or while it is being dragged. The label animates its +max-width (width can't transition to auto) and never wraps. +--> +<script lang="ts"> + import { Plus } from 'lucide-svelte' + + let { expanded }: { expanded: boolean } = $props() +</script> + +<span + class="group/add h-6 min-w-6 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm leading-none overflow-hidden transition-[padding] duration-200 ease-out data-[open=true]:pl-1" + data-open={expanded} +> + <Plus size={16} strokeWidth={2.5} class="shrink-0" /> + <span + class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2" + > + Add downstream step + </span> +</span> diff --git a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte index 880abb685a..57a31e34d8 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte @@ -1,145 +1,36 @@ <script lang="ts"> - import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte' - import { - Plus, - Clock, - Webhook, - Mail, - Zap, - Radio, - MessageSquare, - Database, - Send, - CloudCog, - Upload - } from 'lucide-svelte' - import type { ScriptLang } from '$lib/gen' - import type { NativeTriggerKind } from './types' - import { PIPELINE_LANGUAGES } from './pipelineLanguages' - import type { PipelineOutputKind } from './pipelineTemplates' - - // Each left-column kind is just "pipeline script triggered by <trigger - // source>". id === the SCRIPT_TRIGGER_KIND value, so the handler can - // dispatch on it uniformly. Asset-triggered scripts are not in this - // menu; those live under the per-asset + inside the graph. - type KindId = NativeTriggerKind + import AddDataSourceMenu from './AddDataSourceMenu.svelte' + import { Plus } from 'lucide-svelte' + import { NODE } from '$lib/components/graph/util' interface Props { - data: { - onAddPipelineScript: ( - language: ScriptLang, - path: string, - source: { kind: NativeTriggerKind; path: string | undefined }, - outputKind: PipelineOutputKind, - aiPrompt?: string - ) => void - pathPrefix: string - defaultPathSuffix: string - } + data: Pick< + import('svelte').ComponentProps<typeof AddDataSourceMenu>, + 'onAddPipelineScript' | 'pathPrefix' | 'defaultPathSuffix' + > } let { data }: Props = $props() - - function handlePick(pick: PipelineInsertPick) { - if (!pick.language || !pick.path) return - const kindId = pick.kindId as KindId - const outputKind = (pick.outputKind ?? 'none') as PipelineOutputKind - // Native trigger annotation is marker-only — the binding lives on - // the trigger row's own `script_path`, which the user creates - // separately. Seed with `path: undefined`. - data.onAddPipelineScript( - pick.language as ScriptLang, - pick.path, - { kind: kindId, path: undefined }, - outputKind, - pick.aiPrompt - ) - } </script> -<PipelineInsertMenu - kinds={[ - { - id: 'data_upload', - label: 'On data upload', - description: 'UI-first: run by uploading a file via the S3 picker', - icon: Upload - }, - { - id: 'schedule', - label: 'On schedule', - description: 'Triggered by a schedule you create', - icon: Clock - }, - { - id: 'webhook', - label: 'On webhook', - description: 'Triggered by an HTTP webhook', - icon: Webhook - }, - { - id: 'email', - label: 'On email', - description: 'Triggered by incoming email', - icon: Mail - }, - { - id: 'kafka', - label: 'On Kafka', - description: 'Triggered by a Kafka message', - icon: Zap - }, - { - id: 'mqtt', - label: 'On MQTT', - description: 'Triggered by an MQTT message', - icon: Radio - }, - { - id: 'amqp', - label: 'On AMQP', - description: 'Triggered by an AMQP (RabbitMQ) message', - icon: Radio - }, - { - id: 'nats', - label: 'On NATS', - description: 'Triggered by a NATS message', - icon: MessageSquare - }, - { - id: 'postgres', - label: 'On Postgres', - description: 'Triggered by a Postgres event', - icon: Database - }, - { - id: 'sqs', - label: 'On SQS', - description: 'Triggered by an SQS message', - icon: Send - }, - { - id: 'gcp', - label: 'On GCP Pub/Sub', - description: 'Triggered by a Pub/Sub message', - icon: CloudCog - } - ]} - languages={PIPELINE_LANGUAGES as any} - pathPrefix={data.pathPrefix} - defaultPathSuffix={data.defaultPathSuffix} - onPick={handlePick} -> - {#snippet trigger()} - <!-- Quiet insert affordance, mirroring the flow editor's inline + - buttons (bg-surface + gray border + secondary text) — a filled - accent circle outweighed every real node on the canvas. --> - <button - type="button" - class="w-8 h-8 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer" - title="Add to pipeline" - > - <Plus size={16} /> - </button> - {/snippet} -</PipelineInsertMenu> +<!-- The layout gives this node a full node-width slot; centering the pill in + it lines it up with the nodes below whatever its label's width. --> +<div class="flex justify-center" style="width: {NODE.width}px;"> + <AddDataSourceMenu + onAddPipelineScript={data.onAddPipelineScript} + pathPrefix={data.pathPrefix} + defaultPathSuffix={data.defaultPathSuffix} + > + {#snippet trigger()} + <!-- Quiet insert affordance, mirroring the flow editor's inline + + buttons (bg-surface + gray border + secondary text) — a filled + accent pill would outweigh every real node on the canvas. --> + <button + type="button" + class="h-8 px-3 rounded-full flex items-center gap-1.5 whitespace-nowrap bg-surface border border-gray-400 dark:border-gray-600 text-xs font-normal text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer" + > + <Plus size={16} class="shrink-0" /> + Add data source + </button> + {/snippet} + </AddDataSourceMenu> +</div> diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 27f6ce6be7..87b085593c 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -1,8 +1,11 @@ <script lang="ts"> import '@xyflow/svelte/dist/base.css' + import { randomUUID } from '$lib/utils/uuid' + import { onMount } from 'svelte' import { SvelteFlow, Controls, + Panel, MiniMap, ConnectionLineType, type Node, @@ -13,13 +16,47 @@ import RunnableNode from './RunnableNode.svelte' import TriggerNode, { type TriggerNodeKind } from './TriggerNode.svelte' import AddNode from './AddNode.svelte' + import AddDataSourceMenu from './AddDataSourceMenu.svelte' + import PipelineInsertMenu, { type PipelineInsertSchedule } from './PipelineInsertMenu.svelte' + import AddDownstreamMenu from './AddDownstreamMenu.svelte' + import AddDownstreamPill from './AddDownstreamPill.svelte' import DataTestNode from './DataTestNode.svelte' import AssetGraphEdge from './AssetGraphEdge.svelte' import PanToNode from './PanToNode.svelte' import InitialFitView from './InitialFitView.svelte' - import { layoutAssetGraph } from './assetGraphLayout' + import { + layoutAssetGraph, + PIPELINE_NODE_EXTRA_ROW, + PIPELINE_NODE_HEADER, + PIPELINE_NODE_HEADER_CARD_PAD + } from './assetGraphLayout' + import { + assetsOnlyView, + upstreamDeletion, + withoutPassiveReads, + type AssetUpstream, + type AssetUpstreamDelete + } from './assetsOnlyView' + import { assetsOnlyNodeWidth } from './assetNodeWidth' + import { describeEdge } from './edgeDescription' + import { onDirectiveTargets } from './onDirectiveTargets' + import { listenChangeText, stopListeningChangeText } from './pipelineAnnotationEdits' + import { Button } from '$lib/components/common' + import type { NodeFix, NodeFixSpec } from './NodeFixButton.svelte' + import { isAssetsOnlyFolder, setAssetsOnlyFolder } from './assetsOnlyFolders' + import { formatAssetKind, formatShortAssetPath } from '$lib/components/assets/lib' + import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte' + import { describeSchedule } from '$lib/utils/describeCron' + import Toggle from '$lib/components/Toggle.svelte' + import { fly } from 'svelte/transition' + import { Clock, Plus } from 'lucide-svelte' + import { + getContextMenuContainerClass, + CONTEXT_MENU_ITEM_BASE_CLASS, + CONTEXT_MENU_ITEM_HOVER_CLASS + } from '$lib/components/common/contextmenu/contextMenuStyles' import { computeMutedReadKeys, dbtAssociations } from './resolveGraph' - import { buildDownstreamMap } from './graphTraversal' + import { buildDownstreamMap, hoverLineage, transitivelyImpliedEdges } from './graphTraversal' import { buildLineageDownstreamMap } from './boundedCascade' import type { AssetGraphResponse, @@ -31,16 +68,15 @@ import type { AssetKind } from '$lib/gen' import { NODE } from '$lib/components/graph/util' - // Width of the + node's rendered DOM element. Sugiyama allocates a full - // NODE.width slot for every node, so the small round button ends up - // left-aligned in its slot. We compensate by shifting the + node right - // by half the difference so its visual center matches the slot center. - const ADD_NODE_WIDTH = 40 - interface Props { graph: AssetGraphResponse selection?: AssetGraphSelection | undefined - onselect?: (selection: AssetGraphSelection | undefined) => void + /** `open` marks a selection asked to be shown (a script's open button), as + * opposed to a click on a node, which only selects it. */ + onselect?: ( + selection: AssetGraphSelection | undefined, + opts?: { open?: boolean } + ) => void // Called when the user clicks the per-asset + button (consumer-script // entry). Kept optional so the canvas stays usable outside the // pipeline editor. @@ -49,7 +85,8 @@ language: import('$lib/gen').ScriptLang, scriptPath: string, outputKind: import('./pipelineTemplates').PipelineOutputKind, - aiPrompt?: string + aiPrompt?: string, + options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions ) => void // Pipeline-wide + node shown at the top of the graph. Picking any // kind from the menu invokes this one callback with the chosen @@ -65,7 +102,8 @@ path: string | undefined }, outputKind: import('./pipelineTemplates').PipelineOutputKind, - aiPrompt?: string + aiPrompt?: string, + options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions ) => void // Folder-scoped prefix shown as a read-only chip in the insert menu // path input (e.g. `f/{folder}/`). Shared across top + and per-asset +. @@ -194,6 +232,32 @@ * canvas is embedded inline inside a scrollable container, so a wheel * gesture over it scrolls the container instead of being captured. */ scrollZoom?: boolean + /** Offer the "Show scripts and triggers" switch; off folds scripts and triggers into the + * assets they produce. */ + assetsOnlyToggle?: boolean + /** The pipeline's `<workspace>/<folder>`, when the toggle's state should be + * remembered for it. */ + assetsOnlyFolder?: string + /** Deletes what produces an asset in the assets-only view: its script, and its + * trigger when set. The canvas only offers what no other asset shares. */ + onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void + /** Canvas node id → what is wrong with it and how to fix it (the Fix pill). */ + nodeFixes?: ReadonlyMap<string, NodeFixSpec> + /** "Archive" for someone who can only archive the script the delete removes. */ + assetDeleteVerb?: 'Delete' | 'Archive' + /** Makes scripts run after each write to an asset: a script's (or, in the + * assets-only view, an asset's producers') top dot dragged onto that asset. + * `reads` says whether each script already reads it. */ + onAddAssetTrigger?: (a: { + scripts: Array<{ script: string; reads: boolean }> + asset: { kind: AssetKind; path: string } + }) => void + /** Makes scripts run after each write to an asset they read, or stop. `reads` + * decides whether stopping also mutes the asset. */ + onEdgeAction?: (a: { + action: 'listen' | 'stop' + targets: Array<{ script: string; asset: { kind: AssetKind; path: string }; reads: boolean }> + }) => void } let { graph, @@ -226,9 +290,347 @@ highlightActiveRun = false, recomputedAssetIds, assetRunStatus, - scrollZoom = true + scrollZoom = true, + assetsOnlyToggle = false, + assetsOnlyFolder, + onDeleteAssetUpstream, + nodeFixes, + assetDeleteVerb = 'Delete', + onEdgeAction, + onAddAssetTrigger }: Props = $props() + // Two drags write `// on`: a script's top dot onto an asset, and an asset's "+" + // onto a script. Only the nodes the drop can subscribe without a loop stay lit. + // The "+" can also be dropped on the blank canvas, opening its add menu there. + let onDrag = $state< + | { + sourceId: string + from: { x: number; y: number } + to: { x: number; y: number } + eligible: Set<string> + over: string | undefined + /** Dragging an asset's "+", whose blank-canvas drop adds a step. */ + feed: boolean + blank: boolean + } + | undefined + >(undefined) + // Hit-tested on the boxes rather than with elementFromPoint: svelte-flow can + // make nodes click-through while the pointer is held down. + const within = (el: Element, x: number, y: number) => { + const r = el.getBoundingClientRect() + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom + } + let flowEl: HTMLElement | undefined = $state() + const nodeUnder = (x: number, y: number) => + Array.from(flowEl?.querySelectorAll('.svelte-flow__node') ?? []) + .find((el) => within(el, x, y)) + ?.getAttribute('data-id') ?? undefined + const overBlank = (x: number, y: number) => + !!flowEl && + within(flowEl, x, y) && + !Array.from( + flowEl.querySelectorAll('.svelte-flow__panel, .svelte-flow__controls, .svelte-flow__minimap') + ).some((el) => within(el, x, y)) + const readsAsset = (assetId: string, scriptId: string) => + model.edges.some((x) => x.kind === 'lineage-read' && x.source === assetId && x.target === scriptId) + const assetOf = (assetId: string) => { + const a = model.nodes.find((n) => n.id === assetId)?.data + return a ? { kind: a.asset_kind as AssetKind, path: a.path as string } : undefined + } + function subscribe(assetId: string, scripts: string[]) { + const asset = assetOf(assetId) + if (!asset) return + onAddAssetTrigger?.({ + asset, + scripts: scripts.map((id) => ({ + script: id.replace(/^script:/, ''), + reads: readsAsset(assetId, id) + })) + }) + } + /** `threshold`: pixels the pointer must travel before it counts as a drag, so a + * plain click on the handle still clicks. */ + function trackDrag( + e: PointerEvent, + start: { sourceId: string; feed: boolean; eligible: () => Set<string>; threshold: number }, + drop: (target: string | undefined, at: { x: number; y: number }, blank: boolean) => void + ) { + const from = { x: e.clientX, y: e.clientY } + let started = false + const begin = () => { + started = true + onDrag = { + sourceId: start.sourceId, + from, + to: from, + eligible: start.eligible(), + over: undefined, + feed: start.feed, + blank: false + } + } + if (start.threshold === 0) begin() + const move = (ev: PointerEvent) => { + if (!started) { + if (Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < start.threshold) return + begin() + } + if (!onDrag) return + onDrag.to = { x: ev.clientX, y: ev.clientY } + onDrag.over = nodeUnder(ev.clientX, ev.clientY) + onDrag.blank = !onDrag.over && overBlank(ev.clientX, ev.clientY) + } + // A drag that ends without a release (pointer cancelled, window left) drops + // nothing, and must not leave its line following the cursor. + const teardown = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', cancel) + window.removeEventListener('blur', cancel) + document.removeEventListener('visibilitychange', cancel) + } + const cancel = () => { + teardown() + onDrag = undefined + } + const up = (ev: PointerEvent) => { + teardown() + const drag = onDrag + onDrag = undefined + if (!started || !drag) return + // The release is not a click on whatever it lands on. + const swallow = (c: MouseEvent) => { + c.stopPropagation() + c.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0) + const target = nodeUnder(ev.clientX, ev.clientY) + const at = { x: ev.clientX, y: ev.clientY } + drop(target && drag.eligible.has(target) ? target : undefined, at, !target && drag.blank) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', cancel) + window.addEventListener('blur', cancel) + document.addEventListener('visibilitychange', cancel) + } + function startOnDrag(sourceId: string, scripts: string[], e: PointerEvent) { + if (!onAddAssetTrigger || scripts.length === 0) return + trackDrag( + e, + { + sourceId, + feed: false, + threshold: 0, + eligible: () => { + const sets = scripts.map((id) => onDirectiveTargets(model.nodes, model.edges, id)) + return new Set([...sets[0]].filter((a) => sets.every((t) => t.has(a)))) + } + }, + (target) => { + if (target) subscribe(target, scripts) + } + ) + } + // Where a "+" dropped on the blank canvas opened its add menu. + let feedMenu = $state< + | { at: { x: number; y: number }; asset: { kind: AssetKind; path: string }; data: any } + | undefined + >(undefined) + let feedMenuSignal = $state(0) + function startFeedDrag(assetId: string, data: any, e: PointerEvent) { + trackDrag( + e, + { + sourceId: assetId, + feed: true, + threshold: 5, + // A drop target is a node whose scripts can all subscribe to the asset. + eligible: () => { + if (!onAddAssetTrigger) return new Set() + const targets = new Map<string, Set<string>>() + const can = (id: string) => { + if (!targets.has(id)) targets.set(id, onDirectiveTargets(model.nodes, model.edges, id)) + return targets.get(id)!.has(assetId) + } + return new Set( + view.nodes + .filter((n) => { + const scripts = dragScripts(n) + return n.id !== assetId && scripts.length > 0 && scripts.every(can) + }) + .map((n) => n.id) + ) + } + }, + (target, at, blank) => { + const node = target ? view.nodes.find((n) => n.id === target) : undefined + if (node) subscribe(assetId, dragScripts(node)) + else if (blank) { + const asset = assetOf(assetId) + if (!asset) return + feedMenu = { at, asset, data } + feedMenuSignal++ + } + } + ) + } + // Pipeline scripts a node's top dot drags for: the script itself, or, in the + // assets-only view, the scripts building the asset. + function dragScripts(n: { id: string; type: string; data: any }): string[] { + const ids = + n.type === 'runnable' ? [n.id] : n.type === 'asset' ? ((n.data.foldedIds ?? []) as string[]) : [] + return ids.filter((id) => { + const d = model.nodes.find((x) => x.id === id)?.data + return d?.runnable_kind === 'script' && d.in_pipeline + }) + } + + let assetsOnly = $state(false) + // Before the first paint: the default view is assets-only, and a plain effect + // would flash the full graph first. + $effect.pre(() => { + if (assetsOnlyFolder != undefined) assetsOnly = isAssetsOnlyFolder(assetsOnlyFolder) + }) + + // Right-click on the empty canvas: a menu at the pointer whose entry opens the + // add-data-source menu there too. + let paneMenu = $state<{ x: number; y: number } | undefined>(undefined) + + // Hovering an edge names the relationship it draws, after a short pause so + // sweeping the pointer across the canvas doesn't flash popovers. + let hoveredEdge = $state<{ id: string; x: number; y: number } | undefined>(undefined) + let edgeHoverTimer: ReturnType<typeof setTimeout> | undefined + function onEdgeEnter({ edge, event }: { edge: Edge; event: PointerEvent }) { + clearTimeout(edgeHoverTimer) + const at = { id: edge.id, x: event.clientX, y: event.clientY } + edgeHoverTimer = setTimeout(() => (hoveredEdge = at), 300) + } + // Leaving the edge closes the popover after a moment, so the pointer can reach + // its buttons; entering the popover keeps it. + function onEdgeLeave() { + clearTimeout(edgeHoverTimer) + edgeHoverTimer = setTimeout(() => (hoveredEdge = undefined), 200) + } + // What the hovered edge lets you change: a plain read can become a trigger, and + // an asset trigger can be dropped. Both rewrite the script's `// on` header. + // What the hovered edge lets you change: a script that only reads the upstream + // asset can rerun on each write to it, and one it triggers can stop. On an + // assets-only edge, those are the scripts folded into it; both rewrite `// on`. + type EdgeTarget = { script: string; asset: { kind: AssetKind; path: string }; reads: boolean } + let hoveredEdgeActions = $derived.by(() => { + const e = hoveredEdge && onEdgeAction && view.edges.find((x) => x.id === hoveredEdge!.id) + if (!e) return [] + const assetData = view.nodes.find((n) => n.id === e.source)?.data + if (!assetData?.asset_kind) return [] + const asset = { kind: assetData.asset_kind as AssetKind, path: assetData.path as string } + const scriptIds = + e.kind === 'asset-flow' + ? (e.via ?? []) + : e.kind === 'lineage-read' || e.kind === 'trigger-asset' + ? [e.target] + : [] + const listen: EdgeTarget[] = [] + const stop: EdgeTarget[] = [] + for (const id of scriptIds) { + const script = model.nodes.find((n) => n.id === id)?.data + if (script?.runnable_kind !== 'script' || !script.in_pipeline) continue + const has = (kind: string) => + model.edges.some((x) => x.kind === kind && x.source === e.source && x.target === id) + const target = { script: script.path as string, asset, reads: has('lineage-read') } + if (has('trigger-asset')) stop.push(target) + // A script rerunning on its own writes would loop: offered only for inputs. + else if ( + target.reads && + !model.edges.some( + (x) => x.kind === 'lineage-write' && x.source === id && x.target === e.source + ) + ) + listen.push(target) + } + const where = (ts: EdgeTarget[]) => + ts.length === 1 ? ts[0].script : `${ts.length} scripts: ${ts.map((t) => t.script).join(', ')}` + const out: Array<{ label: string; detail: string; run: () => void }> = [] + if (listen.length) { + out.push({ + label: 'Rerun on each write', + detail: `${listenChangeText(asset, true)} in ${where(listen)}; only the annotation header changes. Saved as a draft.`, + run: () => onEdgeAction?.({ action: 'listen', targets: listen }) + }) + } + if (stop.length) { + out.push({ + label: 'Stop rerunning on writes', + detail: `${stopListeningChangeText( + asset, + stop.some((t) => t.reads) + )} in ${where(stop)}; only the annotation header changes. Saved as a draft.`, + run: () => onEdgeAction?.({ action: 'stop', targets: stop }) + }) + } + return out + }) + let hoveredEdgeText = $derived.by(() => { + const e = hoveredEdge && view.edges.find((x) => x.id === hoveredEdge!.id) + if (!e) return undefined + const byId = new Map(view.nodes.map((n) => [n.id, n])) + const nameOf = (id: string) => { + const n = byId.get(id) + if (!n) return id.replace(/^[^:]+:/, '') + if (n.type === 'asset') + return formatShortAssetPath({ kind: n.data.asset_kind, path: n.data.path }) + if (n.type === 'no-asset') return n.data.noAssetName + if (n.type === 'trigger') return n.data.summary || n.data.ref + return n.data.summary || n.data.path + } + return describeEdge(e, nameOf, (id) => byId.get(id)?.data) + }) + let addMenuAt = $state<{ x: number; y: number }>({ x: 0, y: 0 }) + let addMenuSignal = $state(0) + + // The schedule wizard a Fix action opens for an existing script, at the pill. + let scheduleWizard = $state< + | { + script: string + onSchedule: (s: PipelineInsertSchedule) => void + at: { x: number; y: number } + } + | undefined + >(undefined) + let scheduleWizardSignal = $state(0) + + function toFix(spec: NodeFixSpec | undefined): NodeFix | undefined { + if (!spec) return undefined + return { + explainer: spec.explainer, + actions: spec.actions.map((a) => ({ + label: a.label, + detail: a.detail, + run: (anchor) => { + if (a.scheduleFor) { + scheduleWizard = { ...a.scheduleFor, at: anchor } + scheduleWizardSignal++ + } else a.run?.() + } + })) + } + } + function handleContextMenu(e: MouseEvent) { + const t = e.target as HTMLElement | null + // A node's own actions menu opens on right-click; the browser's never does, + // on nodes without one either. + if (t?.closest('.svelte-flow__node')) { + e.preventDefault() + return + } + if (!onAddPipelineScript || boundPick || !t?.closest('.svelte-flow__pane')) return + e.preventDefault() + paneMenu = { x: e.clientX, y: e.clientY } + } + // `${kind}:${path}` ids for the hovered / pinned runs (both script and flow // variants, since the run row's kind isn't known here). const runIds = (paths: string[] | undefined): Set<string> => @@ -256,7 +658,12 @@ | 'macro' | 'test-dependency' | 'dbt-ref' + // Assets-only view: an asset → an asset computed from it, through the + // script that is folded away. + | 'asset-flow' unsaved?: boolean + // Assets-only `asset-flow`: a write to the source reruns a folded script. + reactive?: boolean // Muted read edge: a ducklake/s3 input read every run whose (default) // auto cascade trigger is suppressed by `// mute` / `// mute all`. // Rendered with a bell-off badge — auto-wiring is the norm, so we mark @@ -276,6 +683,8 @@ // whole lib when pulled in via `// use`) — rendered as a ƒ badge. macro_names?: string[] via_use?: boolean + // Assets-only edges: the scripts folded between the two assets. + via?: string[] } // Graph-id of the script the user just launched (zero-latency hint), @@ -290,7 +699,7 @@ function build(g: AssetGraphResponse) { const nodes: Array<{ id: string - type: 'asset' | 'runnable' | 'trigger' | 'add' | 'data-test' + type: 'asset' | 'runnable' | 'trigger' | 'add' | 'data-test' | 'no-asset' data: any }> = [] const edges: BuiltEdge[] = [] @@ -427,8 +836,10 @@ path: a.path, fork_materialization: a.fork_materialization, derived_from: a.derived_from, + error: a.error, dbt: a.dbt, onAddScript: onAddScriptForAsset, + neverWritten: a.never_written, pathPrefix, defaultPathSuffix, producers: producersByAsset.get(`${a.kind}:${a.path}`) ?? [], @@ -454,11 +865,14 @@ const owner = model.dbtOwnerByAsset.get(assetId) const [kind, ...rest] = owner?.split(':') ?? [] if (kind && rest.length) { - onselect?.({ - kind: 'runnable', - runnable_kind: kind as 'script' | 'flow', - path: rest.join(':') - }) + onselect?.( + { + kind: 'runnable', + runnable_kind: kind as 'script' | 'flow', + path: rest.join(':') + }, + { open: true } + ) } } } @@ -466,14 +880,6 @@ } }) } - // Set of `script_path` values for runnables that are still drafts (no - // DB row yet). Trigger nodes use this to swap "Click to create" for - // "Click to create (after draft save)" so the user knows the create - // button is blocked until the script is deployed. - const unsavedRunnablePaths = new Set<string>() - for (const r of g.runnables) { - if (r.unsaved) unsavedRunnablePaths.add(r.path) - } // Producer → its `// data_test` checks, keyed by runnable id, so the // write-edge to the materialized asset can carry the test badge: the // edge *is* the transformation, and the tests assert on what it produces. @@ -526,6 +932,9 @@ data: { runnable_kind: r.usage_kind, path: r.path, + summary: r.summary, + language: r.language, + error: r.error, in_pipeline: r.in_pipeline ?? false, partition_kind: r.partition_kind, freshness: r.freshness, @@ -724,6 +1133,10 @@ kind: TriggerNodeKind ref: string missing: boolean + draft: boolean + schedule?: string + timezone?: string + summary?: string // First target script (drives the per-script create/edit flows). runnable_path?: string // Every target script: a single (kind, ref) — e.g. one schedule — @@ -739,7 +1152,11 @@ ref: string, unsaved: boolean, missing: boolean, - runnable_path?: string + draft: boolean, + runnable_path?: string, + schedule?: string, + timezone?: string, + summary?: string ) { const prev = triggerSourceNodes.get(id) if (!prev) { @@ -748,6 +1165,10 @@ kind, ref, missing, + draft, + schedule, + timezone, + summary, runnable_path, runnable_paths: runnable_path ? [runnable_path] : [] }) @@ -796,13 +1217,17 @@ ref, !!t.unsaved, isMissing, + t.draft === true, // Always thread the target script so the trigger node can // reach back to it — drives both the missing-trigger "create" // flow and the attached-trigger "edit" flow's script-path // lock. Previously only set for missing, which silently // disabled `canEdit` (and the resulting click affordance) // on every attached native trigger. - t.runnable_path + t.runnable_path, + t.schedule, + t.timezone, + t.summary ) edges.push({ id: `trig-${t.trigger_kind}:${sourceId}->${runnableId}`, @@ -830,10 +1255,12 @@ ref: info.ref, unsaved: info.allUnsaved, missing: info.missing, + draft: info.draft, + schedule: info.schedule, + timezone: info.timezone, + summary: info.summary, runnable_path: info.runnable_path, - runnable_unsaved: info.runnable_path - ? unsavedRunnablePaths.has(info.runnable_path) - : false, + runnable_paths: info.runnable_paths, // data_upload nodes go green once a file is staged for their // target script (see readyDataUploadPaths / page dataUploadArgs). ready: info.runnable_path @@ -881,11 +1308,383 @@ let model = $derived(build(graph)) + // The trigger chip last clicked in the assets-only view, by trigger node id: it + // opens a drawer rather than a selection, so it is highlighted until the next pick. + let activeTriggerId = $state<string | undefined>(undefined) + $effect(() => { + void selection + activeTriggerId = undefined + }) + + /** The chip naming what runs an asset's producer, and the editor it opens: the + * same routes the trigger's own node takes. */ + function triggerChip( + t: (AssetUpstream & { multiple: false })['trigger'], + scriptPath: string + ): { + label: string + /** Nothing to show: the edges into the node already say it runs on writes. */ + hidden?: boolean + kind?: string + nodeId?: string + missing?: boolean + draft?: boolean + onOpen?: () => void + } { + if (!t) return { label: 'Manual' } + if (t.kind === 'asset') return { label: '', hidden: true } + const kind = t.kind as NativeTriggerKind + const style = TRIGGER_NODE_STYLE[kind] + const label = + t.kind === 'schedule' && t.data?.schedule + ? (describeSchedule(t.data.schedule, t.data.timezone) ?? `Schedule (${t.data.schedule})`) + : (style?.label ?? t.kind) + const rowless = kind === 'webhook' || kind === 'data_upload' + const missing = !!t.data?.missing && !rowless + const open = + kind === 'webhook' + ? onOpenWebhook && (() => onOpenWebhook(scriptPath)) + : kind === 'data_upload' + ? onOpenDataUpload && (() => onOpenDataUpload(scriptPath)) + : missing + ? onCreateMissingTrigger && (() => onCreateMissingTrigger(kind, scriptPath)) + : t.data?.ref && onEditTrigger + ? () => onEditTrigger(kind, t.data.ref, scriptPath) + : undefined + return { + label, + kind, + nodeId: t.nodeId, + missing, + draft: t.data?.draft, + onOpen: open + ? () => { + activeTriggerId = t.nodeId + open() + } + : undefined + } + } + + // Scripts and triggers fold into the assets they produce: each asset carries + // its producer's run state, language and trigger instead of drawing them. + function assetsOnlyModel(m: typeof model): typeof model { + const v = assetsOnlyView(m.nodes, m.edges) + const runnables = new Map(m.nodes.filter((n) => n.type === 'runnable').map((n) => [n.id, n])) + // What an asset card shows in this view: its producer's chips, its width, + // and what deleting it removes. `data` is an asset's, or the "No asset" node's. + const fold = (data: any, u: AssetUpstream | undefined) => { + const r = u && !u.multiple ? runnables.get(u.runnableId)?.data : undefined + // A dbt model is built by its project, whose writes the canvas does not + // draw; its dbt chip already names it. + const upstream = !u + ? data.dbt && data.dbt.resource_type !== 'source' + ? undefined + : { none: true as const } + : u.multiple || !r + ? { + multiple: true as const, + scripts: (u.multiple ? u.runnableIds : []) + .map((id) => runnables.get(id)?.data) + .filter((d) => d != undefined) + .map((d) => ({ + runnableId: `${d.runnable_kind}:${d.path}`, + path: d.path as string, + summary: d.summary as string | undefined, + language: d.language, + unsaved: d.unsaved as boolean | undefined, + onOpen: () => + onselect?.( + { kind: 'runnable', runnable_kind: d.runnable_kind, path: d.path }, + { open: true } + ) + })), + // Every script's own triggers; asset triggers show as edges instead. + triggers: (u.multiple ? u.runnableIds : []) + .flatMap((id) => { + const d = runnables.get(id)?.data + if (!d) return [] + return m.edges + .filter((e) => e.kind === 'trigger-native' && e.target === id) + .map((e) => m.nodes.find((n) => n.id === e.source)) + .filter((t) => t != undefined) + .map((t) => ({ + ...triggerChip( + { nodeId: t.id, kind: t.data.kind, data: t.data }, + d.path as string + ), + script: d.path as string + })) + }) + .filter((t) => !t.hidden) + } + : { + runnableId: u.runnableId, + path: r.path, + summary: r.summary, + language: r.language, + unsaved: r.unsaved, + runState: r.runState, + trigger: triggerChip(u.trigger, r.path), + onOpen: () => + onselect?.( + { kind: 'runnable', runnable_kind: r.runnable_kind, path: r.path }, + { open: true } + ) + } + const toDelete = + onDeleteAssetUpstream && u && !u.multiple && r + ? upstreamDeletion(u, r, v.upstream) + : undefined + // The scripts and triggers folded into this node, whose errors it reports. + const scriptIds = u ? (u.multiple ? u.runnableIds : [u.runnableId]) : [] + const foldedIds = [ + ...scriptIds, + ...m.edges + .filter((e) => e.kind === 'trigger-native' && scriptIds.includes(e.target)) + .map((e) => e.source) + ] + const foldedErrors = foldedIds.filter((id) => nodeFixes?.has(id)).length + const asset = { kind: data.asset_kind, path: data.path } + // Chips this estimate does not model keep the regular width. + const width = + upstream && !data.dbt && !data.fork_materialization && !data.derived_from && !data.runStatus + ? assetsOnlyNodeWidth({ + kind: data.noAsset ? 'No asset' : formatAssetKind(asset), + title: data.noAsset ? data.noAssetName : formatShortAssetPath(asset), + chip: foldedErrors + ? `${foldedErrors} errors · Fix` + : 'none' in upstream + ? 'External source' + : 'multiple' in upstream + ? (upstream.triggers?.length ?? 0) > 1 + ? 'Multiple triggers' + : (upstream.triggers?.length ?? 0) === 1 + ? (upstream.triggers?.[0]?.label ?? '') + : '' + : upstream.trigger.hidden + ? '' + : `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`, + chipIcon: foldedErrors > 0, + // Room for the chip's trailing icon: a pencil, or the chevron of a + // several-trigger menu (about as wide). + chipEdit: + !foldedErrors && + ('multiple' in upstream + ? (upstream.triggers?.length ?? 0) > 1 || + (!!upstream.triggers?.[0]?.onOpen && !upstream.triggers?.[0]?.missing) + : 'trigger' in upstream && + !!upstream.trigger?.onOpen && + !upstream.trigger.missing), + header: + 'runnableId' in upstream + ? { + label: upstream.summary || upstream.path, + icons: 1, + draft: !!upstream.unsaved + } + : 'scripts' in upstream && upstream.scripts?.length + ? upstream.scripts.length === 1 + ? { + label: upstream.scripts[0].summary || upstream.scripts[0].path, + icons: 1, + draft: !!upstream.scripts[0].unsaved + } + : { label: `${upstream.scripts.length} scripts`, icons: 2, chevron: true } + : undefined + }) + : undefined + return { + ...data, + upstream, + width, + foldedIds, + ...(toDelete + ? { + upstreamDelete: toDelete, + deleteVerb: assetDeleteVerb, + onDeleteUpstream: (t: AssetUpstreamDelete) => onDeleteAssetUpstream?.(t) + } + : onDeleteAssetUpstream && u + ? { + // No combined delete, but each script it folds can still go on its + // own: in this view its node's menu is the only way to reach it. + scriptDeletes: (u.multiple ? u.runnableIds : [u.runnableId]) + .map((id) => runnables.get(id)?.data) + .filter((d) => d?.runnable_kind === 'script') + .map((d) => ({ path: d.path as string, unsaved: !!d.unsaved })), + // Its scripts' own triggers: a row that starts only them, which + // webhooks and data uploads (no row) and missing ones are not. + triggerDeletes: scriptIds.flatMap((id) => { + const runnable = runnables.get(id)?.data + // A flow is not deleted from here, and neither are its triggers. + if (runnable?.runnable_kind !== 'script') return [] + const scriptPath = runnable.path + return m.edges + .filter((e) => e.kind === 'trigger-native' && e.target === id) + .map((e) => m.nodes.find((n) => n.id === e.source)?.data) + .filter( + (t) => + t && + t.kind !== 'webhook' && + t.kind !== 'data_upload' && + t.ref && + !t.missing && + ((t.runnable_paths ?? []) as string[]).every((p) => p === scriptPath) + ) + .map((t) => ({ + kind: t.kind as NativeTriggerKind, + path: t.ref as string, + draft: !!t.draft + })) + }), + deleteVerb: assetDeleteVerb, + onDeleteUpstream: (t: AssetUpstreamDelete) => onDeleteAssetUpstream?.(t), + // The No-asset card stands for its scripts; an asset is built by them. + deleteBlocked: data.noAsset + ? !u.multiple + ? 'it stands for a flow' + : u.runnableIds.length > 1 + ? 'it stands for several scripts' + : 'its script has several triggers' + : !u.multiple + ? r?.runnable_kind === 'flow' + ? 'a flow builds it' + : 'its script builds other assets too' + : u.runnableIds.length > 1 + ? 'several scripts build it' + : 'its script has several triggers' + } + : {}) + } + } + const nodes = m.nodes + .filter((n) => n.id === ADD_NODE_ID || v.nodeIds.has(n.id)) + .map((n) => (n.type === 'asset' ? { ...n, data: fold(n.data, v.upstream.get(n.id)) } : n)) + for (const id of v.noAssetNodeIds) { + const u = v.upstream.get(id) + const scriptId = u && !u.multiple ? u.runnableId : u?.runnableIds[0] + const data = fold( + { noAsset: true, noAssetName: scriptId?.split('/').pop() ?? 'script' }, + u + ) + // Run only by writes to what it reads, the edges into it already say how + // it starts: the script's banner alone. Another trigger, or an error to + // fix, needs the card's chip row. + const bannerOnly = + !!u && + !u.multiple && + u.trigger?.kind === 'asset' && + !(data.foldedIds as string[]).some((f) => nodeFixes?.has(f)) + nodes.push({ id, type: 'no-asset', data: bannerOnly ? { ...data, bannerOnly } : data }) + } + const edges: BuiltEdge[] = v.edges.map((e) => ({ ...e, kind: e.kind as BuiltEdge['kind'] })) + if (nodes.some((n) => n.id === ADD_NODE_ID)) { + const hasIncoming = new Set(edges.map((e) => e.target)) + for (const n of nodes) { + if (n.id !== ADD_NODE_ID && !hasIncoming.has(n.id)) { + edges.push({ + id: `add-anchor:${n.id}`, + source: ADD_NODE_ID, + target: n.id, + kind: 'add-anchor' + }) + } + } + } + return { ...m, nodes, edges } + } + // Reads that start nothing (lookups, reference tables) are often most of a + // graph's edges; hiding them leaves what makes the pipeline run. Remembered + // across pipelines. + const SHOW_PASSIVE_READS_KEY = 'pipeline-show-passive-reads' + let showPassiveReads = $state(true) + try { + showPassiveReads = localStorage.getItem(SHOW_PASSIVE_READS_KEY) !== 'false' + } catch {} + let withoutPassive = $derived(withoutPassiveReads(model.nodes, model.edges)) + // Only where the toggle is shown: the preference is global, and a graph + // without the toggle would have no way to show the reads again. + let shownModel = $derived( + !assetsOnlyToggle || showPassiveReads || withoutPassive.dropped === 0 + ? model + : { ...model, nodes: withoutPassive.nodes, edges: withoutPassive.edges } + ) + let view = $derived(assetsOnly ? assetsOnlyModel(shownModel) : shownModel) + // dbt association, surfaced by emphasis instead of edges. Hovering a model's // dbt badge lights up the project node that materializes it; hovering the // project node lights up every model it owns. Clicking the badge selects the // project node, so the association survives the pointer leaving. let dbtHoverId = $state<string | undefined>(undefined) + + // Hovering a node keeps its lineage — everything upstream and downstream of + // it, recursively — at full strength and fades the rest. Off while a bounded + // pick or a subscribe drag owns the graph's dimming. + let hoveredNodeId = $state<string | undefined>(undefined) + // A click re-renders the graph under the cursor (the selection rebuilds the + // nodes, the details pane resizes or covers the canvas), which fires bursts of + // leave/enter. The fade waits for the pointer to settle on a node, outlives a + // brief leave, and stays off on a node just clicked until another is hovered. + let hoverTimer: ReturnType<typeof setTimeout> | undefined + let clickedNodeId: string | undefined + function onNodePointerEnter(id: string) { + clearTimeout(hoverTimer) + if (id === clickedNodeId) return + clickedNodeId = undefined + hoverTimer = setTimeout(() => (hoveredNodeId = id), 120) + } + function onNodePointerLeave() { + clearTimeout(hoverTimer) + hoverTimer = setTimeout(() => (hoveredNodeId = undefined), 80) + } + $effect(() => () => clearTimeout(hoverTimer)) + let lineage = $derived( + hoveredNodeId && hoveredNodeId !== ADD_NODE_ID && !boundPick && !onDrag + ? hoverLineage( + view.edges.filter((e) => e.kind !== 'add-anchor'), + hoveredNodeId + ) + : undefined + ) + // The fade goes through a stylesheet keyed on node and edge ids, never through + // the graph's node or edge objects: new node objects make the flow re-measure + // them and rebuild every edge they touch, and a rebuilt element jumps to its + // new opacity instead of easing there. + const canvasId = randomUUID() + let lineageFadeCss = $derived.by(() => { + if (!lineage) return '' + const scope = `[data-asset-canvas="${canvasId}"]` + const nodes = view.nodes + .filter((n) => outsideLineage(n.id)) + .map((n) => `${scope} .svelte-flow__node[data-id="${CSS.escape(n.id)}"]`) + const edges = view.edges + .filter((e) => e.kind !== 'add-anchor' && !lineage!.hasEdge(e)) + .map((e) => `${scope} .svelte-flow__edge[data-id="${CSS.escape(e.id)}"]`) + return [ + nodes.length ? `${nodes.join(',')}{opacity:0.5}` : '', + edges.length ? `${edges.join(',')}{opacity:0.35}` : '' + ].join('') + }) + // Assets-only: a trigger edge A → C that A → B → … → C already implies says + // nothing new, so it recedes; the chain carries the cascade. + let impliedTriggerEdges = $derived( + assetsOnly + ? transitivelyImpliedEdges(view.edges.filter((e) => e.kind === 'asset-flow' && e.reactive)) + : new Set<string>() + ) + let lineageSheet = $state<HTMLStyleElement | undefined>(undefined) + onMount(() => { + const sheet = document.createElement('style') + document.head.appendChild(sheet) + lineageSheet = sheet + return () => sheet.remove() + }) + $effect(() => { + if (lineageSheet) lineageSheet.textContent = lineageFadeCss + }) + const outsideLineage = (id: string) => + !!lineage && id !== ADD_NODE_ID && !lineage.upstream.has(id) && !lineage.downstream.has(id) let dbtEmphasisIds = $derived.by(() => { if (!dbtHoverId) return new Set<string>() const owned = model.dbtWritesByOwner.get(dbtHoverId) @@ -922,14 +1721,14 @@ // input; the rendered edges are untouched (both arrows still drawn). let writeEdgePairs = $derived( new Set( - model.edges.filter((e) => e.kind === 'lineage-write').map((e) => `${e.source}\n${e.target}`) + view.edges.filter((e) => e.kind === 'lineage-write').map((e) => `${e.source}\n${e.target}`) ) ) let layoutInput = $derived({ - nodes: model.nodes - .map((n) => ({ id: n.id, data: n.data })) + nodes: view.nodes + .map((n) => ({ id: n.id, data: n.data, width: n.data?.width as number | undefined })) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)), - edges: model.edges + edges: view.edges .filter( (e) => !( @@ -950,7 +1749,18 @@ : 1 ) }) - let layoutPositions = $derived(layoutAssetGraph(layoutInput, ADD_NODE_ID)) + let layoutPositions = $derived( + layoutAssetGraph( + layoutInput, + ADD_NODE_ID, + assetsOnly + ? PIPELINE_NODE_EXTRA_ROW + PIPELINE_NODE_HEADER + PIPELINE_NODE_HEADER_CARD_PAD + : 0 + ) + ) + + /** Assets-only asset cards size to their text; every other node is `NODE.width`. */ + const nodeWidth = (n: { data?: any }): number => n.data?.width ?? NODE.width let positionedNodes = $derived.by(() => { // Compute bbox width from layout; shift every x so the graph is @@ -965,11 +1775,8 @@ } const bboxWidth = isFinite(minX) ? maxX - minX : 0 const xCenter = paneWidth / 2 - bboxWidth / 2 - return model.nodes.map<Node>((n) => { + return view.nodes.map<Node>((n) => { const p = layoutPositions.get(n.id) ?? { x: 0, y: 0 } - // Compensate for the + node being narrower than its layout slot - // so it visually centers over the node(s) below. - const xShift = n.id === ADD_NODE_ID ? (NODE.width - ADD_NODE_WIDTH) / 2 : 0 // Activity-panel emphasis (purely visual rings, kept off `selected` // which swaps the details pane). Hover wins over pin so the cursor // always tracks. @@ -998,12 +1805,73 @@ else if (!boundPick.eligible.has(n.id)) boundClass = 'wm-bound-dim' } const dbtClass = dbtEmphasisIds.has(n.id) ? 'wm-dbt-linked' : undefined + const dragClass = !onDrag + ? undefined + : onDrag.eligible.has(n.id) + ? onDrag.over === n.id + ? 'wm-drag-over' + : undefined + : n.id === onDrag.sourceId + ? undefined + : 'wm-drag-dim' + // The chips' selected look rides on the positioned nodes, so a selection + // never re-runs the layout. + const up = n.type === 'asset' || n.type === 'no-asset' ? n.data.upstream : undefined + const fix = toFix(nodeFixes?.get(n.id)) + const withUpstream = up?.scripts + ? { + ...n.data, + upstream: { + ...up, + selected: up.scripts.some( + (sc: { runnableId: string }) => sc.runnableId === selectedId + ), + triggers: (up.triggers ?? []).map((t: { nodeId?: string }) => ({ + ...t, + selected: !!t.nodeId && t.nodeId === activeTriggerId + })) + } + } + : up?.runnableId !== undefined + ? { + ...n.data, + upstream: { + ...up, + selected: up.runnableId === selectedId, + trigger: { + ...up.trigger, + selected: !!up.trigger.nodeId && up.trigger.nodeId === activeTriggerId + } + } + } + : n.data + const foldedFixes = ((n.data.foldedIds ?? []) as string[]) + .map((id) => toFix(nodeFixes?.get(id))) + .filter((f) => f != undefined) + const scriptsToDrag = onAddAssetTrigger ? dragScripts(n) : [] + // Assets-only: a folded script with an error reddens the header naming it. + const scriptError = ((n.data.foldedIds ?? []) as string[]).some( + (id) => id.startsWith('script:') && nodeFixes?.has(id) + ) + const data = { + ...withUpstream, + ...(scriptError ? { scriptError } : {}), + ...(fix ? { fix } : {}), + ...(foldedFixes.length ? { foldedFixes } : {}), + ...(scriptsToDrag.length + ? { onStartOnDrag: (e: PointerEvent) => startOnDrag(n.id, scriptsToDrag, e) } + : {}), + ...(n.type === 'asset' && onAddScriptForAsset + ? { onStartFeedDrag: (e: PointerEvent) => startFeedDrag(n.id, n.data, e) } + : {}) + } return { id: n.id, type: n.type, - position: { x: p.x + xCenter + xShift, y: p.y + 40 }, - data: n.data, - class: boundClass ?? dbtClass ?? runClass ?? assetClass, + // The layout places centers; a node's position is its top-left corner. + position: { x: p.x - nodeWidth(n) / 2 + xCenter, y: p.y + 40 }, + data, + class: dragClass ?? boundClass ?? dbtClass ?? runClass ?? assetClass, selected: n.id === selectedId, // All nodes non-draggable: the layout is sugiyama-computed, // dragging would fight the reactive re-layout. Selection is @@ -1013,7 +1881,7 @@ // on its inner box so the edit/create button still receives // clicks despite svelte-flow's wrapper-level pointer-events: none. draggable: false, - selectable: n.id !== ADD_NODE_ID && n.type !== 'trigger' + selectable: n.id !== ADD_NODE_ID && n.type !== 'trigger' && n.type !== 'no-asset' } }) }) @@ -1039,7 +1907,7 @@ let assetEmphasis = $derived.by<Map<string, 'input' | 'output'>>(() => { const m = new Map<string, 'input' | 'output'>() if (emphasizedRunIdSet.size === 0) return m - for (const e of model.edges) { + for (const e of view.edges) { if (e.kind === 'lineage-write' && emphasizedRunIdSet.has(e.source)) { m.set(e.target, 'output') } else if ( @@ -1058,15 +1926,20 @@ let nodeCenters = $derived( positionedNodes .filter((n) => n.id !== ADD_NODE_ID) - .map((n) => ({ id: n.id, cx: n.position.x + NODE.width / 2, cy: n.position.y })) + .map((n) => ({ + id: n.id, + cx: n.position.x + nodeWidth(n) / 2, + cy: n.position.y, + halfW: nodeWidth(n) / 2 + })) ) - // Detour lane for an edge whose straight run would pass over an unrelated - // node (the failure the same-column gutter can't see). For each node - // strictly between the endpoints' rows, sample the straight line at that - // row; if the node sits under it, route around the obstacle on the side the - // edge is already heading. Returns the outermost lane x clearing every + // Detour lane for an edge whose run would pass over an unrelated node. An + // edge spanning rows bends inside the first gap below its source and then + // runs straight down the *target's* column (see AssetGraphEdge), so every + // node strictly between the endpoints' rows is tested against the target's + // x, not against a source→target line. A crossed node is routed around on + // the side the target lies. Returns the outermost lane x clearing every // crossed node, or undefined when the corridor is clear. O(nodes) per edge. - const HALF_W = NODE.width / 2 const ROUTE_PAD = NODE.gap.horizontal / 2 function detourForEdge(sourceId: string, targetId: string): number | undefined { const s = nodeCenters.find((n) => n.id === sourceId) @@ -1078,11 +1951,10 @@ if (n.id === sourceId || n.id === targetId) continue // strictly between the two rows if ((n.cy - s.cy) / dyTot <= 0.01 || (n.cy - s.cy) / dyTot >= 0.99) continue - const edgeX = s.cx + (t.cx - s.cx) * ((n.cy - s.cy) / dyTot) - if (Math.abs(edgeX - n.cx) >= HALF_W + 8) continue + if (Math.abs(t.cx - n.cx) >= n.halfW + 8) continue // Crossed: a lane just outside this node, toward the target side. const side = t.cx >= n.cx ? 1 : -1 - const candidate = n.cx + side * (HALF_W + ROUTE_PAD) + const candidate = n.cx + side * (n.halfW + ROUTE_PAD) // Keep the outermost lane so one detour clears every obstacle. if (lane == undefined || Math.abs(candidate - s.cx) > Math.abs(lane - s.cx)) lane = candidate } @@ -1090,7 +1962,7 @@ } let flowEdges = $derived.by(() => - model.edges + view.edges // Anchor edges are layout-only. .filter((e) => e.kind !== 'add-anchor') .map<Edge>((e) => { @@ -1118,30 +1990,42 @@ // the "happening now" signal. switch (e.kind) { case 'lineage-write': - style = 'stroke: rgb(59 130 246); stroke-width: 2px;' + style = 'stroke: rgb(59 130 246); stroke-width: 1.25px;' animated = flowAnimated markerColor = 'rgb(59 130 246)' break + case 'asset-flow': + // Assets-only: a write upstream reruns what builds the target + // (solid, like a trigger), or the target only reads it (dashed gray). + if (e.reactive) { + style = `stroke: rgb(59 130 246); stroke-width: 1.5px;${impliedTriggerEdges.has(e.id) ? ' opacity: 0.35;' : ''}` + markerColor = 'rgb(59 130 246)' + } else { + style = 'stroke: rgb(156 163 175); stroke-width: 1px;' + strokeDasharray = '4 3' + markerColor = 'rgb(156 163 175)' + } + break case 'lineage-read': - style = 'stroke: rgb(156 163 175); stroke-width: 1.25px;' + style = 'stroke: rgb(156 163 175); stroke-width: 1px;' animated = flowAnimated break case 'data-test': // Asset → its custom-test script: dashed, muted, no run // animation (the test isn't a producing step). - style = 'stroke: rgb(156 163 175); stroke-width: 1.25px;' + style = 'stroke: rgb(156 163 175); stroke-width: 1px;' strokeDasharray = '4 3' markerColor = 'rgb(156 163 175)' break case 'trigger-asset': - style = 'stroke: rgb(107 114 128); stroke-width: 2px;' + style = 'stroke: rgb(107 114 128); stroke-width: 1.25px;' animated = flowAnimated markerColor = 'rgb(107 114 128)' label = 'triggers' labelStyle = 'fill: rgb(107 114 128); font-size: 10px; font-weight: 600;' break case 'trigger-native': - style = 'stroke: rgb(107 114 128); stroke-width: 2px;' + style = 'stroke: rgb(107 114 128); stroke-width: 1.25px;' strokeDasharray = '6 3' markerColor = 'rgb(107 114 128)' label = 'triggers' @@ -1151,7 +2035,7 @@ // Library → consumer: violet dashed, visually apart from both // lineage (blue/gray solid) and trigger (gray dashed) families — // it's a code dependency, not data flow or execution. - style = 'stroke: rgb(139 92 246); stroke-width: 1.25px;' + style = 'stroke: rgb(139 92 246); stroke-width: 1px;' strokeDasharray = '5 3' markerColor = 'rgb(139 92 246)' label = e.via_use ? 'uses lib' : 'macros' @@ -1161,7 +2045,7 @@ // Producer → tested script: amber dashed ordering link. Not // data flow (blue/gray) nor execution trigger (gray "triggers") // — it only says "the test needs this asset to exist first". - style = 'stroke: rgb(217 119 6); stroke-width: 1.25px;' + style = 'stroke: rgb(217 119 6); stroke-width: 1px;' strokeDasharray = '5 3' markerColor = 'rgb(217 119 6)' label = 'test needs' @@ -1171,7 +2055,7 @@ // model → model inside one dbt project. Orange, matching the // dbt badges, and dashed because the edge is dbt's own lineage // rather than a Windmill read/write the cascade acts on. - style = 'stroke: rgb(234 88 12); stroke-width: 1.25px;' + style = 'stroke: rgb(234 88 12); stroke-width: 1px;' strokeDasharray = '4 3' markerColor = 'rgb(234 88 12)' label = 'ref' @@ -1208,7 +2092,7 @@ // (red dashed dimmed — fresh draft annotation that also has // no matching row, which is the common case). if (e.missing) { - style = 'stroke: rgb(239 68 68); stroke-width: 2px;' + style = 'stroke: rgb(239 68 68); stroke-width: 1.25px;' strokeDasharray = '3 3' markerColor = 'rgb(239 68 68)' label = 'missing trigger' @@ -1246,8 +2130,8 @@ style, markerEnd: { type: MarkerType.ArrowClosed, - width: 14, - height: 14, + width: 12, + height: 12, color: markerColor } } @@ -1265,6 +2149,7 @@ const nodeTypes = { asset: AssetNode as any, + 'no-asset': AssetNode as any, runnable: RunnableNode as any, trigger: TriggerNode as any, add: AddNode as any, @@ -1276,6 +2161,9 @@ } function handleNodeClick({ node }: { node: Node }) { + clearTimeout(hoverTimer) + hoveredNodeId = undefined + clickedNodeId = node.id // Bounded-run pick mode intercepts clicks: an eligible (downstream) // node toggles as an end bound; the start, dimmed nodes, and // triggers/+ are inert. Selection (details pane) is suppressed so the @@ -1319,12 +2207,20 @@ ) { return } + activeTriggerId = undefined onselect(undefined) } </script> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> -<div class="w-full h-full relative" bind:clientWidth={paneWidth} onclickcapture={handleBackgroundClick}> +<div + class="w-full h-full relative" + data-asset-canvas={canvasId} + bind:this={flowEl} + bind:clientWidth={paneWidth} + onclickcapture={handleBackgroundClick} + oncontextmenu={handleContextMenu} +> <SvelteFlow {nodes} {edges} @@ -1342,12 +2238,42 @@ defaultEdgeOptions={{ type: 'asset' }} proOptions={{ hideAttribution: true }} onnodeclick={handleNodeClick} + onnodepointerenter={({ node }) => onNodePointerEnter(node.id)} + onnodepointerleave={onNodePointerLeave} + onedgepointerenter={onEdgeEnter} + onedgepointerleave={onEdgeLeave} --background-color={false} > <div class="absolute inset-0 !bg-surface-secondary h-full"></div> - <InitialFitView {nodes} fitKey={viewportFitKey} /> + <InitialFitView {nodes} fitKey={`${viewportFitKey}:${assetsOnly}`} /> <PanToNode targetId={panToNodeId} {nodes} /> <Controls position="top-right" orientation="horizontal" showLock={false} class="!mr-10" /> + {#if assetsOnlyToggle} + <Panel position="top-left" class="!m-3 flex flex-col gap-1.5"> + <Toggle + checked={!assetsOnly} + size="xs" + options={{ right: 'Show scripts and triggers' }} + on:change={(e) => { + assetsOnly = !e.detail + if (assetsOnlyFolder != undefined) setAssetsOnlyFolder(assetsOnlyFolder, assetsOnly) + }} + /> + {#if withoutPassive.dropped > 0} + <Toggle + checked={showPassiveReads} + size="xs" + options={{ right: "Show reads that don't trigger runs" }} + on:change={(e) => { + showPassiveReads = e.detail + try { + localStorage.setItem(SHOW_PASSIVE_READS_KEY, String(e.detail)) + } catch {} + }} + /> + {/if} + </Panel> + {/if} {#if showMinimap} <!-- Node hues mirror the canvas: blue asset cards, amber triggers, bordered neutral script cards. Visible strokes + rounded corners + @@ -1379,21 +2305,173 @@ </SvelteFlow> </div> +<svelte:window + onpointerdown={(e) => { + if (paneMenu && !(e.target as HTMLElement | null)?.closest('[data-pane-menu]')) + paneMenu = undefined + }} + onkeydown={(e) => { + if (e.key === 'Escape') paneMenu = undefined + }} +/> + +{#if paneMenu} + <div + class="fixed {getContextMenuContainerClass()}" + style="left: {paneMenu.x}px; top: {paneMenu.y}px;" + transition:fly={{ duration: 150, y: -10 }} + role="menu" + data-pane-menu + > + <button + type="button" + role="menuitem" + class="{CONTEXT_MENU_ITEM_BASE_CLASS} {CONTEXT_MENU_ITEM_HOVER_CLASS} gap-2 w-full text-left" + onclick={() => { + if (paneMenu) addMenuAt = paneMenu + paneMenu = undefined + addMenuSignal++ + }} + > + <Plus size={14} /> + Add data source + </button> + </div> +{/if} + +{#if onDrag} + <svg class="fixed inset-0 w-screen h-screen pointer-events-none z-50" aria-hidden="true"> + <line + x1={onDrag.from.x} + y1={onDrag.from.y} + x2={onDrag.to.x} + y2={onDrag.to.y} + class="stroke-blue-500" + stroke-width="1.5" + stroke-dasharray="4 3" + /> + {#if !(onDrag.feed && onDrag.blank)} + <circle cx={onDrag.to.x} cy={onDrag.to.y} r="3" class="fill-blue-500" /> + {/if} + </svg> + {#if onDrag.feed && onDrag.blank} + <div + class="fixed z-50 pointer-events-none -translate-x-1/2 -translate-y-1/2" + style="left: {onDrag.to.x}px; top: {onDrag.to.y}px;" + > + <AddDownstreamPill expanded /> + </div> + {/if} +{/if} + +{#if feedMenu && onAddScriptForAsset} + <div + class="fixed z-10 -translate-x-1/2 -translate-y-1/2" + style="left: {feedMenu.at.x}px; top: {feedMenu.at.y}px;" + > + <AddDownstreamMenu + asset={feedMenu.asset} + onAddScript={onAddScriptForAsset} + pathPrefix={feedMenu.data.pathPrefix ?? pathPrefix} + defaultPathSuffix={feedMenu.data.defaultPathSuffix ?? defaultPathSuffix} + openSignal={feedMenuSignal} + > + {#snippet trigger({ open })} + {#if open} + <AddDownstreamPill expanded /> + {/if} + {/snippet} + </AddDownstreamMenu> + </div> +{/if} + +{#if hoveredEdge && hoveredEdgeText} + {@const d = hoveredEdgeText} + {@const actions = hoveredEdgeActions} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed z-50 max-w-80 rounded-md border bg-surface px-2.5 py-1.5 text-xs text-primary shadow-md" + style="left: {hoveredEdge.x + 12}px; top: {hoveredEdge.y + 12}px;" + transition:fly={{ duration: 120, y: -4 }} + role="tooltip" + onmouseenter={() => clearTimeout(edgeHoverTimer)} + onmouseleave={onEdgeLeave} + > + <span class="font-semibold text-emphasis">{d.subject}</span> + {d.phrase} + {#if d.object}<span class="font-semibold text-emphasis">{d.object}</span>{/if} + {#if d.note}<div class="mt-0.5 text-2xs text-secondary">{d.note}</div>{/if} + {#each actions as action (action.label)} + <div class="mt-2 flex flex-col gap-0.5"> + <Button + variant="default" + unifiedSize="sm" + onclick={() => { + // Closing the popover drops `actions`: run what this one held. + const run = action.run + hoveredEdge = undefined + run() + }} + > + {action.label} + </Button> + <span class="text-2xs text-secondary">{action.detail}</span> + </div> + {/each} + </div> +{/if} + +{#if scheduleWizard} + <div class="fixed w-0 h-0" style="left: {scheduleWizard.at.x}px; top: {scheduleWizard.at.y}px;"> + <PipelineInsertMenu + kinds={[{ id: 'schedule', label: 'On schedule', description: '', icon: Clock }]} + onPick={() => {}} + openSignal={scheduleWizardSignal} + scheduleFor={scheduleWizard.script} + onSchedule={(s) => scheduleWizard?.onSchedule(s)} + > + {#snippet trigger()} + <span class="block w-0 h-0" aria-hidden="true"></span> + {/snippet} + </PipelineInsertMenu> + </div> +{/if} + +{#if onAddPipelineScript} + <div class="fixed w-0 h-0" style="left: {addMenuAt.x}px; top: {addMenuAt.y}px;"> + <AddDataSourceMenu + {onAddPipelineScript} + {pathPrefix} + {defaultPathSuffix} + openSignal={addMenuSignal} + > + {#snippet trigger()} + <span class="block w-0 h-0" aria-hidden="true"></span> + {/snippet} + </AddDataSourceMenu> + </div> +{/if} + <style lang="postcss"> :global(.svelte-flow__handle) { opacity: 0; } + /* Dragging a script's dot: what it cannot subscribe to recedes. */ + :global(.svelte-flow__node.wm-drag-dim) { + opacity: 0.25; + transition: opacity 120ms; + } + :global(.svelte-flow__node.wm-drag-over .drop-shadow-sm) { + @apply ring-2 ring-blue-500 rounded-md; + } :global(.svelte-flow__controls-button) { @apply bg-surface border-0; } :global(.svelte-flow__controls-button:hover) { @apply bg-surface-hover; } - :global(.svelte-flow__node.selected .drop-shadow-sm) { - @apply outline outline-2 outline-blue-500; - } /* Activity-panel emphasis — soft, monochromatic, less prominent than the - blue details selection above. Hover is a thin neutral ring (transient); + node's own selected state. Hover is a thin neutral ring (transient); pinning an expanded run is a soft-blue ring. */ /* A dbt project node and the models it materializes, related by badge rather than by edges — hovering either lights up the whole set. */ @@ -1427,6 +2505,11 @@ :global(.svelte-flow__node.wm-bound-end .drop-shadow-sm) { @apply outline outline-[3px] outline-amber-500; } + /* Hovering a node fades what is not in its lineage (see lineageFadeCss). */ + :global(.svelte-flow__node), + :global(.svelte-flow__edge) { + transition: opacity 120ms; + } :global(.svelte-flow__node.wm-bound-dim) { @apply opacity-30; } diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index 6050c2d1cd..4577a11915 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -2,7 +2,6 @@ import { ScriptService, type Script, type ScriptLang } from '$lib/gen' import { untrack } from 'svelte' import { resource } from 'runed' - import { base } from '$lib/base' import Button from '$lib/components/common/button/Button.svelte' import ScriptEditor from '$lib/components/ScriptEditor.svelte' import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte' @@ -20,10 +19,17 @@ SquareFunction, Trash2, X, - Pencil + Pencil, + ChevronDown, + Zap } from 'lucide-svelte' - import Popover from '$lib/components/meltComponents/Popover.svelte' - import { tick } from 'svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { + upstreamScriptItems, + upstreamTriggerItems, + type UpstreamScript, + type UpstreamTriggerAction + } from './upstreamScriptItems' import { inferArgs } from '$lib/infer' import { emptySchema, sendUserToast } from '$lib/utils' import type { Schema } from '$lib/common' @@ -41,6 +47,7 @@ import { extractDraftMacros } from './resolveGraph' import { assetColumnNodes, type ColumnLineageGraph } from './columnLineageGraph' import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte' + import Badge from '$lib/components/common/badge/Badge.svelte' import S3FilePreview from '$lib/components/S3FilePreview.svelte' import DataTablePreview from './DataTablePreview.svelte' import DucklakeAssetPanel from './DucklakeAssetPanel.svelte' @@ -117,7 +124,9 @@ // (`onDraftPersist`). `onDraftPersist` stays the authoritative commit on // navigate-away; this is the continuous feed that keeps the draft's // server copy current while the user is still typing. - onContentChange?: (scriptPath: string | undefined, content: string) => void + // `base`: the deployed copy being edited, so the parent can autosave unsaved + // edits to it before they are promoted to a draft. Unset for a draft. + onContentChange?: (scriptPath: string | undefined, content: string, base?: Script) => void // Fires when the user navigates away from a draft (selects another // node, closes the pane, etc.) — the parent must persist the new // content + write outputs into its drafts Map. Without this, @@ -133,7 +142,8 @@ writes: { kind: AssetWithAltAccessType['kind']; path: string }[] // Body-inferred reads, so the parent's draft keeps its input // lineage while inactive (no live inference runs for it). - reads: { kind: AssetWithAltAccessType['kind']; path: string }[] + // Undefined when nothing was inferred: keep what the draft has. + reads?: { kind: AssetWithAltAccessType['kind']; path: string }[] // Full edited script — set when the source is a persisted // script (needed to seed a brand-new draft entry). script?: Script @@ -156,6 +166,12 @@ path: string unsaved?: boolean }> + /** The selected asset's producing scripts, for its "Edit script" action, + * which calls `onEditScript` with the one picked. */ + producerScripts?: UpstreamScript[] + onEditScript?: (path: string) => void + /** The selected asset's upstream triggers that can be opened from here. */ + producerTriggers?: UpstreamTriggerAction[] // Pipeline-wide column-lineage graph (built by the parent page from the // resolved graph). Drives the transitive column-lineage trace shown for a // selected materialized asset. @@ -215,15 +231,18 @@ // the edges. Counter rather than boolean so back-to-back runs // re-fire the effect even if no other prop changed. requestRunSignal?: number - // Folder-scoped non-editable prefix shown next to the suffix - // editor when the user renames a draft (e.g. `f/<folder>/`). The - // new path = pathPrefix + suffix. - pathPrefix?: string // Called when the user renames a draft — the parent reseats the // path key in its drafts map and updates activeDraftPath. Returns // true on success; false on collision/validation failure so the // popover can keep itself open and surface the error inline. onDraftPathChange?: (oldPath: string, newPath: string) => boolean | string + onDraftMetaChange?: ( + path: string, + meta: { summary: string; labels: string[] | undefined } + ) => void + // The pipeline's folder prefix (`f/<folder>/`). A draft renamed outside it + // would be deployed out of the pipeline, so the rename is refused. + pathPrefix?: string // Bumped by the parent (e.g. from the runnable-node action menu) to // auto-open the archive/delete confirmation modal for the currently // loaded persisted script. No-ops while the pane is showing a draft @@ -280,6 +299,19 @@ // can persist a data-upload entry's staged input (drives node readiness). // `isValid` is the full-schema validity, not just "file present". onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void + // Show only the open script's inputs and runs (a data-upload click), not + // its editor. `onRunInputs` runs it with them, drafts included, and + // `onShowScript` leaves this view for the script. + inputsOnly?: boolean + /** `live` is the pane's buffer when it differs from what is stored: always + * for a draft, and for a deployed script edited before switching to the + * inputs view. A run with it previews that body. */ + onRunInputs?: ( + path: string, + args: Record<string, any>, + live?: { content: string; language: ScriptLang } + ) => Promise<string | undefined> + onShowScript?: () => void } let { selection, @@ -301,6 +333,9 @@ onScriptRenamed, onScriptRemoved, selectionProducers = [], + producerScripts = [], + onEditScript, + producerTriggers = [], selectionColumnGraph, selectionColumnLoading = false, selectionColumnTruncated = false, @@ -315,8 +350,9 @@ activeRunnable, onTestStateChange, requestRunSignal, - pathPrefix = '', onDraftPathChange, + onDraftMetaChange, + pathPrefix = '', requestRemoveSignal, downstreamSubscribers = 0, onStartBoundedRun, @@ -328,10 +364,15 @@ onRunByPath, onRunCascadeByPath, runFormInitialArgs, - onRunFormArgsChange + onRunFormArgsChange, + inputsOnly = false, + onRunInputs, + onShowScript }: Props = $props() let readOnly = $derived(mode !== 'edit') + // Someone who cannot switch the pipeline to edit mode only gets to read it. + let editScriptLabel = $derived(readOnly && !onRequestEdit ? 'Open script' : 'Edit script') // Root element of the pane — used to scope the S3-input lookup for the // data_upload focus effect. @@ -408,7 +449,7 @@ // PipelineScriptView renders instead of the test panel. let viewReadyForTarget = $derived.by( () => - readOnly && + (readOnly || inputsOnly) && runTargetPath !== undefined && script?.path === runTargetPath && (isDraft || (!scriptRes.loading && scriptRes.current?.path === runTargetPath)) @@ -484,6 +525,12 @@ // alongside `liveBodyAssets` and forwarded so the live graph can show // inferred column lineage on the edited script before it deploys. let liveColumnLineage = $state<ColumnLineage[] | undefined>(undefined) + // The script whose editor last bound `liveBodyAssets` (undefined there means it + // inferred nothing). They stay bound after that editor unmounts, so a script + // that never had one (the inputs-only view) must not report or persist them as + // its lineage. Set by an effect declared after the save-on-switch one, whose + // cleanup still needs the previous owner when the selection moves. + let liveAssetsOwner: string | undefined = undefined // Bumped when the runs panel reports a watched job has reached a // terminal state. Drives S3FilePreview's refreshKey so the preview @@ -549,7 +596,9 @@ let args = $state<Record<string, any>>({}) let argsSeedPath: string | undefined = undefined $effect.pre(() => { - const p = script?.path + // Re-seeded on leaving the inputs-only view too, so the file picked there + // shows in the editor's test form. + const p = script?.path === undefined ? undefined : `${script.path}|${inputsOnly}` if (p === argsSeedPath) return argsSeedPath = p args = runFormInitialArgs ? structuredClone($state.snapshot(runFormInitialArgs)) : {} @@ -559,7 +608,8 @@ // required field (not just the S3 file) is satisfied. let runFormIsValid = $state(true) $effect(() => { - if (readOnly || !script) return + // Inputs-only mounts no ScriptEditor: PipelineScriptView reports its own args. + if (readOnly || inputsOnly || !script) return onRunFormArgsChange?.(script.path, $state.snapshot(args), runFormIsValid) }) @@ -596,18 +646,25 @@ b: Array<{ kind: string; path: string }> ) => a.length === b.length && a.every((x, i) => x.kind === b[i]?.kind && x.path === b[i]?.path) return () => { - const writes = (liveBodyAssets ?? []) + // No editor inferred this script (the inputs-only view): keep its stored + // lineage rather than persisting it as empty. + const inferred = liveAssetsOwner === captured.path + const writes = !inferred + ? (writesAtRegister ?? []) + : (liveBodyAssets ?? []) .filter((a) => { const t = a.access_type ?? a.alt_access_type return t === 'w' || t === 'rw' }) .map((a) => ({ kind: a.kind, path: a.path })) - const reads = (liveBodyAssets ?? []) - .filter((a) => { - const t = a.access_type ?? a.alt_access_type - return t === 'r' || t === 'rw' - }) - .map((a) => ({ kind: a.kind, path: a.path })) + const reads = !inferred + ? readsAtRegister + : (liveBodyAssets ?? []) + .filter((a) => { + const t = a.access_type ?? a.alt_access_type + return t === 'r' || t === 'rw' + }) + .map((a) => ({ kind: a.kind, path: a.path })) // Draft runs: emit only when content or lineage changed in THIS clone. // An unconditional emit ping-pongs forever when the entry is rewritten // externally while the pane stays mounted (rename rekey, AI edit): @@ -617,6 +674,7 @@ (captured.content ?? '') === contentAtRegister && refsEq(writesAtRegister ?? [], writes) && readsAtRegister != undefined && + reads != undefined && refsEq(readsAtRegister, reads) ) return @@ -651,12 +709,42 @@ } }) + $effect(() => { + if (script && !readOnly && !inputsOnly) liveAssetsOwner = script.path + }) + let saving = $state(false) // What this pane last deployed, so the persist-back cleanup can tell "the // buffer equals the new deployed head" from real unsaved edits (plain // variable: only read inside the untracked cleanup). let deployedFromPane: { path: string; content: string } | undefined = undefined let isDraft = $derived(draftScript != undefined) + function liveBody(): { content: string; language: ScriptLang } | undefined { + if (!script) return undefined + const live = { content: script.content ?? '', language: script.language as ScriptLang } + if (isDraft) return live + const orig = scriptRes.current + return orig?.path === script.path && (orig.content ?? '') !== live.content ? live : undefined + } + // A draft's stored schema is empty until an editor infers it, and the inputs + // view mounts none: infer from the body here so its fields (the file picker) + // show up. + const inputsSchema = resource( + [() => (inputsOnly && isDraft ? script?.content : undefined), () => script?.language], + async ([content, language]) => { + if (content === undefined || !language) return undefined + const schema = emptySchema() + try { + await inferArgs(language, content, schema as Schema) + } catch { + return undefined + } + return schema + } + ) + let inputsScript = $derived( + script && inputsSchema.current ? { ...script, schema: inputsSchema.current } : undefined + ) // Single trash-bin button opens one modal that exposes both Archive // (always available) and Delete permanently (admin-only). Archive is @@ -763,12 +851,17 @@ onAnnotationsChange?.(script?.path, liveAnnotations) }) $effect(() => { - if (readOnly) return + if (readOnly || (inputsOnly && liveAssetsOwner !== script?.path)) return onAssetsChange?.(script?.path, liveBodyAssets ?? [], liveColumnLineage) }) $effect(() => { if (readOnly) return - onContentChange?.(script?.path, script?.content ?? '') + const base = draftScript ? undefined : scriptRes.current + onContentChange?.( + script?.path, + script?.content ?? '', + base && base.path === script?.path ? base : undefined + ) }) async function save() { @@ -910,49 +1003,6 @@ JSON.stringify(script.schema ?? null) === JSON.stringify(orig.schema ?? null) ) }) - - // Suffix editor for the draft-path popover. Seeded from the current - // path each time the popover opens so the user starts with what they - // see, not stale state from an earlier rename. - let draftPathSuffix = $state('') - let draftPathError = $state<string | undefined>(undefined) - let draftPathInput: HTMLInputElement | undefined = $state(undefined) - - function suffixOf(fullPath: string): string { - return fullPath.startsWith(pathPrefix) ? fullPath.slice(pathPrefix.length) : fullPath - } - - async function openDraftPathEditor() { - draftPathSuffix = script ? suffixOf(script.path) : '' - draftPathError = undefined - await tick() - draftPathInput?.focus() - draftPathInput?.select() - } - - function confirmDraftPath(close: () => void) { - if (!script) return - const suffix = draftPathSuffix.trim() - if (!suffix) { - draftPathError = 'Path cannot be empty' - return - } - const newPath = pathPrefix + suffix - if (newPath === script.path) { - close() - return - } - const result = onDraftPathChange?.(script.path, newPath) - if (result === true || result === undefined) { - script.path = newPath - draftPathError = undefined - close() - } else if (typeof result === 'string') { - draftPathError = result - } else { - draftPathError = 'Path already in use' - } - } </script> <div class="flex flex-col h-full bg-surface" bind:this={paneEl}> @@ -964,80 +1014,34 @@ {@const draftScriptPath = script.path} <Code2 size={16} class="shrink-0 text-tertiary" /> {#if onDraftPathChange && !readOnly} - <!-- Inline rename popover for drafts. The persisted-script - branch uses SummaryPathDisplay which round-trips through - updateItemPathAndSummary; drafts have no server row yet, - so we just rekey the parent's drafts map locally. --> - <Popover - placement="bottom-start" - contentClasses="p-3" - usePointerDownOutside - on:openChange={(e) => { - if (e.detail) openDraftPathEditor() + <!-- Same summary/path popover as a deployed script, saved into the + drafts map instead of through the API: a draft has no row yet. --> + <SummaryPathDisplay + summary={script.summary ?? ''} + path={draftScriptPath} + labels={script.labels ?? []} + kind="script" + onSaved={() => {}} + saveOverride={({ path, summary, labels }) => { + if (pathPrefix && (!path.startsWith(pathPrefix) || path === pathPrefix)) { + return `A pipeline script stays in ${pathPrefix}: pick a name inside that folder` + } + // Before the rename, which carries the draft entry over as it is. + onDraftMetaChange?.(draftScriptPath, { summary, labels }) + if (script) { + script.summary = summary + script.labels = labels + } + if (path === draftScriptPath) return undefined + const result = onDraftPathChange(draftScriptPath, path) + if (result !== true && result !== undefined) { + return typeof result === 'string' ? result : 'Path already in use' + } + if (script) script.path = path + return undefined }} - > - {#snippet trigger()} - <button - type="button" - class="flex flex-col min-w-0 text-left px-2 py-1 rounded-md hover:bg-surface-hover transition-colors group" - title="Edit draft path" - > - <span - class="text-3xs uppercase tracking-wide text-tertiary flex items-center gap-1" - > - Draft pipeline script - <Pencil size={9} class="opacity-0 group-hover:opacity-60 transition-opacity" /> - </span> - <span class="text-xs font-mono truncate" title={draftScriptPath} - >{draftScriptPath}</span - > - </button> - {/snippet} - {#snippet content({ close })} - <div class="flex flex-col gap-2 w-[420px]"> - <span class="text-2xs font-normal text-secondary">Path</span> - <div - class="flex items-stretch border rounded-md bg-surface overflow-hidden focus-within:ring-2 focus-within:ring-blue-400" - > - {#if pathPrefix} - <span - class="flex items-center px-2 bg-surface-secondary text-tertiary text-sm font-mono border-r select-none" - > - {pathPrefix} - </span> - {/if} - <input - bind:this={draftPathInput} - bind:value={draftPathSuffix} - onkeydown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - confirmDraftPath(close) - } else if (e.key === 'Escape') { - e.preventDefault() - close() - } - }} - class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none" - placeholder="my_script" - /> - </div> - {#if draftPathError} - <span class="text-2xs text-red-500">{draftPathError}</span> - {/if} - <div class="flex justify-end"> - <Button - variant="accent" - unifiedSize="sm" - disabled={!draftPathSuffix.trim()} - onClick={() => confirmDraftPath(close)} - > - Rename - </Button> - </div> - </div> - {/snippet} - </Popover> + /> + <Badge color="gray">Draft</Badge> {:else} <div class="flex flex-col min-w-0"> <span class="text-3xs uppercase tracking-wide text-tertiary"> @@ -1097,6 +1101,66 @@ {/if} </div> <div class="flex items-center gap-1 shrink-0"> + {#if selection?.kind === 'asset' && onEditScript && producerScripts.length === 1} + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Pencil }} + onclick={() => onEditScript(producerScripts[0].path)} + title={`Open ${producerScripts[0].path}, which builds this asset`} + > + {editScriptLabel} + </Button> + {:else if selection?.kind === 'asset' && onEditScript && producerScripts.length > 1} + <DropdownV2 + items={upstreamScriptItems(producerScripts, onEditScript)} + placement="bottom-end" + enableFlyTransition + > + {#snippet buttonReplacement()} + <Button + nonCaptureEvent + variant="default" + unifiedSize="sm" + startIcon={{ icon: Pencil }} + endIcon={{ icon: ChevronDown }} + title={`${producerScripts.length} scripts build this asset`} + > + {editScriptLabel} + </Button> + {/snippet} + </DropdownV2> + {/if} + {#if selection?.kind === 'asset' && producerTriggers.length === 1} + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Zap }} + onclick={() => producerTriggers[0].onOpen()} + title={producerTriggers[0].detail} + > + {producerTriggers[0].label} + </Button> + {:else if selection?.kind === 'asset' && producerTriggers.length > 1} + <DropdownV2 + items={upstreamTriggerItems(producerTriggers)} + placement="bottom-end" + enableFlyTransition + > + {#snippet buttonReplacement()} + <Button + nonCaptureEvent + variant="default" + unifiedSize="sm" + startIcon={{ icon: Zap }} + endIcon={{ icon: ChevronDown }} + title={`${producerTriggers.length} triggers start the scripts that build this asset`} + > + Triggers + </Button> + {/snippet} + </DropdownV2> + {/if} {#if readOnly && onRequestEdit} <Button variant="accent" @@ -1118,28 +1182,17 @@ title="Discard draft" /> {/if} - <!-- Action order, left → right: trash, external link, save, close. - Save sits closest to Close so the primary commit action is - anchored at the right edge of the bar; trash lives at the - far left so destructive ops are visually separated from - navigation/commit. Mirrors the draft Discard placement. --> - {#if !readOnly && !isDraft && isScriptView && script?.hash} - <Button - variant="subtle" - unifiedSize="sm" - startIcon={{ icon: Trash2 }} - onclick={() => (removeOpen = true)} - iconOnly - title="Archive or delete" - /> - {/if} + <!-- Action order, left → right: external link, save, close. Save sits + closest to Close so the primary commit action is anchored at the + right edge of the bar. Removing a script goes through its node's + menu. --> {#if !readOnly && !isDraft && selection?.kind === 'runnable'} <Button variant="subtle" unifiedSize="sm" href={selection.runnable_kind === 'flow' - ? `${base}/flows/edit/${selection.path}` - : `${base}/scripts/edit/${selection.path}`} + ? `/flows/edit/${selection.path}` + : `/scripts/edit/${selection.path}`} target="_blank" startIcon={{ icon: ExternalLink }} iconOnly @@ -1331,21 +1384,38 @@ {#if readOnly} Flows have no inline view here. Open <a class="text-blue-600 hover:underline" - href="{base}/flows/get/{selection.path}">the flow page</a + href="/flows/get/{selection.path}">the flow page</a > for details and runs. {:else} Flows are not editable inline. Use the open-in-editor button above. {/if} </div> {:else if !isDraft && scriptRes.loading && !script} - <div class="absolute inset-0 flex items-center justify-center gap-2 text-tertiary"> - <Loader2 size={16} class="animate-spin" /> - <span class="text-xs">Loading script…</span> - </div> + <!-- Blank while the script loads: the fetch is short, and a spinner only flashes. --> {:else if !isDraft && scriptRes.error} <div class="p-3 text-xs text-red-500"> Failed to load: {scriptRes.error.message} </div> + {:else if script && inputsOnly} + {#key script.path} + <PipelineScriptView + {isDraft} + canRun + runsDrafts + inputsOnly + {onShowScript} + onRun={onRunInputs && ((path, args) => onRunInputs(path, args, liveBody()))} + script={inputsScript ?? script} + {runsRefreshKey} + {runsPendingJobId} + initialArgs={runFormInitialArgs} + onArgsChange={onRunFormArgsChange} + onRunCompleted={() => { + previewRefreshKey += 1 + onRunCompleted?.() + }} + /> + {/key} {:else if script && readOnly} <!-- Read-only modes: no Monaco/ScriptEditor (operators are backend-blocked from previews anyway) — highlighted source, diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte index f39c2d8d18..a6d38b05d8 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte' import { NODE } from '$lib/components/graph/util' + import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout' import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte' import type { ColumnLineage, DataTest } from './parsePipelineAnnotations' @@ -103,33 +104,42 @@ // straight under the nodes in between — the child-and-grandchild-of-the- // same-node case, where the tidy-tree layout puts all three on one // column. Detour those through the gutter beside the column. - const SKIP_DY = NODE.gap.vertical * 2 + NODE.height + const SKIP_DY = NODE.gap.vertical * 2 + PIPELINE_NODE_HEIGHT const NEAR_VERTICAL_DX = NODE.width / 4 // Lane offset: half a node plus a margin that stays inside the // inter-column gutter (gap.horizontal / 2). const LANE = NODE.width / 2 + NODE.gap.horizontal / 2 - const CORNER = 14 - // Rounded-orthogonal detour: drop out of the source, swing into the - // gutter lane at `midX`, run it down past the intermediate rows, swing - // back into the target. + // Detour through the gutter lane at `midX`, drawn with the same curves as + // every other edge: curve out of the source into the lane within the gap + // below it, run down the lane past the rows in between, curve into the + // target within the gap above it. function gutterPath(midX: number): string { - const yTop = sourceY + 24 - const yBot = targetY - 24 - const outDir = midX > sourceX ? 1 : -1 - const inDir = targetX > midX ? 1 : -1 - return [ - `M ${sourceX} ${sourceY}`, - `L ${sourceX} ${yTop - CORNER}`, - `Q ${sourceX} ${yTop} ${sourceX + outDir * CORNER} ${yTop}`, - `L ${midX - outDir * CORNER} ${yTop}`, - `Q ${midX} ${yTop} ${midX} ${yTop + CORNER}`, - `L ${midX} ${yBot - CORNER}`, - `Q ${midX} ${yBot} ${midX + inDir * CORNER} ${yBot}`, - `L ${targetX - inDir * CORNER} ${yBot}`, - `Q ${targetX} ${yBot} ${targetX} ${yBot + CORNER}`, - `L ${targetX} ${targetY}` - ].join(' ') + const bend = NODE.gap.vertical + const yTop = sourceY + bend + // Clamp only downward edges: an upward (feedback) edge must still run up the + // lane, or its last curve cuts across the node the detour was clearing. + const yBot = targetY > sourceY ? Math.max(yTop, targetY - bend) : targetY - bend + const [out] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX: midX, + targetY: yTop, + targetPosition, + curvature: 0.25 + }) + const [into] = getBezierPath({ + sourceX: midX, + sourceY: yBot, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature: 0.25 + }) + // Continue from the lane rather than lifting the pen. + return `${out} L${midX},${yBot} ${into.replace(/^M/, 'L')}` } // Long edges bend out of the source within the layer gap and descend in @@ -178,7 +188,7 @@ path={edgePath} {markerEnd} {style} - interactionWidth={0} + interactionWidth={12} label={undefined} labelStyle={undefined} /> diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index e719bfe2bd..d0835b2880 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -2,9 +2,14 @@ import { Handle, Position } from '@xyflow/svelte' import { twMerge } from 'tailwind-merge' import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte' - import { formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib' - import { NODE } from '$lib/components/graph/util' - import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte' + import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib' + import PipelineNodeCard from './PipelineNodeCard.svelte' + import NodeActionsMenu from './NodeActionsMenu.svelte' + import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte' + import NodeOnDot from './NodeOnDot.svelte' + import { PIPELINE_NODE_HEADER } from './assetGraphLayout' + import AddDownstreamMenu from './AddDownstreamMenu.svelte' + import AddDownstreamPill from './AddDownstreamPill.svelte' import { ArrowUpRight, Code2, @@ -12,19 +17,28 @@ History, Play, Loader2, - Plus, ShieldCheck, ShieldAlert, CheckCircle2, + ChevronDown, + Pencil, + CircleSlash, + Trash2, + Upload, XCircle } from 'lucide-svelte' + import { Button } from '$lib/components/common' import type { ScriptLang } from '$lib/gen' import { enterpriseLicense } from '$lib/stores' - import { sendUserToast } from '$lib/utils' - import { PIPELINE_LANGUAGES } from './pipelineLanguages' + import { sendUserToast, type Item } from '$lib/utils' import type { PipelineOutputKind } from './pipelineTemplates' import type { DbtAssetProvenance } from './types' import DbtIcon from '$lib/components/icons/DbtIcon.svelte' + import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte' + import RunStateChip from './RunStateChip.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { upstreamScriptItems } from './upstreamScriptItems' + import type { RunnableRunState } from './activeRunnables.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -33,6 +47,49 @@ // `content` / `language` so the page-level run handler can dispatch to // `runScriptPreview` instead of `runScriptByPath` (which 404s for // non-deployed scripts). + /** What starts a producing script, as its chip shows it. */ + export type TriggerChipData = { + label: string + /** The native trigger kind; a data upload renders as a call to action. */ + kind?: string + /** No chip: the node's incoming edges already show what starts it. */ + hidden?: boolean + missing?: boolean + draft?: boolean + /** Opens the trigger's editor; unset for what has none (manual, asset change). */ + onOpen?: () => void + selected?: boolean + } + + export type AssetUpstreamChips = + | { none: true } + | { + multiple: true + /** The producing scripts; one when only its triggers are ambiguous. */ + scripts: Array<{ + runnableId: string + path: string + summary?: string + language?: ScriptLang + unsaved?: boolean + onOpen: () => void + }> + /** The scripts' own triggers, each named with its script. */ + triggers: Array<TriggerChipData & { script: string }> + selected?: boolean + } + | { + runnableId: string + path: string + summary?: string + language?: ScriptLang + unsaved?: boolean + runState?: RunnableRunState + trigger: TriggerChipData + onOpen: () => void + selected?: boolean + } + export type AssetProducer = { kind: 'script' | 'flow' path: string @@ -51,6 +108,8 @@ // "current view of <dim>" marker so it reads as a derived node, not an // unrelated table. derived_from?: string + // Why the node is misconfigured, when it is: renders it red. + error?: string // dbt provenance when this warehouse table is a dbt node: which model // it is, how dbt materializes it, its tags and its generic tests. dbt?: DbtAssetProvenance @@ -65,10 +124,14 @@ language: ScriptLang, scriptPath: string, outputKind: PipelineOutputKind, - aiPrompt?: string + aiPrompt?: string, + options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions ) => void pathPrefix?: string defaultPathSuffix?: string + /** Starts dragging the "+" onto a script (subscribing it to this asset) + * or onto the blank canvas (opening the add menu there). */ + onStartFeedDrag?: (e: PointerEvent) => void // Producer scripts/flows that write to this asset, supplied by // the canvas from the graph's write/rw edges. Drives the on-hover // "Run" button. Includes unsaved/draft producers so the button @@ -99,6 +162,41 @@ // instead of only settling once the job ends. runStatus?: 'running' | 'materialized' | 'failed' runRowCount?: number | null + /** Set in the assets-only view, where the producing script and its + * trigger are folded into the asset: `none` for an asset nothing in the + * pipeline writes, `multiple` when the producer or its trigger is + * ambiguous. */ + upstream?: AssetUpstreamChips + /** What is wrong with the node, and how to fix it: replaces the "+". */ + fix?: NodeFix + /** Assets-only: errors of the scripts and triggers folded into this node. */ + foldedFixes?: NodeFix[] + /** Assets-only: starts dragging the top dot onto an asset, subscribing the + * scripts that build this one to it. */ + onStartOnDrag?: (e: PointerEvent) => void + /** The assets-only node for the scripts that build no asset. */ + noAsset?: boolean + /** The No-asset node's script, by its file name. */ + noAssetName?: string + /** Assets-only: the node is only its script's banner. */ + bannerOnly?: boolean + /** Assets-only: a script folded into the node has an error. */ + scriptError?: boolean + /** No script in the workspace writes it; a flow's write fires no asset trigger. */ + neverWritten?: boolean + /** Card width in the assets-only view, sized to its text. */ + width?: number + /** Assets-only view: the producing script and trigger no other asset + * shares, which the menu offers to delete one by one or together. */ + upstreamDelete?: import('./assetsOnlyView').AssetUpstreamDelete + /** When no combined delete is possible: the folded scripts, each deletable + * on its own. */ + scriptDeletes?: Array<{ path: string; unsaved: boolean }> + triggerDeletes?: Array<NonNullable<import('./assetsOnlyView').AssetUpstreamDelete['trigger']>> + onDeleteUpstream?: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void + deleteVerb?: 'Delete' | 'Archive' + /** Why nothing can be deleted, when the producer is shared. */ + deleteBlocked?: string } // SvelteFlow injects this on the node component when the user clicks // the node. Combined with our own `hovered` state to drive the @@ -140,19 +238,94 @@ } } - function handlePick(pick: PipelineInsertPick) { - if (pick.kindId === 'pipeline_script' && pick.language && pick.path) { - data.onAddScript?.( - { kind: data.asset_kind, path: data.path }, - pick.language as ScriptLang, - pick.path, - (pick.outputKind ?? 'none') as PipelineOutputKind, - pick.aiPrompt - ) - } - } - let showAdd = $derived(data.onAddScript != undefined) + + // A red asset needs fixing where its error comes from, not a downstream step; + // and a step downstream of what nothing writes would never run on its writes. + let showAdd = $derived(data.onAddScript != undefined && !data.error && !data.neverWritten) + let menuItems: Item[] = $derived( + data.onDeleteUpstream && data.upstreamDelete + ? deleteItems(data.upstreamDelete, data.onDeleteUpstream) + : data.onDeleteUpstream && (data.scriptDeletes?.length || data.triggerDeletes?.length) + ? [ + ...(data.scriptDeletes ?? []).map((script) => ({ script })), + ...(data.triggerDeletes ?? []).map((trigger) => ({ trigger })) + ].flatMap((t) => deleteItems(t, data.onDeleteUpstream!)) + : data.deleteBlocked + ? [ + { + displayName: `Can't delete: ${data.deleteBlocked}`, + icon: Trash2, + disabled: true + } + ] + : [] + ) + const lastSegment = (p: string) => p.split('/').pop() ?? p + // A script is archived rather than deleted when the user may not delete it; + // a trigger is always deleted. + function deleteItems( + t: import('./assetsOnlyView').AssetUpstreamDelete, + run: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void + ): Item[] { + const items: Item[] = [] + if (t.script) { + const script = t.script + items.push({ + displayName: `${data.deleteVerb ?? 'Delete'} script ${lastSegment(script.path)}`, + icon: Trash2, + type: 'delete' as const, + action: () => run({ script }) + }) + } + if (t.trigger) { + const trigger = t.trigger + items.push({ + displayName: `Delete ${trigger.kind} trigger ${lastSegment(trigger.path)}`, + icon: Trash2, + type: 'delete' as const, + action: () => run({ trigger }) + }) + } + if (t.script && t.trigger) { + items.push({ + displayName: `${data.deleteVerb === 'Archive' ? 'Remove' : 'Delete'} all 2 items`, + icon: Trash2, + type: 'delete' as const, + action: () => run(t) + }) + } + return items + } + let upstream = $derived(data.upstream) + let HEADER_SELECTED = $derived( + data.scriptError + ? 'bg-red-200 dark:bg-red-600 hover:bg-red-200 dark:hover:bg-red-600' + : 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent' + ) + const CHIP_SELECTED = + 'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent' + let producer = $derived(upstream && 'path' in upstream ? upstream : undefined) + let coProducers = $derived(upstream && 'scripts' in upstream ? upstream.scripts : []) + let hasHeader = $derived(!!producer || coProducers.length > 0) + // The corner controls sit on the card's corner, below the header tab: + // -top-2 plus PIPELINE_NODE_HEADER (22px). + const CARD_CORNER_TOP = 'top-[14px]' + let coSelected = $derived(!!(upstream && 'scripts' in upstream && upstream.selected)) + let coProducerItems = $derived( + upstreamScriptItems(coProducers, (path) => coProducers.find((s) => s.path === path)?.onOpen()) + ) + let scriptMenuOpen = $state(false) + let coTriggers = $derived(upstream && 'triggers' in upstream ? upstream.triggers : []) + let coTriggerItems: Item[] = $derived( + coTriggers.map((t) => ({ + displayName: `${t.label}${t.draft ? ' · draft' : ''}`, + description: coProducers.length > 1 ? t.script : undefined, + disabled: !t.onOpen, + action: () => t.onOpen?.() + })) + ) + let triggerMenuOpen = $state(false) // dbt badge. `materialized` is dbt's own word rather than the Windmill // strategy because `view` and `ephemeral` have no strategy, and showing the @@ -220,6 +393,185 @@ ) </script> +{#snippet scriptHeader()} + <!-- The script building this asset, folded into it: a strip across the top + naming it, which opens it. Several scripts open a menu to pick one. --> + {@const one = producer ?? (coProducers.length === 1 ? coProducers[0] : undefined)} + {@const headerClass = twMerge( + 'flex items-center gap-1.5 w-full min-w-0 px-2 text-3xs font-normal leading-none text-secondary hover:bg-surface-hover hover:text-primary', + data.scriptError && + 'bg-red-100 dark:bg-red-700 text-red-700 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-600 hover:text-red-800' + )} + {#if one} + {@const isSelected = producer ? !!producer.selected : coSelected} + <button + type="button" + class={twMerge(headerClass, isSelected && HEADER_SELECTED)} + title={`Open ${one.summary ? `${one.summary} (${one.path})` : one.path}${one.unsaved ? ' · draft' : ''}`} + onpointerdown={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + onclick={(e) => { + e.stopPropagation() + one.onOpen() + }} + > + {#if one.language} + <LanguageIcon lang={one.language} width={11} height={11} /> + {:else} + <Code2 size={11} class="shrink-0" /> + {/if} + <span class="truncate">{one.summary || one.path}</span> + {#if one.unsaved}<span class="shrink-0 text-tertiary">draft</span>{/if} + {#if producer?.runState} + <RunStateChip runState={producer.runState} class="ml-auto h-4 px-1 rounded" /> + {/if} + <Pencil size={10} class={twMerge('shrink-0 opacity-70', !producer?.runState && 'ml-auto')} /> + </button> + {:else if coProducers.length > 1} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="w-full min-w-0 flex" + onpointerdown={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + <DropdownV2 + items={coProducerItems} + placement="bottom-start" + bind:open={scriptMenuOpen} + fixedHeight={false} + usePointerDownOutside + enableFlyTransition + class="w-full" + > + {#snippet buttonReplacement()} + <span + class={twMerge(headerClass, 'h-full', (coSelected || scriptMenuOpen) && HEADER_SELECTED)} + title={`Built by ${coProducers.length} scripts: pick one to open`} + > + {#each coProducers.slice(0, 2) as sc (sc.path)} + {#if sc.language} + <LanguageIcon lang={sc.language} width={11} height={11} /> + {:else} + <Code2 size={11} class="shrink-0" /> + {/if} + {/each} + <span class="truncate">{coProducers.length} scripts</span> + <ChevronDown size={11} class="shrink-0 ml-auto" /> + </span> + {/snippet} + </DropdownV2> + </div> + {/if} +{/snippet} + +{#snippet upstreamRow()} + {#if data.foldedFixes?.length} + <!-- A folded script or trigger is misconfigured: its Fix, one at a time. --> + <NodeFixButton + chip + fix={data.foldedFixes[0]} + label={`${data.foldedFixes.length} error${data.foldedFixes.length === 1 ? '' : 's'} · Fix`} + /> + {:else if upstream && coTriggers.length > 1 && !coTriggers.some((t) => t.onOpen)} + <!-- Several triggers, none editable here (View mode): a plain label. --> + <span + class="truncate rounded-md border px-1.5 py-1 text-3xs leading-none font-normal border-gray-300 dark:border-gray-600 text-secondary" + >Multiple triggers</span + > + {:else if upstream && coTriggers.length > 1} + <!-- Several triggers start what builds this asset: one chip, a menu to pick + the one to edit. --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="min-w-0 flex" + onpointerdown={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + <DropdownV2 + items={coTriggerItems} + placement="bottom-start" + bind:open={triggerMenuOpen} + fixedHeight={false} + usePointerDownOutside + enableFlyTransition + > + {#snippet buttonReplacement()} + <span + class={twMerge( + 'flex items-center gap-1 min-w-0 rounded-md border px-1.5 py-1 text-3xs leading-none font-normal border-gray-300 dark:border-gray-600 text-secondary bg-surface hover:bg-surface-hover cursor-pointer', + (triggerMenuOpen || coTriggers.some((t) => t.selected)) && CHIP_SELECTED + )} + title="Started by several triggers: pick one to edit" + > + <span class="truncate">Multiple triggers</span> + <ChevronDown size={9} class="shrink-0 opacity-70" /> + </span> + {/snippet} + </DropdownV2> + </div> + {:else if upstream} + {@const trigger = producer?.trigger ?? coTriggers[0]} + {@const triggerScript = producer?.path ?? coTriggers[0]?.script} + {@const chipClass = twMerge( + 'truncate rounded-md border px-1.5 py-1 text-3xs leading-none font-normal', + trigger?.missing + ? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300' + : 'border-gray-300 dark:border-gray-600 text-secondary', + trigger?.draft && 'border-dashed', + trigger?.onOpen && 'bg-surface hover:bg-surface-hover cursor-pointer', + trigger?.selected && CHIP_SELECTED + )} + {#if trigger?.kind === 'data_upload' && trigger.onOpen} + <!-- The one trigger that needs the user: nothing runs until a file is + provided, so it reads as the node's main action rather than a chip. --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <span + class="flex min-w-0" + onpointerdown={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + <Button + variant="accent" + unifiedSize="sm" + startIcon={{ icon: Upload }} + title={`Upload the data ${triggerScript} runs on`} + onClick={(e) => { + e?.stopPropagation() + trigger.onOpen?.() + }} + > + Upload data + </Button> + </span> + {:else if trigger?.onOpen} + <button + type="button" + class={twMerge(chipClass, 'flex items-center gap-1 min-w-0')} + title={trigger.missing + ? `No ${trigger.label} trigger targets ${triggerScript} yet: click to create one` + : `Edit the ${trigger.label} trigger`} + onpointerdown={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + onclick={(e) => { + e.stopPropagation() + trigger.onOpen?.() + }} + > + <span class="truncate">{trigger.label}{trigger.draft ? ' · draft' : ''}</span> + {#if !trigger.missing}<Pencil size={9} class="shrink-0 opacity-70" />{/if} + </button> + {:else} + <span class={chipClass}> + {#if 'none' in upstream} + External source + {:else if trigger} + {trigger.label}{trigger.draft ? ' · draft' : ''} + {/if} + </span> + {/if} + {/if} +{/snippet} + <!-- onmouseenter/leave on the wrapper (not the inner card) so the run button — which floats outside the card — keeps the hover state alive when the cursor moves between the card and the button. --> @@ -236,123 +588,169 @@ <div class="wm-recompute-flash pointer-events-none absolute inset-0 z-10 rounded-md"></div> {/key} {/if} - <!-- Mirrors the flow editor's asset pill: quiet surface + gray border at - rest, accent reserved for the selected state. --> - <div - class={twMerge( - 'flex items-center rounded-md drop-shadow-sm overflow-hidden border', - 'bg-surface-secondary border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500 transition-colors', - selected && 'bg-surface-accent-selected border-border-selected' + <NodeActionsMenu + items={menuItems} + hover={hovered} + kebabClass={twMerge( + showGuardBadge && '-right-8', + hasHeader && !data.bannerOnly && CARD_CORNER_TOP )} - style="width: {NODE.width}px; min-height: {NODE.height}px;" - title={data.path} > - <!-- Data identity carries the accent (luminance blue), pairing with - the blue write edges that produce these assets — scripts stay - neutral, so script vs data reads at a glance. --> - <AssetGenericIcon - assetKind={data.asset_kind} - class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-blue-600 dark:text-blue-400'}`} - size="14px" - /> - {#if data.runStatus} - <!-- The run in view, per relation: a spinner while its producer is - building it, then its outcome. Left of the name so the eye finds - the moving nodes first on a wide graph. --> - <span - class="shrink-0 mr-1 {data.runStatus === 'failed' - ? 'text-red-600 dark:text-red-400' - : data.runStatus === 'materialized' - ? 'text-green-600 dark:text-green-400' - : 'text-blue-600 dark:text-blue-400'}" - title={data.runStatus} + {#if data.bannerOnly} + <!-- A script that builds nothing and runs only on writes to what it reads: + the incoming edges say how it starts, so its banner is the whole node. --> + <div + class={twMerge( + 'flex items-stretch min-w-0 rounded-md overflow-hidden bg-surface border border-gray-200 dark:border-gray-700 drop-shadow-sm', + (producer ? producer.selected : coSelected) && + 'border-border-selected dark:border-border-selected' + )} + style="width: {data.width ?? 160}px; height: {PIPELINE_NODE_HEADER}px;" > - {#if data.runStatus === 'running'} - <Loader2 size={11} class="animate-spin" /> - {:else if data.runStatus === 'failed'} - <XCircle size={11} /> + {@render scriptHeader()} + </div> + {:else} + <PipelineNodeCard + kindLabel={data.noAsset ? 'No asset' : formatAssetKind(asset)} + title={data.noAsset ? (data.noAssetName ?? '') : formatShortAssetPath(asset)} + tooltip={data.noAsset + ? 'This script builds no asset' + : data.error + ? `${data.path}: ${data.error}` + : data.path} + {selected} + tone={data.error ? 'error' : undefined} + subtitle={data.foldedFixes?.length || + (upstream && + !producer?.trigger?.hidden && + !('triggers' in upstream && upstream.triggers.length === 0)) + ? upstreamRow + : undefined} + header={hasHeader ? scriptHeader : undefined} + headerTone={data.scriptError ? 'error' : undefined} + headerSelected={producer ? !!producer.selected : coSelected} + width={data.width} + surface={upstream ? 'primary' : 'secondary'} + > + {#snippet icon()} + <!-- Data identity carries the accent (luminance blue), pairing with + the blue write edges that produce these assets — scripts stay + neutral, so script vs data reads at a glance. --> + {#if data.noAsset} + <CircleSlash size={14} class="text-tertiary" /> {:else} - <CheckCircle2 size={11} /> + <AssetGenericIcon + assetKind={data.asset_kind} + class={data.error + ? 'text-red-600 dark:text-red-200' + : selected + ? 'text-accent' + : 'text-blue-600 dark:text-blue-400'} + size="14px" + /> {/if} - </span> - <!-- Rows the run wrote. Recorded per relation already, and the cheapest - answer to "did this model actually produce anything" — a model that - built green but emitted 0 rows is the failure that looks like a - success. Only once settled: mid-build the number is not yet real. --> - {#if data.runStatus === 'materialized' && data.runRowCount != undefined} - <span - class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary" - title="{data.runRowCount} rows written by this run" - > - {Intl.NumberFormat().format(data.runRowCount)} - </span> - {/if} - {/if} - <span class="flex-1 min-w-0 pr-1 py-0.5 text-2xs font-mono text-emphasis truncate"> - {formatShortAssetPath(asset)} - </span> - <!-- Fork data-environment chip: in a fork every asset shares its parent's - name, so the env it resolves to must read at a glance. Labeled + tinted - (amber "parent" = deferred read of the parent's current table via a - view; emerald "fork" = the fork's own materialized copy) rather than a - bare icon, which was too easy to miss. The title carries the detail. --> - {#if data.fork_materialization === 'deferred'} - <span - class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700" - title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it." - > - <ArrowUpRight size={10} /> - parent - </span> - {:else if data.fork_materialization === 'fork'} - <span - class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700" - title="Materialized in this fork: reads and writes use the fork's isolated copy." - > - <GitFork size={10} /> - fork - </span> - {/if} - <!-- dbt chip: names the materialization dbt declares, plus a test count - when the model carries generic tests. Orange keeps it visually - separate from the fork/SCD2 chips, which describe Windmill state. - Where the project node is on the graph it also stands in for the edge - to it: hovering lights that node up, clicking selects it. Where it is - not — the run page, and a pipeline holding a dbt project — there is - nothing to point at, so the chip renders inert. --> - {#if data.dbt} - <button - type="button" - class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect - ? 'hover:brightness-95 cursor-pointer' - : 'cursor-default'}" - title={dbtTitle} - onmouseenter={() => data.onDbtHover?.(true)} - onmouseleave={() => data.onDbtHover?.(false)} - onclick={(e) => { - e.stopPropagation() - data.onDbtSelect?.() - }} - > - <DbtIcon width={9} height={9} /> - {dbtLabel} - {#if data.dbt.data_tests?.length} - <span class="opacity-70">· {data.dbt.data_tests.length}T</span> + {/snippet} + {#snippet leading()} + {#if data.runStatus} + <!-- The run in view, per relation: a spinner while its producer is + building it, then its outcome. Left of the name so the eye finds + the moving nodes first on a wide graph. --> + <span + class="shrink-0 mr-1 {data.runStatus === 'failed' + ? 'text-red-600 dark:text-red-400' + : data.runStatus === 'materialized' + ? 'text-green-600 dark:text-green-400' + : 'text-blue-600 dark:text-blue-400'}" + title={data.runStatus} + > + {#if data.runStatus === 'running'} + <Loader2 size={11} class="animate-spin" /> + {:else if data.runStatus === 'failed'} + <XCircle size={11} /> + {:else} + <CheckCircle2 size={11} /> + {/if} + </span> + <!-- Rows the run wrote. Recorded per relation already, and the cheapest + answer to "did this model actually produce anything" — a model that + built green but emitted 0 rows is the failure that looks like a + success. Only once settled: mid-build the number is not yet real. --> + {#if data.runStatus === 'materialized' && data.runRowCount != undefined} + <span + class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary" + title="{data.runRowCount} rows written by this run" + > + {Intl.NumberFormat().format(data.runRowCount)} + </span> + {/if} {/if} - </button> + {/snippet} + {#snippet trailing()} + <!-- Fork data-environment chip: in a fork every asset shares its parent's + name, so the env it resolves to must read at a glance. Labeled + tinted + (amber "parent" = deferred read of the parent's current table via a + view; emerald "fork" = the fork's own materialized copy) rather than a + bare icon, which was too easy to miss. The title carries the detail. --> + {#if data.fork_materialization === 'deferred'} + <span + class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700" + title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it." + > + <ArrowUpRight size={10} /> + parent + </span> + {:else if data.fork_materialization === 'fork'} + <span + class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700" + title="Materialized in this fork: reads and writes use the fork's isolated copy." + > + <GitFork size={10} /> + fork + </span> + {/if} + <!-- dbt chip: names the materialization dbt declares, plus a test count + when the model carries generic tests. Orange keeps it visually + separate from the fork/SCD2 chips, which describe Windmill state. + Where the project node is on the graph it also stands in for the edge + to it: hovering lights that node up, clicking selects it. Where it is + not — the run page, and a pipeline holding a dbt project — there is + nothing to point at, so the chip renders inert. --> + {#if data.dbt} + <button + type="button" + class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect + ? 'hover:brightness-95 cursor-pointer' + : 'cursor-default'}" + title={dbtTitle} + onmouseenter={() => data.onDbtHover?.(true)} + onmouseleave={() => data.onDbtHover?.(false)} + onclick={(e) => { + e.stopPropagation() + data.onDbtSelect?.() + }} + > + <DbtIcon width={9} height={9} /> + {dbtLabel} + {#if data.dbt.data_tests?.length} + <span class="opacity-70">· {data.dbt.data_tests.length}T</span> + {/if} + </button> + {/if} + <!-- SCD2 companion marker: this node is the `<dim>_current` "latest row + per key" view its producer maintains alongside the base dimension. + Icon-only (the pill already truncates); the title names the base. --> + {#if data.derived_from} + <span + class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400" + title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`} + > + <History size={12} /> + </span> + {/if} + {/snippet} + </PipelineNodeCard> {/if} - <!-- SCD2 companion marker: this node is the `<dim>_current` "latest row - per key" view its producer maintains alongside the base dimension. - Icon-only (the pill already truncates); the title names the base. --> - {#if data.derived_from} - <span - class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400" - title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`} - > - <History size={12} /> - </span> - {/if} - </div> + </NodeActionsMenu> {#if showGuardBadge} <!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the left-edge run button and the bottom + inserter) so it never collides @@ -361,6 +759,7 @@ <div class={twMerge( 'absolute -top-2 -right-2 z-10 rounded-full w-5 h-5 grid place-items-center border shadow-sm', + hasHeader && CARD_CORNER_TOP, guardClass )} title={guardTitle} @@ -376,7 +775,13 @@ cards. Drafts are runnable too via runScriptPreview, so no greyed-out state — the page-supplied callback handles the dispatch. --> - <div class="absolute -left-3 top-1/2 -translate-y-1/2 z-10"> + <!-- Centred on the card, below the header tab when there is one. --> + <div + class={twMerge( + 'absolute -left-3 top-1/2 -translate-y-1/2 z-10', + hasHeader && !data.bannerOnly && 'top-[calc(50%+11px)]' + )} + > <button type="button" onclick={runProducers} @@ -398,53 +803,47 @@ </button> </div> {/if} - {#if showAdd} - <!-- Always-visible + below the asset for downstream pipeline-script - creation. Half-overlapping the bottom edge so it visually attaches - to the node like the flow editor's between-step inserter. --> + {#if data.onStartOnDrag} + <NodeOnDot onStart={data.onStartOnDrag} visible={hovered} /> + {/if} + {#if data.fix} + <!-- A red node's way out replaces its "+": adding downstream of a broken + node is not what it needs. --> <div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10"> - <PipelineInsertMenu - kinds={[ - { - id: 'pipeline_script', - label: 'Add downstream pipeline script', - description: 'Triggered when this asset changes', - icon: Code2 - } - ]} - languages={PIPELINE_LANGUAGES as any} - pathPrefix={data.pathPrefix ?? ''} - defaultPathSuffix={data.defaultPathSuffix ?? ''} - onPick={handlePick} + <NodeFixButton fix={data.fix} /> + </div> + {:else if showAdd} + <!-- Always-visible + on the asset's bottom edge for downstream pipeline- + script creation, half-overlapping it like the flow editor's + between-step inserter. --> + <div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10"> + <AddDownstreamMenu + asset={{ kind: data.asset_kind, path: data.path }} + onAddScript={data.onAddScript!} + pathPrefix={data.pathPrefix} + defaultPathSuffix={data.defaultPathSuffix} > - {#snippet trigger()} - <!-- - Sizing notes for the round + button: - - w-6/h-6 (24px) chosen so that with border-2 (2px each - side) the inner area is exactly 20px — divisible by - the 16px icon to leave a 2px gap on every side. At - 20px / 12px before, the gap was 4px-each but the - odd rounding interacted badly with svelte-flow's - fractional zoom transforms and the icon drifted - half a pixel off-center on certain zoom levels. - - `grid place-items-center` instead of flex centering: - flex's baseline alignment introduces a sub-pixel - nudge on small elements that grid avoids. - - `leading-none` strips the default line-height - contribution that the SVG inherits via the parent's - text rendering, otherwise the icon is shifted - downward by ~0.5px at fractional zooms. - --> + {#snippet trigger({ open })} + <!-- A click opens the menu; a drag goes to the canvas, which + tells a real drag from a click by how far the pointer moves. --> <button type="button" + class="nopan nodrag block" onclick={(e) => e.stopPropagation()} - class="bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:bg-surface-hover rounded-full w-6 h-6 grid place-items-center shadow-sm leading-none" - title="Add downstream pipeline script" + onpointerdown={(e) => { + if (e.button !== 0) return + e.preventDefault() + data.onStartFeedDrag?.(e) + }} + aria-label="Add downstream step" + title={data.onStartFeedDrag + ? 'Click to add a downstream step, or drag onto a script to run it after each write' + : undefined} > - <Plus size={16} strokeWidth={2.5} /> + <AddDownstreamPill expanded={open} /> </button> {/snippet} - </PipelineInsertMenu> + </AddDownstreamMenu> </div> {/if} </div> diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte index 7cee1deb76..7d55da04a1 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte @@ -15,7 +15,6 @@ import LogViewer from '$lib/components/LogViewer.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' import JobArgs from '$lib/components/JobArgs.svelte' - import { base } from '$lib/base' import { Badge } from '$lib/components/common' import { Popover } from '$lib/components/meltComponents' import { twMerge } from 'tailwind-merge' @@ -288,7 +287,7 @@ </span> <a class="text-3xs text-blue-600 hover:underline shrink-0" - href={`${base}/run/${selectedJob.id}?workspace=${$operatingWorkspace}`} + href={`/run/${selectedJob.id}?workspace=${$operatingWorkspace}`} target="_blank">Open ↗</a > {#if $operatingWorkspace} diff --git a/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte b/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte index 5f5dc98b60..5391c7caff 100644 --- a/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte @@ -16,7 +16,6 @@ import { parseDbInputFromAssetSyntax } from '$lib/utils' import { AlertTriangle, Database, Loader2, Settings } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' - import { base } from '$lib/base' import { twMerge } from 'tailwind-merge' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -180,7 +179,7 @@ </span> </div> <Button - href="{base}/workspace_settings?tab=windmill_data_tables" + href="/workspace_settings?tab=windmill_data_tables" variant="accent" unifiedSize="sm" startIcon={{ icon: Settings }} diff --git a/frontend/src/lib/components/assets/AssetGraph/DucklakeAssetPanel.svelte b/frontend/src/lib/components/assets/AssetGraph/DucklakeAssetPanel.svelte index 5f7ad050e4..65bd86258d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/DucklakeAssetPanel.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/DucklakeAssetPanel.svelte @@ -11,7 +11,8 @@ // can't be read), so a selectable version always exists in the table. import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { Table2, History, Columns3 } from 'lucide-svelte' + import { Table2, History, Columns3, Database } from 'lucide-svelte' + import DBManagerContent from '$lib/components/DBManagerContent.svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' import { resource } from 'runed' import { fetchDucklakeSnapshots } from '$lib/components/dbOps' @@ -31,7 +32,7 @@ } let { path, workspace, schemaCanEvolve = true }: Props = $props() - let tab = $state<'partitions' | 'history' | 'schema'>('partitions') + let tab = $state<'data' | 'partitions' | 'history' | 'schema'>('data') // The user's explicit snapshot pick (undefined until they click a row). let selectedVersion = $state<number | undefined>(undefined) @@ -72,24 +73,28 @@ </script> <div class="flex flex-col h-full"> - {#if !qualifiedTable} - <!-- Catalog-level ducklake node (no table segment, e.g. `ducklake://main`): - snapshot history / time-travel are per-table, so only the partition - grid applies here. --> - <PartitionStatusGrid {path} {workspace} /> - {:else} - <div class="flex items-center gap-2 px-3 py-2 border-b shrink-0"> - <ToggleButtonGroup selected={tab} on:selected={(e) => (tab = e.detail)}> - {#snippet children({ item })} - <ToggleButton size="sm" value="partitions" label="Partitions" icon={Table2} {item} /> + <div class="flex items-center gap-2 px-3 py-2 border-b shrink-0"> + <ToggleButtonGroup selected={tab} on:selected={(e) => (tab = e.detail)}> + {#snippet children({ item })} + <ToggleButton size="sm" value="data" label="Data" icon={Database} {item} /> + <ToggleButton size="sm" value="partitions" label="Partitions" icon={Table2} {item} /> + <!-- Snapshot history and time-travel are per table: a catalog-level node + (`ducklake://main`, no table segment) has only its data and partitions. --> + {#if qualifiedTable} <ToggleButton size="sm" value="schema" label="Schema" icon={Columns3} {item} /> <ToggleButton size="sm" value="history" label="History" icon={History} {item} /> - {/snippet} - </ToggleButtonGroup> - </div> + {/if} + {/snippet} + </ToggleButtonGroup> + </div> - <div class="flex-1 min-h-0"> - {#if tab === 'partitions'} + <div class="flex-1 min-h-0"> + {#if tab === 'data' && parsed} + <!-- Keyed on the asset: the manager opens its table once, on mount. --> + {#key path} + <DBManagerContent input={parsed} {workspace} /> + {/key} + {:else if tab === 'partitions' || !qualifiedTable} <PartitionStatusGrid {path} {workspace} /> {:else if tab === 'schema'} <SchemaHistoryPanel {path} {workspace} canEvolve={schemaCanEvolve} /> @@ -117,7 +122,6 @@ </Pane> </Splitpanes> </div> - {/if} - </div> - {/if} + {/if} + </div> </div> diff --git a/frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte b/frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte index eea1fb306d..2f8c0cdfe7 100644 --- a/frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/InitialFitView.svelte @@ -25,6 +25,8 @@ if (!nodesInitialized.current) return if (!nodes.some((n) => n.type !== 'add')) return fittedFor = fitKey - void fitView({ padding: 0.1, maxZoom: 1 }) + // A re-fit over nodes that keep their ids finds them already measured, one + // render before their new positions reach the flow. + requestAnimationFrame(() => void fitView({ padding: 0.1, maxZoom: 1 })) }) </script> diff --git a/frontend/src/lib/components/assets/AssetGraph/NodeActionsMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/NodeActionsMenu.svelte new file mode 100644 index 0000000000..dcb32aa00e --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/NodeActionsMenu.svelte @@ -0,0 +1,68 @@ +<!-- +@component +A pipeline node's actions, reachable two ways: the kebab revealed on hover at the +node's top-right corner, and a right-click menu over the node carrying the same +items. Place it inside the node's `relative` wrapper, around the node's card. +--> +<script lang="ts"> + import type { Snippet } from 'svelte' + import { twMerge } from 'tailwind-merge' + import { EllipsisVertical } from 'lucide-svelte' + import { stopPropagation, preventDefault } from 'svelte/legacy' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import ContextMenu from '$lib/components/common/contextmenu/ContextMenu.svelte' + import { contextMenuItemsFromMenu } from '$lib/components/common/contextmenu/fromMenuItems' + import type { Item } from '$lib/utils' + + interface Props { + items: Item[] + /** The pointer is over the node: shows the kebab. */ + hover: boolean + /** Moves the kebab, for a node with its own badge on that corner. */ + kebabClass?: string + children: Snippet + } + + let { items, hover, kebabClass, children }: Props = $props() + + let menuOpen = $state(false) + let contextItems = $derived(contextMenuItemsFromMenu(items)) +</script> + +{#if items.length > 0} + <ContextMenu items={contextItems}> + {@render children()} + </ContextMenu> + <!-- Rendered only on hover or while open, so the canvas stays clean at rest. + `pointerdown` is stopped so svelte-flow doesn't select the node when the + user reaches for the menu. --> + <div + class={twMerge('absolute -top-2 -right-2 h-7 p-1 min-w-7', kebabClass)} + style="will-change: transform;" + > + <DropdownV2 + {items} + placement="bottom-end" + bind:open={menuOpen} + fixedHeight={false} + usePointerDownOutside + enableFlyTransition + > + {#snippet buttonReplacement()} + <button + class={twMerge( + 'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary', + hover || menuOpen ? 'block' : '!hidden', + 'shadow-md rounded-md' + )} + onpointerdown={stopPropagation(preventDefault(() => {}))} + title="Actions" + > + <EllipsisVertical size={12} /> + </button> + {/snippet} + </DropdownV2> + </div> +{:else} + {@render children()} +{/if} diff --git a/frontend/src/lib/components/assets/AssetGraph/NodeFixButton.svelte b/frontend/src/lib/components/assets/AssetGraph/NodeFixButton.svelte new file mode 100644 index 0000000000..6e3ba8b8ee --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/NodeFixButton.svelte @@ -0,0 +1,124 @@ +<!-- +@component +The "Fix" pill on the bottom edge of a misconfigured (red) pipeline node: what is +wrong, and the actions that fix it. Styled like the node's "+" pill; the node's +red fill already says something is wrong. +--> +<script lang="ts" module> + export type NodeFixAction = { + label: string + /** The side effect, spelled out: what changes, where. */ + detail?: string + /** `anchor`: where the Fix pill sits, for an action opening a menu there. */ + run: (anchor: { x: number; y: number }) => void + } + /** Prose, with the entities it names as links that select them. */ + export type FixExplainer = Array<string | { text: string; onClick: () => void }> + export type NodeFix = { explainer: FixExplainer; actions: NodeFixAction[] } + + /** A fix as its supplier declares it: an action, or opening the schedule + * wizard (which the canvas owns). */ + export type NodeFixSpec = { + explainer: FixExplainer + actions: Array<{ + label: string + detail?: string + run?: () => void + /** Opens the schedule wizard for this script; its confirm calls `onSchedule`. */ + scheduleFor?: { + script: string + onSchedule: (s: import('./PipelineInsertMenu.svelte').PipelineInsertSchedule) => void + } + }> + } +</script> + +<script lang="ts"> + import Popover from '$lib/components/meltComponents/Popover.svelte' + import { Button } from '$lib/components/common' + import { Wrench } from 'lucide-svelte' + + let { + fix, + label = 'Fix', + chip = false + }: { + fix: NodeFix + label?: string + /** A small chip in a node's text, rather than the pill on its edge. */ + chip?: boolean + } = $props() + + let open = $state(false) + let anchorEl: HTMLElement | undefined = $state() +</script> + +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + bind:this={anchorEl} + onpointerdown={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + onclick={(e) => e.stopPropagation()} +> + <Popover + bind:isOpen={open} + placement="bottom" + enableFlyTransition + usePointerDownOutside + contentClasses="p-3 w-96" + > + {#snippet trigger()} + {#if chip} + <span + class="flex items-center gap-1 rounded-md border px-1.5 py-1 text-3xs leading-none font-normal whitespace-nowrap bg-surface border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 hover:bg-surface-hover" + > + <Wrench size={10} class="shrink-0" /> + {label} + </span> + {:else} + <span + class="h-6 px-2 rounded-full flex items-center gap-1 bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm text-2xs font-normal leading-none whitespace-nowrap" + > + <Wrench size={12} class="shrink-0" /> + {label} + </span> + {/if} + {/snippet} + {#snippet content()} + <div class="flex flex-col gap-3"> + <p class="text-xs text-primary"> + {#each fix.explainer as part} + {#if typeof part === 'string'}{part}{:else}<button + type="button" + class="text-accent hover:underline" + onclick={() => { + open = false + part.onClick() + }}>{part.text}</button + >{/if} + {/each} + </p> + <div class="flex flex-col gap-2"> + {#each fix.actions as action, i (action.label)} + <div class="flex flex-col gap-0.5"> + <Button + variant={i === 0 ? 'accent' : 'default'} + unifiedSize="sm" + onclick={() => { + const r = anchorEl?.getBoundingClientRect() + open = false + action.run({ x: r ? r.left + r.width / 2 : 0, y: r ? r.bottom : 0 }) + }} + > + {action.label} + </Button> + {#if action.detail} + <span class="text-2xs text-secondary">{action.detail}</span> + {/if} + </div> + {/each} + </div> + </div> + {/snippet} + </Popover> +</div> diff --git a/frontend/src/lib/components/assets/AssetGraph/NodeOnDot.svelte b/frontend/src/lib/components/assets/AssetGraph/NodeOnDot.svelte new file mode 100644 index 0000000000..25d84b9197 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/NodeOnDot.svelte @@ -0,0 +1,23 @@ +<!-- +@component +The dot on a pipeline node's top edge: dragged onto an asset, it makes the node's +script run after each write to that asset. +--> +<script lang="ts"> + let { onStart, visible }: { onStart: (e: PointerEvent) => void; visible: boolean } = $props() +</script> + +<button + type="button" + class="absolute -top-1.5 left-1/2 -translate-x-1/2 z-10 w-3 h-3 rounded-full bg-surface border-2 border-blue-500 cursor-crosshair transition-opacity {visible + ? 'opacity-100' + : 'opacity-0'}" + title="Drag onto an asset to run after each write to it" + aria-label="Drag onto an asset to run after each write to it" + onpointerdown={(e) => { + e.stopPropagation() + e.preventDefault() + onStart(e) + }} + onclick={(e) => e.stopPropagation()} +></button> diff --git a/frontend/src/lib/components/assets/AssetGraph/PanToNode.svelte b/frontend/src/lib/components/assets/AssetGraph/PanToNode.svelte index 83df60b340..22acd606cb 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PanToNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PanToNode.svelte @@ -2,6 +2,7 @@ import { useSvelteFlow, type Node } from '@xyflow/svelte' import { untrack } from 'svelte' import { NODE } from '$lib/components/graph/util' + import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout' // Smoothly pans the viewport so a freshly-created node lands in the middle, // keeping the current zoom. Lives inside <SvelteFlow> so useSvelteFlow() has @@ -27,8 +28,8 @@ untrack(() => { const node = ns.find((n) => n.id === id) if (!node) return // not laid out yet — re-runs when `nodes` updates - const cx = node.position.x + NODE.width / 2 - const cy = node.position.y + NODE.height / 2 + const cx = node.position.x + (node.measured?.width ?? NODE.width) / 2 + const cy = node.position.y + PIPELINE_NODE_HEIGHT / 2 const key = `${id}:${cx}:${cy}` if (key === lastKey) return lastKey = key diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineActivityPanel.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineActivityPanel.svelte index 7e6c34adc2..27a3253d0e 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineActivityPanel.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineActivityPanel.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { workspaceStore } from '$lib/stores' import type { Job } from '$lib/gen' import Select from '$lib/components/select/Select.svelte' @@ -416,7 +415,7 @@ onmouseenter={() => onHoverRun?.([e.path])} onmouseleave={() => onHoverRun?.(undefined)} class={twMerge( - 'w-full flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-surface-hover text-xs text-left', + 'w-full flex items-center gap-2 px-2 py-1.5 rounded-sm font-normal hover:bg-surface-hover text-xs text-left', isMember && 'pl-7', expanded && 'bg-surface-selected' )} @@ -445,7 +444,7 @@ {:else} <Code2 size={12} class="shrink-0 text-tertiary" /> {/if} - <span class="flex-1 min-w-0 truncate font-mono text-2xs" title={e.path}> + <span class="flex-1 min-w-0 truncate text-xs" title={e.path}> {e.path} </span> {#if showSource} @@ -477,7 +476,7 @@ <span class="font-mono truncate flex-1 min-w-0">{expandedJob.id}</span> <a class="text-blue-600 hover:underline shrink-0" - href={`${base}/run/${expandedJob.id}?workspace=${$workspaceStore}`} + href={`/run/${expandedJob.id}?workspace=${$workspaceStore}`} target="_blank">Open ↗</a > </div> @@ -533,7 +532,7 @@ onclick={() => toggleGroup(g.key)} onmouseenter={() => onHoverRun?.(g.members.map((m) => m.path))} onmouseleave={() => onHoverRun?.(undefined)} - class="w-full flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-surface-hover text-xs text-left" + class="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm font-normal hover:bg-surface-hover text-xs text-left" title={collapsed ? 'Expand cascade runs' : 'Collapse cascade runs'} > <span class="shrink-0 text-tertiary"> @@ -549,7 +548,7 @@ {/if} </span> <Workflow size={12} class="shrink-0 text-indigo-500" /> - <span class="flex-1 min-w-0 truncate font-mono text-2xs" title={groupTitle(g)}> + <span class="flex-1 min-w-0 truncate text-xs" title={groupTitle(g)}> {groupTitle(g)} </span> <span diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineDbtSignpost.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineDbtSignpost.svelte deleted file mode 100644 index 495e9a34bd..0000000000 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineDbtSignpost.svelte +++ /dev/null @@ -1,40 +0,0 @@ -<script lang="ts"> - import { base } from '$lib/base' - import Alert from '$lib/components/common/alert/Alert.svelte' - import Button from '$lib/components/common/button/Button.svelte' - import DbtIcon from '$lib/components/icons/DbtIcon.svelte' - import { BookOpen } from 'lucide-svelte' - - const DBT_DOCS = 'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt' -</script> - -<Alert type="info" title="Prefer using dbt? Windmill also runs dbt" size="xs"> - <div class="flex flex-col gap-2"> - <p> - A pipeline is Windmill's own abstraction; an existing dbt project stays a dbt project, and - runs here unchanged as its own kind of script, with dbt still owning its models, refs and - tests. All it needs is a warehouse configured under - <a class="underline" href="{base}/workspace_settings?tab=dbt">Settings → dbt</a>. Its models - show up in the asset graph as <span class="font-mono">dbt://</span> nodes, visible next to whatever - pipelines you do build, so you can adopt pipelines later, or never. - </p> - <div class="flex flex-row flex-wrap items-center gap-2"> - <!-- The dbt mark goes in as a child rather than `startIcon`: that slot - sizes its icon with lucide's `size`, which DbtIcon has no prop for. --> - <Button variant="default" unifiedSize="sm" href="{base}/scripts/add?lang=dbt"> - <DbtIcon width={14} height={14} /> - New dbt script - </Button> - <Button - variant="subtle" - unifiedSize="sm" - href={DBT_DOCS} - target="_blank" - rel="noreferrer" - startIcon={{ icon: BookOpen }} - > - dbt documentation - </Button> - </div> - </div> -</Alert> diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineDeployErrors.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineDeployErrors.svelte new file mode 100644 index 0000000000..6206ebdf33 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineDeployErrors.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import { AlertTriangle } from 'lucide-svelte' + import Popover from '$lib/components/meltComponents/Popover.svelte' + + // Which drafts failed to deploy and why, anchored next to the deploy button so + // the reasons are one click away without leaving the editor. Deployed drafts + // leave the map; what is listed is still unresolved. + let { + errors, + open = $bindable() + }: { + errors: ReadonlyMap<string, string> + open?: boolean + } = $props() +</script> + +<Popover + placement="bottom-end" + contentClasses="p-3 max-w-[480px]" + usePointerDownOutside + enableFlyTransition + bind:isOpen={open} +> + {#snippet trigger()} + <button + type="button" + class="flex items-center gap-1.5 px-2 py-1 rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-xs font-medium" + title="View save errors" + > + <AlertTriangle size={14} /> + <span>{errors.size} failed</span> + </button> + {/snippet} + {#snippet content()} + <div class="flex flex-col gap-2"> + <span class="text-xs font-semibold text-emphasis">Save errors</span> + <div class="flex flex-col gap-2 max-h-72 overflow-y-auto"> + {#each [...errors.entries()] as [path, message] (path)} + <div class="flex flex-col gap-0.5 border-l-2 border-red-400 pl-2"> + <span class="text-2xs font-mono text-emphasis">{path}</span> + <span class="text-2xs text-red-600 dark:text-red-400 break-words"> + {message} + </span> + </div> + {/each} + </div> + </div> + {/snippet} +</Popover> diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineDeployTriggersModal.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineDeployTriggersModal.svelte new file mode 100644 index 0000000000..d96b758625 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineDeployTriggersModal.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import type { PipelineTriggerDraft } from './types' + + // Triggers start running once deployed, so deploying a pipeline's trigger + // drafts asks first, listing what will start. + let { + open, + triggerDrafts, + onConfirmed, + onCanceled + }: { + open: boolean + triggerDrafts: ReadonlyMap<string, PipelineTriggerDraft> + onConfirmed: () => void + onCanceled: () => void + } = $props() +</script> + +<ConfirmationModal + {open} + title="Deploy triggers?" + confirmationText="Deploy" + type="info" + {onConfirmed} + {onCanceled} +> + <div class="flex flex-col gap-2 text-xs text-secondary"> + <p> + {triggerDrafts.size === 1 + ? 'Saving the pipeline also creates this trigger. It starts running its script once deployed.' + : `Saving the pipeline also creates these ${triggerDrafts.size} triggers. They start running their scripts once deployed.`} + </p> + <ul class="flex flex-col gap-1"> + {#each [...triggerDrafts] as [key, d] (key)} + <li class="flex flex-col"> + <span class="font-mono text-2xs text-emphasis truncate">{d.config.path}</span> + <span class="text-2xs text-hint truncate"> + {d.kind}{#if d.kind === 'schedule'} + · <span class="font-mono">{d.config.schedule}</span> ({d.config.timezone}){/if} + → {d.config.script_path} + </span> + </li> + {/each} + </ul> + </div> +</ConfirmationModal> diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte index d35b4f21d6..52772ce515 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { userStore, workspaceStore } from '$lib/stores' - import { base } from '$lib/base' import { goto } from '$app/navigation' import Button from '$lib/components/common/button/Button.svelte' import FolderPicker from '$lib/components/FolderPicker.svelte' @@ -18,11 +17,18 @@ mode?: 'view' | 'edit' // Called right before navigating — lets a wrapping modal close itself. onOpen?: () => void + // Only the folder picker, for hosts that don't list pipelines. + hideExisting?: boolean } - let { currentFolder = undefined, mode = 'view', onOpen = undefined }: Props = $props() + let { + currentFolder = undefined, + mode = 'view', + onOpen = undefined, + hideExisting = false + }: Props = $props() let pipelines = resource( - () => $workspaceStore, + () => (hideExisting ? undefined : $workspaceStore), async (ws) => { if (!ws) return [] as ListPipelineFoldersResponse return await AssetService.listPipelineFolders({ workspace: ws }) @@ -38,7 +44,7 @@ async function openExistingPipeline(folder: string) { onOpen?.() const modeQuery = mode !== 'view' ? `?mode=${mode}` : '' - await goto(`${base}/pipeline/${encodeURIComponent(folder)}${modeQuery}`) + await goto(`/pipeline/${encodeURIComponent(folder)}${modeQuery}`) } async function openPicked() { @@ -49,50 +55,52 @@ </script> <div class="flex flex-col gap-6 w-full min-w-0"> - <section class="flex flex-col gap-2"> - <h3 class="text-xs font-semibold text-secondary uppercase tracking-wide"> - Existing pipelines - </h3> - {#if pipelines.loading && !pipelines.current} - <div class="text-tertiary text-sm flex items-center gap-2"> - <Loader2 size={14} class="animate-spin" /> - Loading… - </div> - {:else if pipelines.error} - <div class="text-red-500 text-sm">Failed: {pipelines.error.message}</div> - {:else if visiblePipelines.length === 0} - <div class="text-tertiary text-sm"> - {currentFolder - ? 'No other pipelines in this workspace.' - : 'No pipelines yet. A pipeline is any folder whose scripts carry pipeline annotations.'} - </div> - {:else} - <div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto"> - {#each visiblePipelines as p (p.folder)} - <button - type="button" - class="flex items-center justify-between px-3 py-2 border-b last:border-b-0 bg-surface hover:bg-surface-hover transition-colors text-left" - onclick={() => openExistingPipeline(p.folder)} - > - <span class="font-mono text-sm">f/{p.folder}</span> - <span class="text-2xs text-tertiary"> - {p.script_count} - {p.script_count === 1 ? 'script' : 'scripts'} - </span> - </button> - {/each} - </div> - {/if} - </section> + {#if !hideExisting} + <section class="flex flex-col gap-2"> + <h3 class="text-sm font-semibold text-emphasis">Existing pipelines</h3> + {#if pipelines.loading && !pipelines.current} + <div class="text-tertiary text-sm flex items-center gap-2"> + <Loader2 size={14} class="animate-spin" /> + Loading… + </div> + {:else if pipelines.error} + <div class="text-red-500 text-sm">Failed: {pipelines.error.message}</div> + {:else if visiblePipelines.length === 0} + <div class="text-tertiary text-sm"> + {currentFolder + ? 'No other pipelines in this workspace.' + : 'No pipelines yet. A pipeline is any folder whose scripts carry pipeline annotations.'} + </div> + {:else} + <div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto"> + {#each visiblePipelines as p (p.folder)} + <button + type="button" + class="flex items-center justify-between px-3 py-2 border-b last:border-b-0 bg-surface hover:bg-surface-hover transition-colors text-left" + onclick={() => openExistingPipeline(p.folder)} + > + <span class="font-mono text-sm">f/{p.folder}</span> + <span class="text-2xs text-tertiary"> + {p.script_count} + {p.script_count === 1 ? 'script' : 'scripts'} + </span> + </button> + {/each} + </div> + {/if} + </section> + {/if} {#if !$userStore?.operator} <!-- Operators only view existing pipelines — opening an arbitrary folder is a build-a-new-pipeline affordance. --> - <section class="flex flex-col gap-2"> - <h3 class="text-xs font-semibold text-secondary uppercase tracking-wide"> - Pick or create a folder - </h3> - <div class="flex items-center gap-2"> + <section class="flex flex-col gap-1"> + <h3 class="text-sm font-semibold text-emphasis">Pick or create a folder</h3> + <p class="text-xs text-secondary"> + Each folder holds one pipeline, made of its pipeline scripts. Pick a folder to open its + pipeline, or create one to start a new pipeline. + </p> + <div class="flex items-center gap-2 mt-1"> <div class="flex-1 min-w-0"> <FolderPicker bind:folderName={pickedFolder} /> </div> diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte index b71a51ea0a..364c4c6221 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte @@ -1,6 +1,15 @@ <script lang="ts"> import { untrack, type Snippet } from 'svelte' - import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' + import { MousePointerClick } from 'lucide-svelte' + import Disposable from '$lib/components/common/drawer/Disposable.svelte' + import Portal from '$lib/components/Portal.svelte' + import { getOverlayHost } from '$lib/components/common/overlayHost.svelte' + import { useFlowPanelMode } from '$lib/components/flows/flowPanelMode.svelte' + import { + PIPELINE_DRAFT_KIND, + pipelineBundlePath, + pipelineLocalMirrorKey + } from '$lib/pipelinePaths' import { Loader2 } from 'lucide-svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' import { DraftService } from '$lib/gen' @@ -11,6 +20,11 @@ import type { ColumnLineageGraph } from './columnLineageGraph' import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import AssetGraphCanvas from './AssetGraphCanvas.svelte' + import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte' + import { describeSchedule } from '$lib/utils/describeCron' + import type { UpstreamTriggerAction } from './upstreamScriptItems' + import type { AssetUpstreamDelete } from './assetsOnlyView' + import type { NodeFixSpec } from './NodeFixButton.svelte' import AssetGraphDetailsPane from './AssetGraphDetailsPane.svelte' import PipelineEventLog from './PipelineEventLog.svelte' import type { @@ -18,8 +32,10 @@ AssetGraphSelection, NativeTriggerKind, PipelineMode, + PipelineTriggerDraft, DbtAssetProvenance } from './types' + import { isDraftableTriggerKind, triggerDraftKey } from './pipelineTriggerDrafts' import type { AssetKind, Script, ScriptLang } from '$lib/gen' import type { RunnableRunState, PipelineEvent } from './activeRunnables.svelte' import type { PipelineOutputKind } from './pipelineTemplates' @@ -41,6 +57,7 @@ defaultPathSuffix = 'new_pipeline_script', panelHidden = false, onTogglePanelHidden, + modalPanel = false, prefetchingAssets = false, hoveredPaths = [], selectedRunPaths = [], @@ -65,6 +82,11 @@ onAddScriptForAsset, onAddPipelineScript, onRunnableMenuRemove, + onDeleteAssetUpstream, + nodeFixes, + assetDeleteVerb, + onEdgeAction, + onAddAssetTrigger, onRunProducer, idlePane, onRequestEdit, @@ -73,6 +95,9 @@ onRunCascadeByPath, runFormInitialArgs, onRunFormArgsChange, + inputsOnly = false, + onRunInputs, + onShowScript, readyDataUploadPaths, resolveLocalScript, localScriptsVersion, @@ -126,6 +151,9 @@ pathPrefix: string defaultPathSuffix?: string panelHidden?: boolean + /** Let the details pane open as a modal over a full-width graph when the editor + * is too narrow to dock it — the flow editor's step panel rule. */ + modalPanel?: boolean onTogglePanelHidden?: () => void prefetchingAssets?: boolean hoveredPaths?: string[] @@ -153,16 +181,29 @@ language: ScriptLang, scriptPath: string, outputKind: PipelineOutputKind, - aiPrompt?: string + aiPrompt?: string, + options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions ) => void onAddPipelineScript?: ( language: ScriptLang, path: string, source: { kind: NativeTriggerKind; path: string | undefined }, outputKind: PipelineOutputKind, - aiPrompt?: string + aiPrompt?: string, + options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions ) => void onRunnableMenuRemove?: (...args: any[]) => void + onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void + nodeFixes?: ReadonlyMap<string, NodeFixSpec> + assetDeleteVerb?: 'Delete' | 'Archive' + onEdgeAction?: (a: { + action: 'listen' | 'stop' + targets: Array<{ script: string; asset: { kind: AssetKind; path: string }; reads: boolean }> + }) => void + onAddAssetTrigger?: (a: { + scripts: Array<{ script: string; reads: boolean }> + asset: { kind: AssetKind; path: string } + }) => void onRunProducer?: (producer: RunProducer) => Promise<string | undefined> idlePane?: Snippet onRequestEdit?: () => void @@ -175,8 +216,16 @@ // callback to persist them as they change — see the page's dataUploadArgs. runFormInitialArgs?: Record<string, any> onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void + /** See AssetGraphDetailsPane's `inputsOnly`. */ + inputsOnly?: boolean + onRunInputs?: ( + path: string, + args: Record<string, any>, + live?: { content: string; language: ScriptLang } + ) => Promise<string | undefined> + onShowScript?: () => void // Data-upload entry scripts whose staged upload is ready (green node). - readyDataUploadPaths?: Set<string> + readyDataUploadPaths?: ReadonlySet<string> /** Local-dev (`/pipeline_dev`): resolve a node to its working-tree content * so the details pane skips the (nonexistent) deployed-script fetch. May * be async (to infer the args schema for the run form). */ @@ -247,18 +296,81 @@ ? (editor.selection != undefined || editor.activeDraftPath != undefined) && !panelHidden : !panelHidden ) + // Docked beside the graph, or in a modal over it (modal mode). In modal mode the + // details only show once asked for: a double-click on a node, a click on the + // node already selected, or a script's open button — never on a selection the + // editor makes itself (an AI-built node), which would pop a modal unasked. + const panelController = useFlowPanelMode({ enabled: () => modalPanel }) + $effect(() => panelController.measure(containerWidth)) + const panelMode = $derived(panelController.mode) + let panelModalOpen = $state(false) + const dockedPaneOpen = $derived(detailsPaneOpen && panelMode === 'docked') + $effect(() => { + if (untrack(() => panelModalOpen) && (panelMode === 'docked' || !detailsPaneOpen)) { + panelModalOpen = false + } + }) + let panelDisposable: Disposable | undefined = $state(undefined) + // Disposable joins the overlay stack through its methods, not by watching `open`. + $effect(() => { + panelModalOpen + untrack(() => { + panelModalOpen ? panelDisposable?.openDrawer() : panelDisposable?.closeDrawer() + }) + }) + const overlayHost = getOverlayHost() + const modalHost = $derived(overlayHost?.el()) + + function selectableNodeAt(e: MouseEvent): HTMLElement | null { + return (e.target as HTMLElement | null)?.closest('.svelte-flow__node.selectable') ?? null + } + // Read in the capture phase: once the click bubbles, the canvas has applied + // its selection and a first click looks like a click on the selected node. + let clickStartedOnSelected = false + function noteSelectionBeforeClick(e: MouseEvent) { + clickStartedOnSelected = !!selectableNodeAt(e)?.classList.contains('selected') + } + function openPanelModalIfReselected(e: MouseEvent) { + if (clickStartedOnSelected && selectableNodeAt(e)) panelModalOpen = true + } + function openPanelModalFromGraph(e: MouseEvent) { + if (selectableNodeAt(e)) panelModalOpen = true + } + // A button in the pane that ends it closes the modal in the same update: left to + // the effect above, the modal would first render the pane without its selection. + function closePanelModal() { + if (panelMode === 'modal') panelModalOpen = false + } + function handleCanvasSelect(s: AssetGraphSelection | undefined, opts?: { open?: boolean }) { + onSelect(s) + if (opts?.open && s && panelMode === 'modal') panelModalOpen = true + } + // A data-upload node's click asks for its run form; in modal mode nothing shows + // it unless the modal opens too. + $effect(() => { + if (focusUploadSignal > 0 && untrack(() => panelMode) === 'modal') { + untrack(() => (panelModalOpen = true)) + } + }) + const showNodeHint = $derived(panelMode === 'modal' && !panelModalOpen) + const nodeHintText = $derived( + effectiveSelection + ? 'Click the selected node to open it' + : 'Double click a node to open it' + ) + let idleView = $derived( mode !== 'edit' && editor.selection == undefined && editor.activeDraftPath == undefined ) // Wrap the writes (and the rightPaneSize read in the else branch) in untrack so - // the effect tracks only `detailsPaneOpen` / `storedRightPaneSize` — without it, + // the effect tracks only `dockedPaneOpen` / `storedRightPaneSize` — without it, // the Pane `bind:size` feedback loops the effect and pegs the main thread. $effect(() => { // Stacked (vertical) splits give the details pane more room — the run // form + result need height more than the graph needs it. const fallback = stacked ? 55 : 40 - if (detailsPaneOpen) { + if (dockedPaneOpen) { const restore = storedRightPaneSize untrack(() => { rightPaneSize = restore > 0 ? restore : fallback @@ -272,6 +384,67 @@ } }) + // The selected asset's producing scripts, named and iconed from the graph, for + // the pane's "Edit script". + let producerScripts = $derived( + selectionProducers + .filter((p) => p.kind === 'script') + .map((p) => { + const r = displayGraph.runnables.find((x) => x.usage_kind === 'script' && x.path === p.path) + return { path: p.path, summary: r?.summary, language: r?.language } + }) + ) + // The selected asset's upstream triggers that can be opened from here, each + // with the same action its trigger node has: edit (edit mode), create a + // missing one (edit mode), or the webhook / data-upload drawers. + let producerTriggers = $derived.by((): UpstreamTriggerAction[] => { + const scripts = new Set(producerScripts.map((p) => p.path)) + const seen = new Set<string>() + const out: UpstreamTriggerAction[] = [] + for (const t of displayGraph.triggers ?? []) { + if (t.trigger_kind === 'asset' || !scripts.has(t.runnable_path)) continue + const kind = t.trigger_kind + const rowless = kind === 'webhook' || kind === 'data_upload' + const key = `${kind}:${rowless || t.missing ? t.runnable_path : t.path}` + if (seen.has(key)) continue + seen.add(key) + const label = TRIGGER_NODE_STYLE[kind]?.label ?? kind + const script = t.runnable_path + let action: UpstreamTriggerAction | undefined + if (kind === 'webhook') { + if (onOpenWebhook) + action = { label: 'Open webhook', detail: script, onOpen: () => onOpenWebhook(script) } + } else if (kind === 'data_upload') { + if (onOpenDataUpload) + action = { label: 'Upload data', detail: script, onOpen: () => onOpenDataUpload(script) } + } else if (t.missing && !rowless) { + if (onCreateMissingTrigger) + action = { + label: `Create ${label} trigger`, + detail: script, + onOpen: () => onCreateMissingTrigger(kind, script) + } + } else if (t.path && onEditTrigger) { + const path = t.path + action = { + label: kind === 'schedule' ? 'Edit schedule' : `Edit ${label} trigger`, + detail: + kind === 'schedule' && t.schedule + ? (t.summary ?? describeSchedule(t.schedule, t.timezone) ?? path) + : path, + onOpen: () => onEditTrigger(kind, path, script) + } + } + if (action) out.push(action) + } + return out + }) + + function editProducerScript(path: string) { + if (mode !== 'edit') onRequestEdit?.() + onSelect({ kind: 'runnable', runnable_kind: 'script', path }) + } + // ===================== Draft autosave (persistDrafts only) ===================== // All of this folder's in-flight drafts live in ONE per-user DB draft (typ // `data_pipeline`) keyed at the folder, syncing across devices + surfacing in @@ -279,12 +452,21 @@ // for the one-time migration below; the DB is the source of truth on load. // FlowBuilder's autosave analogue — gated by `persistDrafts`. let pipelineDraftPath = $derived(pipelineBundlePath(folder)) - let storageKey = $derived(`pipeline-${folder}`) - type PipelineDraftBundle = { drafts: Array<[string, PipelineDraft]>; activeDraftPath?: string } + let storageKey = $derived(pipelineLocalMirrorKey(folder)) + type PipelineDraftBundle = { + drafts: Array<[string, PipelineDraft]> + activeDraftPath?: string + triggerDrafts?: PipelineTriggerDraft[] + } // Hydration is tracked on the editor instance (`editor.hydratedFromDb`), not a // component flag: the in-session preview reuses one instance across editor // hide/show, and it must hydrate ONCE per instance, not on every remount. let lastPersistedBundle: string | undefined = undefined + // What decides whether the bundle needs saving: its drafts, not which one is + // open. Opening a draft is not an edit, so it rides along with the next real + // change instead of saving (and spinning the sync indicator) on a click. + const persistKey = (b: PipelineDraftBundle | undefined) => + b ? JSON.stringify({ drafts: b.drafts, triggerDrafts: b.triggerDrafts }) : undefined function restoreBundle(bundle: PipelineDraftBundle) { if (Array.isArray(bundle.drafts)) { @@ -300,6 +482,18 @@ if (loaded.size > 0) editor.drafts = loaded } if (typeof bundle.activeDraftPath === 'string') editor.activeDraftPath = bundle.activeDraftPath + if (Array.isArray(bundle.triggerDrafts)) { + editor.triggerDrafts = new Map( + bundle.triggerDrafts + .filter( + (d) => + isDraftableTriggerKind(d?.kind) && + typeof d.config?.path === 'string' && + typeof d.config?.script_path === 'string' + ) + .map((d) => [triggerDraftKey(d.kind, d.config.path), d]) + ) + } } function readLocalBundle(): PipelineDraftBundle | undefined { @@ -311,7 +505,8 @@ if (s && (Array.isArray(s.drafts) || typeof s.activeDraftPath === 'string')) { return { drafts: Array.isArray(s.drafts) ? s.drafts : [], - activeDraftPath: typeof s.activeDraftPath === 'string' ? s.activeDraftPath : undefined + activeDraftPath: typeof s.activeDraftPath === 'string' ? s.activeDraftPath : undefined, + triggerDrafts: Array.isArray(s.triggerDrafts) ? s.triggerDrafts : undefined } } } catch (e) { @@ -339,8 +534,8 @@ fromServer = true } } - // A folder retarget during the await (the route-page header switcher, or a - // session retarget) changed the target and reset hydratedFromDb. Don't + // A folder switch during the await (the route page's header switcher) + // changed the target and reset hydratedFromDb. Don't // apply this now-stale folder's bundle or mark the new folder hydrated — // otherwise the stale result blocks the new folder's hydrate and its // drafts bleed across folders. @@ -359,7 +554,7 @@ { workspace: ws ?? '', itemKind: PIPELINE_DRAFT_KIND, path }, migratedFromLocal ? undefined : serverSavedAt ) - if (!migratedFromLocal) lastPersistedBundle = bundle ? JSON.stringify(bundle) : undefined + if (!migratedFromLocal) lastPersistedBundle = persistKey(bundle) } catch (e) { console.warn('failed to load pipeline drafts', e) } finally { @@ -412,30 +607,52 @@ return [p, d] as [string, PipelineDraft] return [p, { ...d, script, outputAssets, inputAssets }] as [string, PipelineDraft] }) + // A deployed script being edited is only promoted to a draft when its pane + // closes; until then its buffer is saved as the draft it will become. + const { scriptPath: livePath, content: liveBody, base } = editor.liveContent + if ( + livePath != undefined && + base && + !editor.drafts.has(livePath) && + liveBody !== (base.content ?? '') + ) { + const live = editor.liveBodyAssets.scriptPath === livePath + const writes = live ? extractWrites(editor.liveBodyAssets.assets) : [] + serialized.push([ + livePath, + { + localId: editor.promotedDraftLocalId(livePath), + script: { ...base, content: liveBody }, + outputAssets: writes.length > 0 ? writes : undefined, + inputAssets: live ? extractReads(editor.liveBodyAssets.assets) : undefined + } + ]) + } const activePath = editor.activeDraftPath + const triggerDrafts = [...editor.triggerDrafts.values()] const key = storageKey const ws = workspace const path = pipelineDraftPath const hydrated = editor.hydratedFromDb untrack(() => { if (!hydrated) return - const isEmpty = serialized.length === 0 && !activePath + const isEmpty = serialized.length === 0 && !activePath && triggerDrafts.length === 0 const bundle: PipelineDraftBundle | undefined = isEmpty ? undefined - : { drafts: serialized, activeDraftPath: activePath } + : { + drafts: serialized, + activeDraftPath: activePath, + ...(triggerDrafts.length > 0 ? { triggerDrafts } : {}) + } try { if (typeof localStorage !== 'undefined') { if (isEmpty) localStorage.removeItem(key) - else - localStorage.setItem( - key, - encodeState({ drafts: serialized, activeDraftPath: activePath }) - ) + else localStorage.setItem(key, encodeState(bundle)) } } catch (e) { console.warn('failed to mirror pipeline state', e) } - const serializedBundle = bundle ? JSON.stringify(bundle) : undefined + const serializedBundle = persistKey(bundle) if (serializedBundle === lastPersistedBundle) return lastPersistedBundle = serializedBundle if (!ws) return @@ -450,10 +667,90 @@ }) </script> -<div class="h-full w-full" bind:clientWidth={containerWidth}> +{#snippet detailsBody(ws: string)} + {#if idleView && idlePane} + {@render idlePane()} + {:else} + <AssetGraphDetailsPane + {mode} + {onRequestEdit} + {canRunByPath} + {onRunByPath} + {onRunCascadeByPath} + {runFormInitialArgs} + {onRunFormArgsChange} + {inputsOnly} + {onRunInputs} + {onShowScript} + {resolveLocalScript} + {localScriptsVersion} + selection={activeDraft ? undefined : editor.selection} + selectionProducers={activeDraft ? [] : selectionProducers} + {producerScripts} + onEditScript={editProducerScript} + {producerTriggers} + {selectionColumnGraph} + {selectionColumnLoading} + {selectionColumnTruncated} + {selectionColumnFailed} + {selectionDbt} + {schemaCanEvolve} + {selectionForkMaterialization} + {schemaContractContext} + {runsRefreshKey} + {runsPendingJobId} + {activeRunnable} + {downstreamSubscribers} + onStartBoundedRun={canBoundedRunOpenScript && + editor.openScriptPath && + onStartBoundedRunForOpen + ? () => onStartBoundedRunForOpen?.(editor.openScriptPath!) + : undefined} + {onRunCompleted} + {onTestStateChange} + {requestRemoveSignal} + {requestRunSignal} + {requestRunCascadeSignal} + {focusUploadSignal} + draftScript={activeDraft?.script} + draftOutputAssets={activeDraft?.outputAssets} + draftInputAssets={activeDraft?.inputAssets} + {onDraftPathChange} + onDraftMetaChange={editor.setDraftMeta} + {pathPrefix} + workspace={ws} + onAnnotationsChange={editor.handleAnnotationsChange} + onAssetsChange={editor.handleAssetsChange} + onContentChange={editor.handleContentChange} + onDraftPersist={editor.handleDraftPersist} + onclose={() => { + closePanelModal() + onClose() + }} + onHide={onTogglePanelHidden && + (() => { + closePanelModal() + onTogglePanelHidden() + })} + {onDiscard} + {onDraftSaved} + {onPersistedSaved} + {onScriptRenamed} + {onScriptRemoved} + /> + {/if} +{/snippet} + +<div class="relative h-full w-full" bind:clientWidth={containerWidth}> <Splitpanes class="!h-full" horizontal={stacked}> <Pane bind:size={leftPaneSize}> - <div class="relative h-full"> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div + class="relative h-full" + ondblclick={panelMode === 'modal' ? openPanelModalFromGraph : undefined} + onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined} + onclick={panelMode === 'modal' ? openPanelModalIfReselected : undefined} + > <AssetGraphCanvas graph={displayGraph} selection={effectiveSelection} @@ -470,10 +767,15 @@ {onOpenWebhook} {onOpenDataUpload} {readyDataUploadPaths} - onselect={onSelect} + onselect={handleCanvasSelect} {onAddScriptForAsset} {onAddPipelineScript} {onRunnableMenuRemove} + {onDeleteAssetUpstream} + {nodeFixes} + {assetDeleteVerb} + {onEdgeAction} + {onAddAssetTrigger} {onRunProducer} {validStartPaths} {onStartBoundedRun} @@ -482,6 +784,8 @@ {panToNodeId} showMinimap={!stacked} viewportFitKey={viewportFitKey ?? folder} + assetsOnlyToggle + assetsOnlyFolder={workspace ? `${workspace}/${folder}` : undefined} /> {#if boundBar}{@render boundBar()}{/if} {#if mode === 'edit'} @@ -509,66 +813,47 @@ {/if} </div></Pane > - {#if detailsPaneOpen && workspace} + {#if dockedPaneOpen && workspace} <Pane bind:size={rightPaneSize} minSize={25}> - {#if idleView && idlePane} - {@render idlePane()} - {:else} - <AssetGraphDetailsPane - {mode} - {onRequestEdit} - {canRunByPath} - {onRunByPath} - {onRunCascadeByPath} - {runFormInitialArgs} - {onRunFormArgsChange} - {resolveLocalScript} - {localScriptsVersion} - selection={activeDraft ? undefined : editor.selection} - selectionProducers={activeDraft ? [] : selectionProducers} - {selectionColumnGraph} - {selectionColumnLoading} - {selectionColumnTruncated} - {selectionColumnFailed} - {selectionDbt} - {schemaCanEvolve} - {selectionForkMaterialization} - {schemaContractContext} - {runsRefreshKey} - {runsPendingJobId} - {activeRunnable} - {downstreamSubscribers} - onStartBoundedRun={canBoundedRunOpenScript && - editor.openScriptPath && - onStartBoundedRunForOpen - ? () => onStartBoundedRunForOpen?.(editor.openScriptPath!) - : undefined} - {onRunCompleted} - {onTestStateChange} - {requestRemoveSignal} - {requestRunSignal} - {requestRunCascadeSignal} - {focusUploadSignal} - draftScript={activeDraft?.script} - draftOutputAssets={activeDraft?.outputAssets} - draftInputAssets={activeDraft?.inputAssets} - {pathPrefix} - {onDraftPathChange} - {workspace} - onAnnotationsChange={editor.handleAnnotationsChange} - onAssetsChange={editor.handleAssetsChange} - onContentChange={editor.handleContentChange} - onDraftPersist={editor.handleDraftPersist} - onclose={onClose} - onHide={onTogglePanelHidden} - {onDiscard} - {onDraftSaved} - {onPersistedSaved} - {onScriptRenamed} - {onScriptRemoved} - /> - {/if} + {@render detailsBody(workspace)} </Pane> {/if} </Splitpanes> + {#if showNodeHint} + <!-- Above the event log's bar, which owns the canvas's bottom edge. --> + <div + class="pointer-events-none absolute bottom-10 left-3 z-30 flex items-center gap-1.5 text-xs text-hint" + > + <MousePointerClick size={13} /> + {nodeHintText} + </div> + {/if} </div> + +<!-- Portalled to the host's overlay anchor (a session preview tab) so the modal covers + that tab rather than the whole page; Disposable owns its place in the overlay stack + and its Escape. --> +<Disposable bind:open={panelModalOpen} bind:this={panelDisposable}> + {#snippet children({ zIndex })} + {#if panelMode === 'modal' && panelModalOpen && workspace} + <Portal target={modalHost ?? 'body'} class="contents"> + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="{modalHost ? 'absolute' : 'fixed'} inset-0 flex justify-center px-2 py-6" + style="z-index: {zIndex}" + role="dialog" + > + <div class="absolute inset-0 bg-black/20" onclick={() => (panelModalOpen = false)}></div> + <div + class="relative flex w-full max-w-4xl flex-col overflow-hidden rounded-md border bg-surface shadow-xl" + > + <div class="min-h-0 flex-1 overflow-auto"> + {@render detailsBody(workspace)} + </div> + </div> + </div> + </Portal> + {/if} + {/snippet} +</Disposable> diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte index e341707250..c1b64b59a2 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte @@ -1,6 +1,7 @@ <script lang="ts" module> import type { ComponentType } from 'svelte' import type { SupportedLanguage } from '$lib/common' + import type { AssetKind, NewSchedule } from '$lib/gen' export type PipelineInsertKind = { // Machine-readable id; drives which right-column panel renders. @@ -8,6 +9,9 @@ label: string description?: string icon?: ComponentType + // Its trigger is configured in its own editor right after the script is + // created, so the wizard's last button reads "Configure". + configuredAfterCreate?: boolean } export type PipelineInsertPick = { @@ -22,7 +26,18 @@ // from this prompt via AI (using language + outputKind + the // upstream input as context) instead of using the seeded template. aiPrompt?: string + // Set by the schedule step when the picked kind is `schedule`. + schedule?: PipelineInsertSchedule + // Set by the asset step for the table output kinds, replacing the + // generated name. + outputAsset?: { kind: AssetKind; path: string } } + + export type PipelineInsertOptions = Pick<PipelineInsertPick, 'schedule' | 'outputAsset'> + + // Everything but the target, which is the script being created. An empty + // `path` means the default `<script>_schedule`. + export type PipelineInsertSchedule = Omit<NewSchedule, 'script_path'> </script> <script lang="ts"> @@ -36,10 +51,28 @@ PIPELINE_OUTPUT_KINDS, type PipelineOutputKind } from './pipelineTemplates' + import CronInput from '$lib/components/CronInput.svelte' import type { ScriptLang } from '$lib/gen' import Label from '$lib/components/Label.svelte' - import TextInput from '$lib/components/text_input/TextInput.svelte' - import { CornerDownLeft, Sparkles } from 'lucide-svelte' + import TextInput, { + inputBaseClass, + inputBorderClass, + inputSizeClasses + } from '$lib/components/text_input/TextInput.svelte' + import DucklakePicker from '$lib/components/DucklakePicker.svelte' + import DatatablePicker from '$lib/components/DatatablePicker.svelte' + import Path from '$lib/components/Path.svelte' + import Section from '$lib/components/Section.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import DateTimeInput from '$lib/components/DateTimeInput.svelte' + import ScheduleAdvancedOptions, { + emptyScheduleAdvanced, + loadDefaultScheduleAdvanced, + scheduleAdvancedCfg + } from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte' + import { workspaceStore } from '$lib/stores' + import { ArrowLeft, ChevronDown, CornerDownLeft, Loader2, Sparkles } from 'lucide-svelte' + import { tick, untrack } from 'svelte' import { arrowTabNav } from '$lib/attachments/arrowTabNav' import { selectAndAdvanceTo } from '$lib/attachments/selectAndAdvanceTo' @@ -49,8 +82,15 @@ pathPrefix?: string defaultPathSuffix?: string onPick: (pick: PipelineInsertPick) => void - trigger: import('svelte').Snippet + /** Receives whether the menu is open, e.g. to keep a hover state while it is. */ + trigger: import('svelte').Snippet<[{ open: boolean }]> placement?: 'bottom' | 'top' | 'left' | 'right' + /** Opens the menu each time it changes, for an entry point other than the trigger. */ + openSignal?: number + /** With `openSignal`: only the schedule step, for this existing script; its + * confirm hands the schedule to `onSchedule` instead of creating a script. */ + scheduleFor?: string + onSchedule?: (schedule: PipelineInsertSchedule) => void } let { @@ -60,7 +100,10 @@ trigger: triggerSnippet, placement = 'bottom', defaultPathSuffix, - onPick + onPick, + openSignal, + scheduleFor, + onSchedule }: Props = $props() // When there's only one trigger kind, hide the Trigger column entirely @@ -75,6 +118,107 @@ aiPrompt: undefined as undefined | string }) let selected = $state(buildEmptySelected()) + let menuOpen = $state(false) + $effect(() => { + if (!openSignal) return + untrack(() => { + if (scheduleFor) { + resetWizard() + selected.triggerId = 'schedule' + step = 1 + config.schedulePath = `${scheduleFor}_schedule` + loadAdvancedDefaults() + void focusCurrentStep() + } + menuOpen = true + }) + }) + let selectedKind = $derived(kinds.find((k) => k.id === selected.triggerId)) + + // Steps after the first, each configuring something the picks call for. + type ConfigStep = 'schedule' | 'asset' + const NAMED_OUTPUT_KINDS: PipelineOutputKind[] = ['materialize', 'datatable', 'ducklake'] + let configSteps = $derived.by<ConfigStep[]>(() => { + const steps: ConfigStep[] = [] + if (selected.triggerId === 'schedule') steps.push('schedule') + if (selected.outputId && NAMED_OUTPUT_KINDS.includes(selected.outputId)) steps.push('asset') + return steps + }) + // 0 is the trigger/language/output/path step; `n` is `configSteps[n - 1]`. + let step = $state(0) + let currentConfigStep = $derived(step > 0 ? configSteps[step - 1] : undefined) + let isLastStep = $derived(step >= configSteps.length) + + const buildEmptyConfig = () => ({ + schedule: '0 0 12 * *', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + cronVersion: 'v2', + validCron: true, + // Follows the script path until the Path field is edited. + schedulePath: '', + schedulePathDirty: false, + schedulePathError: '', + summary: '', + description: '', + pauseUntil: false, + pausedUntil: undefined as string | undefined, + advanced: emptyScheduleAdvanced(), + // The workspace's default handlers, loaded when the schedule step opens. + advancedDefaults: 'unloaded' as 'unloaded' | 'loading' | 'loaded', + // `store` is the data table or DuckLake catalog name. The table name + // follows the script name until the user types in it. + asset: undefined as + | undefined + | { kind: 'ducklake' | 'datatable'; store: string | undefined; table: string }, + tableEdited: false + }) + let config = $state(buildEmptyConfig()) + + // A new schedule starts from the workspace's default handlers, as in the schedule + // drawer. The step cannot be confirmed until they are in. + function loadAdvancedDefaults() { + const ws = $workspaceStore + if (!ws || config.advancedDefaults !== 'unloaded') return + const target = config + target.advancedDefaults = 'loading' + loadDefaultScheduleAdvanced(ws) + .then((d) => (target.advanced = d)) + .catch(() => {}) + .finally(() => (target.advancedDefaults = 'loaded')) + } + + function resetWizard() { + selected = buildEmptySelected() + config = buildEmptyConfig() + step = 0 + } + + const TABLE_NAME_RE = /^[^/\s]+$/ + // The path grammar of `Path.svelte`: word segments, no empty or trailing ones. + const SCRIPT_NAME_RE = /^[\w-]+(\/[\w-]+)*$/ + let scriptNameValid = $derived(SCRIPT_NAME_RE.test(selected.scriptPath.trim())) + let assetValid = $derived(!!config.asset?.store && TABLE_NAME_RE.test(config.asset.table.trim())) + let stepValid = $derived( + currentConfigStep === 'schedule' + ? config.validCron && + !!config.schedule.trim() && + !config.schedulePathError && + config.advancedDefaults === 'loaded' + : currentConfigStep === 'asset' + ? assetValid + : scriptNameValid && !!selected.triggerId && !!selected.language && !!selected.outputId + ) + + function enterAssetStep() { + const kind = selected.outputId === 'datatable' ? 'datatable' : 'ducklake' + if (config.asset?.kind !== kind) { + config.asset = { kind, store: 'main', table: '' } + config.tableEdited = false + } + if (!config.tableEdited && config.asset) { + config.asset.table = selected.scriptPath.trim().split('/').pop() ?? '' + } + } // Refs to the column containers so Enter inside one column can hand // focus off to the first tabbable in the next column. @@ -104,16 +248,73 @@ return out } + // A step change re-renders the popover body, dropping focus to <body>, where + // keystrokes (Enter, Cmd+Enter) no longer reach the step's key handlers. + let contentEl: HTMLElement | undefined = $state() + async function focusCurrentStep() { + await tick() + const target = + step === 0 + ? pathEl?.querySelector('input') + : contentEl?.querySelector<HTMLElement>('[data-autofocus] input') + target?.focus() + } + + function goBack() { + step -= 1 + void focusCurrentStep() + } + + function scheduleConfig(): PipelineInsertSchedule { + return { + path: config.schedulePath, + schedule: config.schedule.trim(), + timezone: config.timezone, + cron_version: config.cronVersion, + summary: config.summary.trim() || undefined, + description: config.description, + paused_until: config.pauseUntil ? config.pausedUntil : undefined, + is_flow: false, + args: {}, + ...scheduleAdvancedCfg($state.snapshot(config.advanced)) + } + } + function confirm(close: () => void) { + if (scheduleFor) { + if (!stepValid) return + onSchedule?.(scheduleConfig()) + close() + return + } const suffix = selected.scriptPath.trim() if (!suffix || !selected.triggerId || !selected.language || !selected.outputId) return + if (!stepValid) return + if (!isLastStep) { + step += 1 + if (configSteps[step - 1] === 'asset') enterAssetStep() + if (configSteps[step - 1] === 'schedule') { + loadAdvancedDefaults() + if (!config.schedulePathDirty) config.schedulePath = `${pathPrefix}${suffix}_schedule` + } + void focusCurrentStep() + return + } const trimmedPrompt = selected.aiPrompt?.trim() onPick({ kindId: selected.triggerId, language: selected.language, path: pathPrefix + suffix, outputKind: selected.outputId, - aiPrompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined + aiPrompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined, + schedule: configSteps.includes('schedule') ? scheduleConfig() : undefined, + outputAsset: + configSteps.includes('asset') && config.asset + ? { + kind: config.asset.kind as AssetKind, + path: `${config.asset.store}/${config.asset.table.trim()}` + } + : undefined }) close() } @@ -123,9 +324,18 @@ enableFlyTransition contentClasses={twMerge( 'p-0 bg-surface overflow-hidden relative transition-height', - showBottomPanel ? 'h-[26rem]' : 'h-[22rem]' + // The first step's columns (w-56 + w-48 + w-80), held across steps. + singleKind ? 'w-[32rem]' : 'w-[46rem]', + currentConfigStep === 'schedule' + ? 'h-[27rem]' + : currentConfigStep === 'asset' + ? 'h-[14rem]' + : showBottomPanel + ? 'h-[26rem]' + : 'h-[22rem]' )} class="inline-block" + bind:isOpen={menuOpen} usePointerDownOutside floatingConfig={{ placement, @@ -136,24 +346,30 @@ fitViewport: true, overlap: false }} - onClose={() => (selected = buildEmptySelected())} + onClose={resetWizard} > {#snippet trigger()} - {@render triggerSnippet?.()} + {@render triggerSnippet?.({ open: menuOpen })} {/snippet} {#snippet content({ close })} - <div class="flex flex-col h-full"> - <div class={'flex flex-row transition-height divide-x overflow-y-scroll'}> - {@render topSection()} - </div> - <div - class={twMerge( - 'flex flex-col gap-5 grow transition-height px-4 border-t', - showBottomPanel ? 'h-[14rem] py-4' : 'h-0' - )} - > - {@render bottomSection(close)} - </div> + <div class="h-full" bind:this={contentEl}> + {#if currentConfigStep} + {@render configStepSection(currentConfigStep, close)} + {:else} + <div class="flex flex-col h-full"> + <div class={'flex flex-row transition-height divide-x overflow-y-scroll'}> + {@render topSection()} + </div> + <div + class={twMerge( + 'flex flex-col gap-5 grow transition-height px-4 border-t', + showBottomPanel ? 'h-[14rem] py-4' : 'h-0' + )} + > + {@render bottomSection(close)} + </div> + </div> + {/if} </div> {/snippet} </Popover> @@ -170,7 +386,10 @@ <Button variant="subtle" btnClasses={'text-left'} - onClick={() => (selected.triggerId = k.id)} + onClick={() => { + selected.triggerId = k.id + if (k.id === 'schedule') loadAdvancedDefaults() + }} selected={isSelected} > {#if k.icon} @@ -264,14 +483,30 @@ {#snippet bottomSection(close: () => void)} <Label label="Path"> <!-- svelte-ignore a11y_no_static_element_interactions --> - <div bind:this={pathEl} class="flex" onkeydown={selectAndAdvanceTo(() => aiPromptEl ?? saveEl)}> + <div + bind:this={pathEl} + class="flex" + onkeydown={(e) => + (e.metaKey || e.ctrlKey) && e.key === 'Enter' + ? (e.preventDefault(), e.stopPropagation(), confirm(close)) + : selectAndAdvanceTo(() => aiPromptEl ?? saveEl)(e)} + > <div class="border rounded-md rounded-r-none border-r-0 text-xs w-fit shrink-0 whitespace-nowrap flex items-center px-2 text-secondary bg-surface-input" > {pathPrefix} </div> - <TextInput bind:value={selected.scriptPath} class="rounded-l-none" /> + <TextInput + bind:value={selected.scriptPath} + class="rounded-l-none" + error={!scriptNameValid && !!selected.scriptPath.trim()} + /> </div> + {#if !scriptNameValid && selected.scriptPath.trim()} + <span class="text-2xs text-red-600 dark:text-red-400"> + Letters, digits, <code>_</code> and <code>-</code>, in <code>/</code>-separated segments + </span> + {/if} </Label> {#if !$copilotInfo.workspaceDisabled} <Label label="AI Prompt (optional)"> @@ -292,15 +527,208 @@ </div> </Label> {/if} - {@const hasAiPrompt = !!selected.aiPrompt?.trim()} <div class="ml-auto" bind:this={saveEl}> - <Button - variant="accent" - btnClasses="w-fit" - disabled={!selected.scriptPath.trim()} - onClick={() => confirm(close)} - startIcon={hasAiPrompt ? { icon: Sparkles } : undefined} - shortCut={{ Icon: CornerDownLeft }}>{hasAiPrompt ? 'Generate' : 'Create'}</Button - > + {@render confirmButton(close)} + </div> +{/snippet} + +{#snippet confirmButton(close: () => void)} + {@const hasAiPrompt = !!selected.aiPrompt?.trim()} + <Button + variant="accent" + btnClasses="w-fit" + disabled={!stepValid} + onClick={() => confirm(close)} + startIcon={isLastStep && hasAiPrompt ? { icon: Sparkles } : undefined} + shortCut={{ Icon: CornerDownLeft }} + >{scheduleFor + ? 'Create schedule' + : !isLastStep + ? step === 0 + ? 'Configure' + : 'Next' + : hasAiPrompt + ? 'Generate' + : selectedKind?.configuredAfterCreate + ? 'Configure' + : 'Create'}</Button + > +{/snippet} + +{#snippet configStepSection(configStep: ConfigStep, close: () => void)} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="flex flex-col h-full" + onkeydown={(e) => { + if (e.key !== 'Enter') return + const target = e.target as HTMLElement | null + // Cmd/Ctrl+Enter confirms from anywhere; bare Enter only from the step's own + // inputs, not from the Advanced fields (a textarea, pickers with their own Enter). + const bareEnterSubmits = target?.tagName === 'INPUT' && !target.closest('[data-advanced]') + if (e.metaKey || e.ctrlKey || bareEnterSubmits) { + e.preventDefault() + e.stopPropagation() + confirm(close) + } + }} + > + <div class="flex items-center gap-2 px-2 py-2 border-b"> + {#if !scheduleFor} + <Button + variant="subtle" + unifiedSize="sm" + iconOnly + startIcon={{ icon: ArrowLeft }} + onClick={goBack} + title="Back" + /> + {/if} + <span class="text-xs font-semibold text-emphasis {scheduleFor ? 'pl-2' : ''}"> + {scheduleFor + ? `Schedule for ${scheduleFor}` + : configStep === 'schedule' + ? 'Schedule' + : 'Output asset'} + </span> + {#if !scheduleFor} + <span class="text-2xs text-hint ml-auto pr-2" + >Step {step + 1} of {configSteps.length + 1}</span + > + {/if} + </div> + <div class="flex flex-col gap-2 grow overflow-auto p-4"> + {#if configStep === 'schedule'} + <span class="text-2xs text-secondary"> + Saved as a draft schedule and deployed along with the pipeline. + </span> + <div data-autofocus class="contents"> + <CronInput + bind:schedule={config.schedule} + bind:timezone={config.timezone} + bind:validCRON={config.validCron} + bind:cronVersion={config.cronVersion} + /> + </div> + <div data-advanced class="mt-4"> + <Section label="Advanced" collapsable initiallyCollapsed> + <div class="flex flex-col gap-6"> + <Label label="Summary"> + <TextInput + bind:value={config.summary} + inputProps={{ placeholder: 'Short summary to be displayed when listed' }} + /> + </Label> + <Label label="Path"> + <Path + bind:path={config.schedulePath} + bind:dirty={config.schedulePathDirty} + bind:error={config.schedulePathError} + initialPath="" + checkInitialPathExistence + namePlaceholder="schedule" + kind="schedule" + /> + </Label> + <Label label="Description"> + <TextInput + underlyingInputEl="textarea" + bind:value={config.description} + inputProps={{ placeholder: 'What this schedule does and how to use it' }} + /> + </Label> + <div class="flex flex-col gap-1"> + <Toggle + options={{ + right: 'Pause schedule until...', + rightTooltip: + 'Pausing the schedule will program the next job to run as if the schedule starts at the time the pause is lifted, instead of now.' + }} + bind:checked={config.pauseUntil} + /> + {#if config.pauseUntil} + <DateTimeInput bind:value={config.pausedUntil} /> + {/if} + </div> + <!-- Rendered once the workspace defaults are in, so they never + overwrite a handler picked while they load. --> + {#if config.advancedDefaults === 'loaded'} + <ScheduleAdvancedOptions + wsId={$workspaceStore} + itemKind="script" + canWrite + bind:errorHandler={config.advanced.errorHandler} + bind:wsErrorHandlerMuted={config.advanced.wsErrorHandlerMuted} + bind:failedTimes={config.advanced.failedTimes} + bind:failedExact={config.advanced.failedExact} + bind:recoveryHandler={config.advanced.recoveryHandler} + bind:recoveredTimes={config.advanced.recoveredTimes} + bind:successHandler={config.advanced.successHandler} + bind:retry={config.advanced.retry} + bind:dynamicSkipPath={config.advanced.dynamicSkipPath} + bind:tag={config.advanced.tag} + /> + {:else} + <div class="flex items-center gap-1.5 text-xs text-secondary"> + <Loader2 size={12} class="animate-spin" /> + Loading the workspace's default handlers… + </div> + {/if} + </div> + </Section> + </div> + {:else if configStep === 'asset' && config.asset} + <Label label="Name"> + <div + class={twMerge( + inputBaseClass, + inputBorderClass({ error: !assetValid }), + inputSizeClasses.md, + 'flex flex-row items-center gap-0 py-0 pl-2 pr-0' + )} + > + <span class="text-xs text-secondary shrink-0">{config.asset.kind}://</span> + {#if config.asset.kind === 'datatable'} + <DatatablePicker + bind:value={config.asset.store} + class="shrink-0" + selectInputClass="!border-none" + placeholder="data table" + useContentEditable + RightIcon={ChevronDown} + /> + {:else} + <DucklakePicker + bind:value={config.asset.store} + class="shrink-0" + selectInputClass="!border-none" + placeholder="catalog" + useContentEditable + RightIcon={ChevronDown} + /> + {/if} + <span class="text-sm text-secondary">/</span> + <div data-autofocus class="grow flex"> + <TextInput + bind:value={config.asset.table} + class="!border-none grow" + inputProps={{ + placeholder: 'table', + oninput: () => (config.tableEdited = true) + }} + /> + </div> + </div> + </Label> + <span class="text-2xs text-hint"> + {config.asset.kind === 'datatable' ? 'Data table' : 'DuckLake catalog'}, then the table + name + </span> + {/if} + </div> + <div class="flex px-4 py-3 border-t"> + <div class="ml-auto"> + {@render confirmButton(close)} + </div> + </div> </div> {/snippet} diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte new file mode 100644 index 0000000000..e78058f905 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte @@ -0,0 +1,174 @@ +<!-- +@component +The body every pipeline graph node shares: icon, a kind line ("Script · draft", "Data table"), +a title, and trailing chips. Node components add only what is theirs — the icon, the chips, +and the controls hung around the card. +--> +<script lang="ts"> + import { NODE } from '$lib/components/graph/util' + import { + PIPELINE_NODE_EXTRA_ROW, + PIPELINE_NODE_HEADER, + PIPELINE_NODE_HEADER_CARD_PAD, + PIPELINE_NODE_HEIGHT + } from './assetGraphLayout' + import { twMerge } from 'tailwind-merge' + import type { Snippet } from 'svelte' + + interface Props { + icon: Snippet + kindLabel: string + title: string + /** Hover text; the path, when the title is a summary. */ + tooltip?: string + /** Scripts sit on the main surface, assets and triggers a step down; in the + * assets-only view, which has no scripts, assets take the main surface. */ + surface?: 'primary' | 'secondary' + selected?: boolean + /** Not deployed yet: dashed edge. */ + draft?: boolean + /** Feedback states, which recolor the whole card. `error` is a misconfigured + * node, styled like a failed flow step. */ + tone?: 'danger' | 'error' | 'success' | 'running' + /** Makes the whole card a button. */ + onclick?: (e: MouseEvent) => void + /** Between the icon and the text: a live status the eye should find first. */ + leading?: Snippet + trailing?: Snippet + /** Defaults to `NODE.width`. */ + width?: number + /** A line under the title, in the text column. */ + subtitle?: Snippet + /** A tab on the card's top edge. Only on a card without `onclick`, since it + * may hold buttons of its own. */ + header?: Snippet + /** The header's script is selected: its edge takes the selected colour. */ + headerSelected?: boolean + /** The header's script has an error: its edge goes red. */ + headerTone?: 'error' + } + + let { + icon, + kindLabel, + title, + tooltip, + surface = 'secondary', + selected = false, + draft = false, + tone, + onclick, + leading, + trailing, + subtitle, + header, + headerSelected = false, + headerTone, + width = NODE.width + }: Props = $props() + + const cardClass = $derived( + twMerge( + 'flex items-center w-full text-left font-normal rounded-md drop-shadow-sm overflow-hidden border transition-colors', + surface === 'primary' ? 'bg-surface' : 'bg-surface-secondary', + 'border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500', + // Like a flow step: a card with a background needs no edge, only its shadow, + // until one of the states below draws one. + surface === 'primary' && + !draft && + !selected && + !tone && + 'border-transparent dark:border-transparent hover:border-transparent dark:hover:border-transparent', + draft && 'border-dashed border-gray-400 dark:border-gray-500', + // Like a selected flow step: the fill and the edge colour change, the edge's + // width and dashes stay. + // Each dark: variant too: the base edge sets its own dark colour, which a plain + // border class does not override. + selected && + 'bg-surface-accent-selected border-border-selected dark:border-border-selected hover:border-border-selected dark:hover:border-border-selected', + tone === 'danger' && + 'bg-red-50 dark:bg-red-900/30 border-dashed border-red-400 dark:border-red-500 hover:bg-red-100 dark:hover:bg-red-900/40', + tone === 'error' && + (selected + ? 'bg-red-200 dark:bg-red-600 border-red-500 dark:border-red-500 hover:border-red-500 dark:hover:border-red-500' + : 'bg-red-100 dark:bg-red-700 border-transparent dark:border-transparent hover:border-transparent dark:hover:border-transparent'), + tone === 'success' && + 'bg-green-50 dark:bg-green-900/30 border-green-500 dark:border-green-600', + tone === 'running' && + 'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse' + ) + ) + const minHeight = $derived(PIPELINE_NODE_HEIGHT + (subtitle ? PIPELINE_NODE_EXTRA_ROW : 0)) + const toneText = $derived( + tone === 'danger' + ? 'text-red-700 dark:text-red-400' + : tone === 'error' + ? 'text-red-700 dark:text-red-200' + : tone === 'success' + ? 'text-green-700 dark:text-green-400' + : undefined + ) +</script> + +{#snippet body()} + <span class="shrink-0 ml-3 mr-2.5 flex items-center">{@render icon()}</span> + {@render leading?.()} + <span class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight"> + <span class={twMerge('text-2xs truncate text-secondary', toneText)}>{kindLabel}</span> + <span class={twMerge('text-xs truncate text-emphasis', selected && 'text-accent', toneText)} + >{title}</span + > + {#if subtitle} + <span class="flex items-center min-w-0 mt-1">{@render subtitle()}</span> + {/if} + </span> + {#if trailing} + <span class="flex items-center shrink-0 pr-2">{@render trailing()}</span> + {/if} +{/snippet} + +{#if onclick} + <button + type="button" + {onclick} + class={cardClass} + style="width: {width}px; min-height: {minHeight}px;" + title={tooltip} + > + {@render body()} + </button> +{:else if header} + <!-- The header is a tab on the card's top edge, inset from its sides. --> + <div class="flex flex-col" style="width: {width}px;"> + <div + class={twMerge( + 'mx-2 flex items-stretch min-w-0 rounded-t-md overflow-hidden bg-surface border border-b-0 border-gray-200 dark:border-gray-700', + // Like an errored card: red, and a stronger red when selected. + headerTone === 'error' + ? headerSelected + ? 'border-red-500 dark:border-red-500' + : 'border-red-300 dark:border-red-600' + : headerSelected && 'border-border-selected dark:border-border-selected' + )} + style="height: {PIPELINE_NODE_HEADER}px;" + > + {@render header()} + </div> + <div + class={twMerge(cardClass, !selected && !tone && 'bg-surface-tertiary')} + style="min-height: {minHeight + PIPELINE_NODE_HEADER_CARD_PAD}px; padding-block: {PIPELINE_NODE_HEADER_CARD_PAD / + 2}px;" + title={tooltip} + > + {@render body()} + </div> + </div> +{:else} + <div + class={cardClass} + style="width: {width}px; min-height: {minHeight}px;" + title={tooltip} + > + {@render body()} + </div> +{/if} diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte index 2450595b6d..8a060e0b2c 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { AssetKind, Script } from '$lib/gen' - import { CalendarClock, Loader2, Play, Upload, Zap } from 'lucide-svelte' + import { CalendarClock, Code2, Loader2, Play, Upload, Zap } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' import HighlightCode from '$lib/components/HighlightCode.svelte' import PipelineRunForm from './PipelineRunForm.svelte' @@ -44,6 +44,12 @@ // "green / ready" state and seeds the whole-pipeline run. `isValid` is the // full-schema validity (all required fields, not just the S3 file). onArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void + // Just the run form and the runs, without the source: what a data-upload + // click asks for. `onShowScript` switches to the script itself. + inputsOnly?: boolean + onShowScript?: () => void + // `onRun` previews a draft's own content, so a draft can run too. + runsDrafts?: boolean } let { @@ -57,8 +63,12 @@ runsPendingJobId, onRunCompleted, initialArgs, - onArgsChange + onArgsChange, + inputsOnly = false, + onShowScript, + runsDrafts = false }: Props = $props() + let runBlockedByDraft = $derived(isDraft && !runsDrafts) // Seed once from the persisted args (see initialArgs). Cloned so the run // form mutates its own copy, not the page's stored snapshot. @@ -139,7 +149,7 @@ </script> <div class="flex flex-col h-full"> - {#if isDraft} + {#if runBlockedByDraft} <div class="shrink-0 flex items-center gap-2 px-3 py-1.5 text-2xs bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-900/60 text-amber-700 dark:text-amber-400" > @@ -168,13 +178,24 @@ {/if} </span> <div class="flex items-center gap-1.5"> + {#if inputsOnly && onShowScript} + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: Code2 }} + onclick={onShowScript} + title="Show the script instead of its inputs" + > + Open script + </Button> + {/if} {#if hasCascade} <Button variant="accent-secondary" unifiedSize="sm" startIcon={{ icon: running ? Loader2 : Zap }} onclick={() => run(true)} - disabled={isDraft || running || !runValid} + disabled={runBlockedByDraft || running || !runValid} title={`Run this script with these inputs, then run its ${downstreamCount} downstream pipeline script${downstreamCount === 1 ? '' : 's'} in order`} > Run + downstream @@ -185,8 +206,8 @@ unifiedSize="sm" startIcon={{ icon: running ? Loader2 : Play }} onclick={() => run(false)} - disabled={isDraft || running || !runValid || !onRun} - title={isDraft + disabled={runBlockedByDraft || running || !runValid || !onRun} + title={runBlockedByDraft ? 'Deploy this draft to run it for real' : hasCascade ? 'Run just this step — downstream scripts are not triggered' @@ -210,9 +231,11 @@ {/key} {/if} </div> - <div class="flex-1 min-h-0 overflow-auto text-xs p-3"> - <HighlightCode code={script.content ?? ''} language={script.language} /> - </div> + {#if !inputsOnly} + <div class="flex-1 min-h-0 overflow-auto text-xs p-3"> + <HighlightCode code={script.content ?? ''} language={script.language} /> + </div> + {/if} </div> </Pane> <Pane size={45} minSize={20}> diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte index 751ecbbdc8..c79e638265 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte @@ -1,8 +1,9 @@ <script lang="ts"> import { resource } from 'runed' import { WorkspaceService } from '$lib/gen' - import { base } from '$lib/base' import { CircleCheck, CircleAlert, Database, HardDrive, ArrowRight } from 'lucide-svelte' + import Alert from '$lib/components/common/alert/Alert.svelte' + import { Button } from '$lib/components/common' interface Props { workspace: string @@ -57,7 +58,7 @@ title: 'Workspace object storage', description: 'Materialized partitions and DuckLake data files are written to S3 / object storage.', - href: `${base}/workspace_settings?tab=windmill_lfs`, + href: '/workspace_settings?tab=windmill_lfs', cta: 'Configure object storage' }, { @@ -66,55 +67,50 @@ title: 'A DuckLake catalog', description: 'DuckLake is the table format pipelines materialize into — add at least one catalog.', - href: `${base}/workspace_settings?tab=ducklake`, + href: '/workspace_settings?tab=ducklake', cta: 'Configure DuckLake' } ]) </script> {#if show} - <div - class="flex flex-col gap-3 rounded-lg border border-amber-300 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/30 p-4" - > - <div class="flex items-start gap-2"> - <CircleAlert size={18} class="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" /> - <div class="flex flex-col gap-0.5"> - <h3 class="text-sm font-semibold text-emphasis">Finish setting up pipelines</h3> - <p class="text-xs text-tertiary"> - Pipelines materialize data into DuckLake tables backed by object storage. Configure the - following before your first pipeline can run. - </p> - </div> + <Alert type="warning" title="Finish setting up pipelines" hideIcon> + <div class="flex flex-col gap-3"> + <p> + Pipelines materialize data into DuckLake tables backed by object storage. Configure the + following before your first pipeline can run. + </p> + <ul class="flex flex-col gap-2"> + {#each steps as step (step.title)} + {@const Icon = step.icon} + <li class="flex items-center gap-3 rounded-md border bg-surface-tertiary px-3 py-2"> + {#if step.done === true} + <CircleCheck size={16} class="text-green-600 dark:text-green-400 shrink-0" /> + {:else if step.done === false} + <CircleAlert size={16} class="text-yellow-600 dark:text-yellow-400 shrink-0" /> + {:else} + <Icon size={16} class="text-secondary shrink-0" /> + {/if} + <div class="flex flex-col min-w-0 flex-1"> + <span class="text-xs font-semibold text-emphasis">{step.title}</span> + <span class="text-2xs text-secondary">{step.description}</span> + </div> + {#if step.done !== true} + <Button + variant="default" + unifiedSize="sm" + href={step.href} + endIcon={{ icon: ArrowRight }} + wrapperClasses="shrink-0" + > + {step.cta} + </Button> + {:else} + <span class="shrink-0 text-2xs text-green-700 dark:text-green-400">Configured</span> + {/if} + </li> + {/each} + </ul> </div> - - <ul class="flex flex-col gap-2"> - {#each steps as step (step.title)} - {@const Icon = step.icon} - <li class="flex items-center gap-3 rounded-md border bg-surface px-3 py-2"> - {#if step.done === true} - <CircleCheck size={16} class="text-green-600 dark:text-green-400 shrink-0" /> - {:else if step.done === false} - <CircleAlert size={16} class="text-amber-600 dark:text-amber-400 shrink-0" /> - {:else} - <Icon size={16} class="text-tertiary shrink-0" /> - {/if} - <div class="flex flex-col min-w-0 flex-1"> - <span class="text-xs font-semibold text-primary">{step.title}</span> - <span class="text-2xs text-tertiary">{step.description}</span> - </div> - {#if step.done !== true} - <a - href={step.href} - class="shrink-0 inline-flex items-center gap-1 text-xs text-blue-500 hover:underline whitespace-nowrap" - > - {step.cta} - <ArrowRight size={12} /> - </a> - {:else} - <span class="shrink-0 text-2xs text-green-700 dark:text-green-400">Configured</span> - {/if} - </li> - {/each} - </ul> - </div> + </Alert> {/if} diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte index 4f97039a1c..d8d19845fa 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte @@ -1,18 +1,10 @@ <script lang="ts"> import { sendUserToast } from '$lib/utils' + import { workspaceStore } from '$lib/stores' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' - import type { NativeTriggerKind } from './types' - import { - EmailTriggerService, - GcpTriggerService, - KafkaTriggerService, - MqttTriggerService, - AmqpTriggerService, - NatsTriggerService, - PostgresTriggerService, - ScheduleService, - SqsTriggerService - } from '$lib/gen' + import type { NativeTriggerKind, PipelineTriggerDraftKind } from './types' + import type { Schedule } from '$lib/gen' + import { deleteTriggerRow } from './pipelineTriggerDraftDeploy' import KafkaTriggerEditor from '$lib/components/triggers/kafka/KafkaTriggerEditor.svelte' import MqttTriggerEditor from '$lib/components/triggers/mqtt/MqttTriggerEditor.svelte' import AmqpTriggerEditor from '$lib/components/triggers/amqp/AmqpTriggerEditor.svelte' @@ -22,6 +14,10 @@ import GcpTriggerEditor from '$lib/components/triggers/gcp/GcpTriggerEditor.svelte' import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' + import { + loadDefaultScheduleAdvanced, + scheduleAdvancedCfg + } from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte' import WebhookEditor from '$lib/components/triggers/webhook/WebhookEditor.svelte' import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -110,6 +106,56 @@ } } + /** The kind's full editor over a pipeline-local draft; Save hands the config back, + * and the drawer stays open when `onSave` refuses it. + * `saved` is the draft being edited (absent for a new one), applied over `defaults`. */ + export function openTriggerDraft( + kind: PipelineTriggerDraftKind, + scriptPath: string, + defaults: Record<string, any>, + saved: Record<string, any> | undefined, + onSave: (cfg: Record<string, any>) => boolean + ) { + switch (kind) { + case 'schedule': { + // A new draft starts from the workspace's default handlers, like a new + // schedule does; the drawer only applies them when it opens without one. + const open = (advanced: Record<string, any>) => + scheduleEditor?.openDraft( + { + is_flow: false, + args: {}, + ...advanced, + ...defaults, + ...saved, + script_path: scriptPath + } as Schedule, + onSave + ) + if (saved || !$workspaceStore) return open({}) + return loadDefaultScheduleAdvanced($workspaceStore) + .then((a) => open(scheduleAdvancedCfg(a))) + .catch(() => open({})) + } + case 'kafka': + return kafkaEditor?.openDraft(scriptPath, defaults, saved, onSave) + case 'mqtt': + return mqttEditor?.openDraft(scriptPath, defaults, saved, onSave) + case 'amqp': + return amqpEditor?.openDraft(scriptPath, defaults, saved, onSave) + case 'nats': + return natsEditor?.openDraft(scriptPath, defaults, saved, onSave) + case 'postgres': + return postgresEditor?.openDraft(scriptPath, defaults, saved, onSave) + case 'sqs': + return sqsEditor?.openDraft(scriptPath, defaults, saved, onSave) + case 'gcp': + return gcpEditor?.openDraft(scriptPath, defaults, saved, onSave) + case 'email': + return emailEditor?.openDraft(scriptPath, defaults, saved, onSave) + } + } + // Open the webhook drawer (endpoint URLs + token flow). Caller owns the // draft guard. export function openWebhook(scriptPath: string) { @@ -133,37 +179,7 @@ const { kind, path: triggerPath } = triggerDeleteTarget triggerDeleteLoading = true try { - switch (kind) { - case 'schedule': - await ScheduleService.deleteSchedule({ workspace, path: triggerPath }) - break - case 'kafka': - await KafkaTriggerService.deleteKafkaTrigger({ workspace, path: triggerPath }) - break - case 'mqtt': - await MqttTriggerService.deleteMqttTrigger({ workspace, path: triggerPath }) - break - case 'amqp': - await AmqpTriggerService.deleteAmqpTrigger({ workspace, path: triggerPath }) - break - case 'nats': - await NatsTriggerService.deleteNatsTrigger({ workspace, path: triggerPath }) - break - case 'postgres': - await PostgresTriggerService.deletePostgresTrigger({ workspace, path: triggerPath }) - break - case 'sqs': - await SqsTriggerService.deleteSqsTrigger({ workspace, path: triggerPath }) - break - case 'gcp': - await GcpTriggerService.deleteGcpTrigger({ workspace, path: triggerPath }) - break - case 'email': - await EmailTriggerService.deleteEmailTrigger({ workspace, path: triggerPath }) - break - default: - return - } + if (!(await deleteTriggerRow(kind, triggerPath, workspace))) return sendUserToast(`Deleted ${kind} trigger "${triggerPath}"`) triggerDeleteTarget = undefined onUpdate() diff --git a/frontend/src/lib/components/assets/AssetGraph/RunStateChip.svelte b/frontend/src/lib/components/assets/AssetGraph/RunStateChip.svelte new file mode 100644 index 0000000000..21edf1df52 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/RunStateChip.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { twMerge } from 'tailwind-merge' + import { CheckCircle2, Loader2, XCircle } from 'lucide-svelte' + import type { RunnableRunState } from './activeRunnables.svelte' + + let { runState: rs, class: className }: { runState: RunnableRunState; class?: string } = $props() +</script> + +<div + class={twMerge( + 'shrink-0 flex items-center gap-0.5 px-1 py-0.5 rounded-sm border', + rs.status === 'running' + ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700' + : rs.status === 'success' + ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border-emerald-300 dark:border-emerald-700' + : 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-300 dark:border-red-700', + className + )} + title={`${ + rs.status === 'running' + ? 'Running now' + : rs.status === 'success' + ? 'Last run succeeded' + : 'Last run failed' + }${rs.runs > 0 ? ` — ran ${rs.runs}× this session` : ''}`} +> + {#if rs.status === 'running'} + <Loader2 size={10} class="animate-spin" /> + {:else if rs.status === 'success'} + <CheckCircle2 size={10} /> + {:else} + <XCircle size={10} /> + {/if} + {#if rs.runs > 0} + <span class="text-3xs leading-none tabular-nums">×{rs.runs}</span> + {/if} +</div> diff --git a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte index c4cb5ca998..5d28067982 100644 --- a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte @@ -1,10 +1,8 @@ <script lang="ts"> import { Handle, Position } from '@xyflow/svelte' import { - CheckCircle2, ChevronDown, Code2, - EllipsisVertical, GitBranch, Layers, Loader2, @@ -15,17 +13,20 @@ Target, Timer, Trash2, - XCircle, Zap } from 'lucide-svelte' import DbtIcon from '$lib/components/icons/DbtIcon.svelte' import { twMerge } from 'tailwind-merge' - import { preventDefault, stopPropagation } from 'svelte/legacy' import type { GraphUsageKind } from './types' import type { RunnableRunState } from './activeRunnables.svelte' import { parseDurationSecs } from './parsePipelineAnnotations' - import { NODE } from '$lib/components/graph/util' - import DropdownV2 from '$lib/components/DropdownV2.svelte' + import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte' + import PipelineNodeCard from './PipelineNodeCard.svelte' + import NodeActionsMenu from './NodeActionsMenu.svelte' + import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte' + import NodeOnDot from './NodeOnDot.svelte' + import RunStateChip from './RunStateChip.svelte' + import type { ScriptLang } from '$lib/gen' import Popover from '$lib/components/meltComponents/Popover.svelte' import type { Item } from '$lib/utils' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -35,8 +36,16 @@ interface Props { data: { + /** What is wrong with the node, and how to fix it: shows the Fix pill. */ + fix?: NodeFix + /** Starts dragging the top dot onto an asset to subscribe the script to it. */ + onStartOnDrag?: (e: PointerEvent) => void runnable_kind: GraphUsageKind path: string + summary?: string + language?: ScriptLang + // Why the script is misconfigured, when it is: renders it red. + error?: string in_pipeline?: boolean partition_kind?: 'daily' | 'hourly' | 'weekly' | 'monthly' | 'dynamic' freshness?: string @@ -101,10 +110,11 @@ } let { data, selected = false }: Props = $props() - // The icon alone conveys "script" vs "flow"; the uppercase kind label was - // visually noisy and redundant. Tooltip on hover surfaces the path in - // full when truncated. let Icon = $derived(data.runnable_kind === 'flow' ? GitBranch : Code2) + let kindLabel = $derived( + data.runnable_kind === 'flow' ? 'Flow' : data.dbt ? 'dbt project' : 'Script' + ) + // The title shows the summary when there is one, so the tooltip keeps the path. let nodeTooltip = $derived( data.unsaved ? `${data.path} (unsaved draft)` @@ -114,7 +124,6 @@ ) let hover = $state(false) - let menuOpen = $state(false) let running = $state(false) // Amber "computing now" surface, gated so only the replay player lights it up. @@ -214,131 +223,106 @@ <!-- Mirrors the flow editor's step styling (getNodeColorClasses): muted surface-tertiary fill with a quiet gray border, accent only for the - selected state, dashes for unsaved drafts. The 2px dashed stroke is - necessary for the dash gaps to be readable; 1px disappears into the - fill. + selected state, dashes for unsaved drafts — 1px, like the trigger + nodes' dashed outline, so every draft node reads the same. --> - <div - class={twMerge( - 'flex items-center rounded-md drop-shadow-sm overflow-hidden border transition-colors', - 'bg-surface border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500', - selected && 'bg-surface-accent-selected border-border-selected', - data.unsaved && 'border-2 border-dashed border-gray-400 dark:border-gray-500', - computingNow && - 'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse' - )} - style="width: {NODE.width}px; min-height: {NODE.height}px;" - title={nodeTooltip} - > - <Icon size={14} class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-secondary'}`} /> - <span class="flex-1 min-w-0 pr-1 py-0.5 text-2xs font-mono text-emphasis truncate"> - {data.path} - </span> - <!-- Annotation chips share one neutral treatment — the icon carries - the meaning (colors are reserved for feedback, per the brand - guidelines). Only the freshness chip (when it has a verdict) - and the run-state chip below use semantic colors. --> - {#if data.partition_kind} - <div - class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" - title={`// partitioned ${data.partition_kind}`} - > - <Layers size={10} /> - <span class="text-3xs leading-none">{data.partition_kind}</span> - </div> - {/if} - <!-- Freshness is the one annotation chip that carries feedback (a - fresh/stale verdict against real run history), so like the - run-state chip it uses semantic colors: emerald = within window, - amber = stale. Neutral when there's no verdict (drafts, bad - window value). --> - {#if data.freshness} - <div - class={twMerge( - 'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm', - freshnessState === 'fresh' - ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300' - : freshnessState === 'stale' - ? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300' - : 'bg-surface-secondary text-secondary' - )} - title={freshnessTooltip} - > - <Timer size={10} /> - <span class="text-3xs leading-none">{data.freshness}</span> - </div> - {/if} - {#if data.tag} - <div - class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" - title={`// tag ${data.tag}`} - > - <Tag size={10} /> - <span class="text-3xs leading-none">{data.tag}</span> - </div> - {/if} - {#if data.retry} - {@const r = data.retry} - <div - class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" - title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`} - > - <RotateCw size={10} /> - <span class="text-3xs leading-none">×{r.count}</span> - </div> - {/if} - {#if data.macros && data.macros.length > 0} - <div - class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" - title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros - .map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`) - .join('\n')}`} - > - <SquareFunction size={10} /> - <span class="text-3xs leading-none">×{data.macros.length}</span> - </div> - {/if} - {#if data.dbt} - <div - class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300" - title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`} - > - <DbtIcon width={10} height={10} /> - <span class="text-3xs leading-none">×{data.dbt.model_count}</span> - </div> - {/if} - {#if data.runState} - {@const rs = data.runState} - <div - class={twMerge( - 'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm', - rs.status === 'running' - ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300' - : rs.status === 'success' - ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300' - : 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300' - )} - title={`${ - rs.status === 'running' - ? 'Running now' - : rs.status === 'success' - ? 'Last run succeeded' - : 'Last run failed' - }${rs.runs > 0 ? ` — ran ${rs.runs}× this session` : ''}`} - > - {#if rs.status === 'running'} - <Loader2 size={10} class="animate-spin" /> - {:else if rs.status === 'success'} - <CheckCircle2 size={10} /> + <NodeActionsMenu items={menuItems} {hover}> + <PipelineNodeCard + surface="primary" + kindLabel="{kindLabel}{data.unsaved ? ' · draft' : ''}" + title={data.summary || data.path} + tooltip={data.error ? `${data.path}: ${data.error}` : nodeTooltip} + {selected} + draft={data.unsaved} + tone={computingNow ? 'running' : data.error ? 'error' : undefined} + > + {#snippet icon()} + {#if data.language && data.runnable_kind === 'script'} + <LanguageIcon lang={data.language} width={14} height={14} /> {:else} - <XCircle size={10} /> + <Icon size={14} class={selected ? 'text-accent' : 'text-secondary'} /> {/if} - {#if rs.runs > 0} - <span class="text-3xs leading-none tabular-nums">×{rs.runs}</span> + {/snippet} + {#snippet trailing()} + <!-- Annotation chips share one neutral treatment — the icon carries + the meaning (colors are reserved for feedback, per the brand + guidelines). Only the freshness chip (when it has a verdict) + and the run-state chip below use semantic colors. --> + {#if data.partition_kind} + <div + class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" + title={`// partitioned ${data.partition_kind}`} + > + <Layers size={10} /> + <span class="text-3xs leading-none">{data.partition_kind}</span> + </div> {/if} - </div> - {/if} - </div> + <!-- Freshness is the one annotation chip that carries feedback (a + fresh/stale verdict against real run history), so like the + run-state chip it uses semantic colors: emerald = within window, + amber = stale. Neutral when there's no verdict (drafts, bad + window value). --> + {#if data.freshness} + <div + class={twMerge( + 'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm', + freshnessState === 'fresh' + ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300' + : freshnessState === 'stale' + ? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300' + : 'bg-surface-secondary text-secondary' + )} + title={freshnessTooltip} + > + <Timer size={10} /> + <span class="text-3xs leading-none">{data.freshness}</span> + </div> + {/if} + {#if data.tag} + <div + class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" + title={`// tag ${data.tag}`} + > + <Tag size={10} /> + <span class="text-3xs leading-none">{data.tag}</span> + </div> + {/if} + {#if data.retry} + {@const r = data.retry} + <div + class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" + title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`} + > + <RotateCw size={10} /> + <span class="text-3xs leading-none">×{r.count}</span> + </div> + {/if} + {#if data.macros && data.macros.length > 0} + <div + class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary" + title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros + .map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`) + .join('\n')}`} + > + <SquareFunction size={10} /> + <span class="text-3xs leading-none">×{data.macros.length}</span> + </div> + {/if} + {#if data.dbt} + <div + class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300" + title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`} + > + <DbtIcon width={10} height={10} /> + <span class="text-3xs leading-none">×{data.dbt.model_count}</span> + </div> + {/if} + {#if data.runState} + <RunStateChip runState={data.runState} class="mr-1" /> + {/if} + {/snippet} + </PipelineNodeCard> + </NodeActionsMenu> {#if showRun} <!-- Run button revealed on hover/select. Matches the placement, size, and behaviour of AssetNode's run button so both nodes feel @@ -463,38 +447,12 @@ {/if} </div> {/if} - - {#if menuItems.length > 0} - <!-- - Hover-revealed action menu, top-right of the node. Mirrors the - FlowModuleSchemaItem pattern: position the trigger button just - outside the node frame, only render it on hover (or while the - menu is open) so the canvas isn't visually cluttered when idle. - The pointerdown stop+preventDefault keeps xyflow from selecting - the node when the user is reaching for the menu. - --> - <div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;"> - <DropdownV2 - items={menuItems} - placement="bottom-end" - bind:open={menuOpen} - fixedHeight={false} - usePointerDownOutside - > - {#snippet buttonReplacement()} - <button - class={twMerge( - 'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary', - hover || menuOpen ? 'block' : '!hidden', - 'shadow-md rounded-md' - )} - onpointerdown={stopPropagation(preventDefault(() => {}))} - title="Actions" - > - <EllipsisVertical size={12} /> - </button> - {/snippet} - </DropdownV2> + {#if data.onStartOnDrag} + <NodeOnDot onStart={data.onStartOnDrag} visible={hover} /> + {/if} + {#if data.fix} + <div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10"> + <NodeFixButton fix={data.fix} /> </div> {/if} </div> diff --git a/frontend/src/lib/components/assets/AssetGraph/TriggerNode.svelte b/frontend/src/lib/components/assets/AssetGraph/TriggerNode.svelte index 0e279112cd..f678b3d272 100644 --- a/frontend/src/lib/components/assets/AssetGraph/TriggerNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/TriggerNode.svelte @@ -24,46 +24,36 @@ type Presentation = { icon: ComponentType label: string - // Tailwind class fragments for bg + border + text accent. - bg: string - border: string - borderUnsaved: string iconText: string } - // One muted treatment for every source kind — colors are meaningful, not + // One muted icon color for every source kind — colors are meaningful, not // decorative (brand guidelines), so the kind is carried by the icon and - // label while bg/border stay on the surface/gray scale like the flow - // editor's nodes. Red stays reserved for the missing-trigger state. - const MUTED = { - bg: 'bg-surface-secondary', - border: 'outline-gray-400 dark:outline-gray-600', - borderUnsaved: 'outline-dashed outline-gray-400 dark:outline-gray-500', - iconText: 'text-secondary' - } + // label. Red stays reserved for the missing-trigger state. + const MUTED = { iconText: 'text-secondary' } export const TRIGGER_NODE_STYLE: Record<TriggerNodeKind, Presentation> = { - schedule: { icon: Clock, label: 'schedule', ...MUTED }, - webhook: { icon: Webhook, label: 'webhook', ...MUTED }, - email: { icon: Mail, label: 'email', ...MUTED }, - kafka: { icon: Zap, label: 'kafka', ...MUTED }, - mqtt: { icon: Radio, label: 'mqtt', ...MUTED }, - amqp: { icon: Radio, label: 'amqp', ...MUTED }, - nats: { icon: MessageSquare, label: 'nats', ...MUTED }, - postgres: { icon: Database, label: 'postgres', ...MUTED }, - sqs: { icon: Send, label: 'sqs', ...MUTED }, - gcp: { icon: CloudCog, label: 'gcp', ...MUTED }, - data_upload: { icon: Upload, label: 'data upload', ...MUTED } + schedule: { icon: Clock, label: 'Schedule', ...MUTED }, + webhook: { icon: Webhook, label: 'Webhook', ...MUTED }, + email: { icon: Mail, label: 'Email', ...MUTED }, + kafka: { icon: Zap, label: 'Kafka', ...MUTED }, + mqtt: { icon: Radio, label: 'MQTT', ...MUTED }, + amqp: { icon: Radio, label: 'AMQP', ...MUTED }, + nats: { icon: MessageSquare, label: 'NATS', ...MUTED }, + postgres: { icon: Database, label: 'Postgres', ...MUTED }, + sqs: { icon: Send, label: 'SQS', ...MUTED }, + gcp: { icon: CloudCog, label: 'GCP Pub/Sub', ...MUTED }, + data_upload: { icon: Upload, label: 'Data upload', ...MUTED } } </script> <script lang="ts"> import { Handle, Position } from '@xyflow/svelte' - import { NODE } from '$lib/components/graph/util' - import { twMerge } from 'tailwind-merge' - import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte' - import DropdownV2 from '$lib/components/DropdownV2.svelte' - import { stopPropagation, preventDefault } from 'svelte/legacy' + import PipelineNodeCard from './PipelineNodeCard.svelte' + import NodeActionsMenu from './NodeActionsMenu.svelte' + import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte' + import { describeSchedule } from '$lib/utils/describeCron' + import { AlertTriangle, CheckCircle2, Target, Trash2 } from 'lucide-svelte' import type { Item } from '$lib/utils' interface Props { @@ -74,16 +64,22 @@ // owning script is in `runnable_path` (used by the title and the // "+ Create trigger" drawer hook passed by the page). data: { + /** What is wrong with the node, and how to fix it: shows the Fix pill. */ + fix?: NodeFix kind: TriggerNodeKind ref: string unsaved?: boolean missing?: boolean + // A pipeline-local schedule draft: `ref` is the path it deploys to. + // Edit/delete go through the same callbacks, which the page routes + // to the draft instead of a trigger row. + draft?: boolean + // The cron, for a schedule: the node names its cadence instead of "Schedule". + schedule?: string + timezone?: string + // Shown as the title over the trigger's path, when the trigger has one. + summary?: string runnable_path?: string - // True iff the target script is still a draft (no DB row yet). - // Drives the "(after draft save)" hint on the missing - // placeholder, since the page-level handler refuses to open the - // create drawer until the script is deployed. - runnable_unsaved?: boolean // Page-supplied dispatcher that opens the matching native // trigger drawer with `script_path` pre-filled. When absent // (e.g. schedule, or a kind without an editor) the placeholder is @@ -128,7 +124,6 @@ let { data }: Props = $props() let hover = $state(false) - let menuOpen = $state(false) let style = $derived(TRIGGER_NODE_STYLE[data.kind]) // Webhooks are never genuinely "missing" — every deployed runnable has an @@ -143,7 +138,7 @@ let Icon = $derived(displayMissing ? AlertTriangle : style.icon) let missingTitle = $derived( displayMissing - ? `Missing ${style.label} trigger: ${data.runnable_path ?? ''} declares \`// on ${style.label}\` but no ${style.label} trigger targets it. Click to create one, or remove the annotation.` + ? `Missing ${style.label} trigger: ${data.runnable_path ?? ''} declares \`// on ${data.kind}\` but no ${style.label} trigger targets it. Click to create one, or remove the annotation.` : undefined ) // Webhook gets a drawer (URLs + webhook-specific token creation) rather @@ -200,7 +195,7 @@ ...(canDelete ? [ { - displayName: 'Delete…', + displayName: data.draft ? 'Discard draft' : 'Delete…', icon: Trash2, type: 'delete' as const, action: () => { @@ -212,6 +207,75 @@ : []) ]) + // A schedule names its cadence ("Every day at 4:00", or the cron itself + // when it has no plain reading); every other kind names its kind. + let kindName = $derived( + data.kind === 'schedule' && data.schedule + ? (describeSchedule(data.schedule, data.timezone) ?? `Schedule (${data.schedule})`) + : style.label + ) + + // One card for every state: which label, title and click the node gets. + let card = $derived.by( + (): { + kindLabel: string + title: string + tooltip?: string + draft: boolean + tone?: 'danger' | 'success' + onclick?: () => void + } => { + const unsaved = !!data.unsaved + if (canCreate) + return { + kindLabel: `${style.label} · missing`, + title: 'Click to create', + tooltip: missingTitle, + draft: false, + tone: 'danger', + onclick: handleMissingClick + } + if (canEdit) + return { + kindLabel: `${kindName}${data.draft ? ' · draft' : unsaved ? ' · unsaved' : ''}`, + title: data.summary || data.ref, + tooltip: data.draft + ? `Draft ${style.label}: ${data.ref} — created when you save the pipeline. Click to edit.` + : `Edit ${style.label} trigger: ${data.ref}`, + draft: unsaved, + onclick: handleEditClick + } + if (canOpenWebhook) + return { + kindLabel: style.label, + title: 'URLs & token', + tooltip: `Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`, + draft: unsaved, + onclick: handleWebhookClick + } + if (canOpenDataUpload) + return { + kindLabel: `${style.label}${dataUploadReady ? ' · ready' : ''}`, + title: dataUploadReady ? 'File staged' : 'Upload & run', + tooltip: dataUploadReady + ? `Data upload for ${data.runnable_path ?? ''} — a file is staged; the pipeline is ready to run. Click to change it.` + : `Data upload for ${data.runnable_path ?? ''} — click to open the run form and upload a file`, + draft: !dataUploadReady && unsaved, + tone: dataUploadReady ? 'success' : undefined, + onclick: handleDataUploadClick + } + return { + kindLabel: `${displayMissing ? style.label : kindName}${displayMissing ? ' · missing' : unsaved ? ' · unsaved' : ''}`, + title: displayMissing ? 'no trigger row' : data.summary || data.ref, + tooltip: + missingTitle ?? + (unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`), + draft: unsaved && !displayMissing, + tone: displayMissing ? 'danger' : undefined + } + } + ) + function handleMissingClick() { if (!canCreate || !data.runnable_path || !data.onCreateMissingTrigger) return data.onCreateMissingTrigger(data.kind as NativeTriggerKind, data.runnable_path) @@ -235,195 +299,33 @@ <!-- svelte-ignore a11y_no_static_element_interactions --> <div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}> - {#if canCreate} - <!-- Same affordance as the asset's downstream + button: render the - whole node as a button so the cursor + hover + click is obvious - without depending on svelte-flow's wrapper-level handlers. --> - <button - type="button" - onclick={handleMissingClick} - class={twMerge( - 'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left', - 'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500', - 'hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors' - )} - style="width: {NODE.width}px; min-height: {NODE.height}px;" - title={missingTitle} + <NodeActionsMenu items={menuItems} {hover}> + <PipelineNodeCard + kindLabel={card.kindLabel} + title={card.title} + tooltip={card.tooltip} + draft={card.draft} + tone={card.tone} + onclick={card.onclick} > - <Icon size={14} class="shrink-0 ml-2 mr-2 text-red-600 dark:text-red-400" /> - <div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight"> - <span class="text-3xs uppercase tracking-wide truncate text-red-700 dark:text-red-400"> - {style.label} · missing - </span> - <span class="text-2xs font-mono truncate text-red-700 dark:text-red-400"> - {data.runnable_unsaved ? 'Click to create (after draft save)' : 'Click to create'} - </span> - </div> - </button> - {:else if canEdit} - <!-- Mirrors the missing-trigger pattern: render the whole node as a - button so clicks open the editor drawer reliably (don't rely on - svelte-flow's onnodeclick wiring). --> - <button - type="button" - onclick={handleEditClick} - class={twMerge( - 'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left', - style.bg, - data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border, - 'hover:brightness-95 dark:hover:brightness-110 transition-[filter]' - )} - style="width: {NODE.width}px; min-height: {NODE.height}px;" - title={`Edit ${style.label} trigger: ${data.ref}`} - > - <Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} /> - <div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight"> - <span class="text-3xs uppercase tracking-wide truncate text-tertiary"> - {style.label}{data.unsaved ? ' · unsaved' : ''} - </span> - <span class="text-2xs font-mono truncate text-emphasis"> - {data.ref} - </span> - </div> - </button> - {:else if canOpenWebhook} - <!-- Webhook endpoint: implicit (no trigger row), so the node opens a - drawer with the URLs + webhook-specific token creation instead of - an editor. Styled like an attached trigger, never the red - "missing" state. --> - <button - type="button" - onclick={handleWebhookClick} - class={twMerge( - 'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left', - style.bg, - data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border, - 'hover:brightness-95 dark:hover:brightness-110 transition-[filter]' - )} - style="width: {NODE.width}px; min-height: {NODE.height}px;" - title={`Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`} - > - <Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} /> - <div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight"> - <span class="text-3xs uppercase tracking-wide truncate text-tertiary"> - {style.label} - </span> - <span class="text-2xs font-mono truncate text-emphasis"> URLs & token </span> - </div> - </button> - {:else if canOpenDataUpload} - <!-- Data upload: UI-first entry point (no trigger row). Clicking the - node opens the target script's run form, where the auto-generated - S3 picker lets the user upload a file and run the pipeline. Goes - green once a file is staged (ready), so "Run pipeline" can proceed; - until then it stays neutral with an "upload a file" prompt. Never - the red "missing" state. --> - <button - type="button" - onclick={handleDataUploadClick} - class={twMerge( - 'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left', - dataUploadReady - ? 'bg-green-50 dark:bg-green-900/30 outline-green-500 dark:outline-green-600' - : style.bg, - dataUploadReady ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border, - 'hover:brightness-95 dark:hover:brightness-110 transition-[filter]' - )} - style="width: {NODE.width}px; min-height: {NODE.height}px;" - title={dataUploadReady - ? `Data upload for ${data.runnable_path ?? ''} — a file is staged; the pipeline is ready to run. Click to change it.` - : `Data upload for ${data.runnable_path ?? ''} — click to open the run form and upload a file`} - > - <DataUploadIcon - size={14} - class={`shrink-0 ml-2 mr-2 ${dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}`} - /> - <div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight"> - <span - class={twMerge( - 'text-3xs uppercase tracking-wide truncate', - dataUploadReady ? 'text-green-700 dark:text-green-400' : 'text-tertiary' - )} - > - {style.label}{dataUploadReady ? ' · ready' : ''} - </span> - <span - class={twMerge( - 'text-2xs font-mono truncate', - dataUploadReady ? 'text-green-700 dark:text-green-400' : 'text-emphasis' - )} - > - {dataUploadReady ? 'File staged' : 'Upload & run'} - </span> - </div> - </button> - {:else} - <div - class={twMerge( - 'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1', - displayMissing - ? 'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500' - : style.bg, - displayMissing ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border - )} - style="width: {NODE.width}px; min-height: {NODE.height}px;" - title={missingTitle ?? - (data.unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`)} - > - <Icon - size={14} - class={`shrink-0 ml-2 mr-2 ${displayMissing ? 'text-red-600 dark:text-red-400' : style.iconText}`} - /> - <div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight"> - <span - class={twMerge( - 'text-3xs uppercase tracking-wide truncate', - displayMissing ? 'text-red-700 dark:text-red-400' : 'text-tertiary' - )} - > - {style.label}{displayMissing ? ' · missing' : data.unsaved ? ' · unsaved' : ''} - </span> - <span - class={twMerge( - 'text-2xs font-mono truncate', - displayMissing ? 'text-red-700 dark:text-red-400' : 'text-emphasis' - )} - > - {displayMissing ? 'no trigger row' : data.ref} - </span> - </div> - </div> - {/if} - - {#if menuItems.length > 0} - <!-- Hover-revealed kebab menu (Delete only for now). Mirrors the - RunnableNode pattern: positioned just outside the top-right of - the node, rendered only on hover or while the menu is open so - the canvas stays clean at rest. `pointerdown` is stopped so - svelte-flow doesn't kick off node selection / drag when the - user reaches for the menu. --> - <div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;"> - <DropdownV2 - items={menuItems} - placement="bottom-end" - bind:open={menuOpen} - fixedHeight={false} - usePointerDownOutside - > - {#snippet buttonReplacement()} - <button - class={twMerge( - 'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary', - hover || menuOpen ? 'block' : '!hidden', - 'shadow-md rounded-md' - )} - onpointerdown={stopPropagation(preventDefault(() => {}))} - title="Actions" - > - <EllipsisVertical size={12} /> - </button> - {/snippet} - </DropdownV2> + {#snippet icon()} + {#if canOpenDataUpload} + <DataUploadIcon + size={14} + class={dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText} + /> + {:else} + <Icon + size={14} + class={displayMissing || canCreate ? 'text-red-600 dark:text-red-400' : style.iconText} + /> + {/if} + {/snippet} + </PipelineNodeCard> + </NodeActionsMenu> + {#if data.fix} + <div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10"> + <NodeFixButton fix={data.fix} /> </div> {/if} </div> diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts index 24e7d4c2da..073432e1a5 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts @@ -147,4 +147,18 @@ describe('layoutAssetGraph (tidy-tree with join breaks)', () => { expect(pos.get(id)!.y).toBeGreaterThan(anchor.y) } }) + + it('packs narrow nodes by their own width', () => { + const pos = layoutAssetGraph({ + nodes: [ + { ...n('p'), width: 100 }, + { ...n('l'), width: 100 }, + { ...n('r'), width: 180 } + ], + edges: [e('p', 'l'), e('p', 'r')] + }) + const gap = pos.get('r')!.x - 90 - (pos.get('l')!.x + 50) + expect(gap).toBe(NODE.gap.horizontal) + expect(pos.get('l')!.x).toBe(50) + }) }) diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts index c9d7d60355..e1e1e1e7da 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts @@ -7,7 +7,15 @@ import { NODE } from '$lib/components/graph/util' // breathing room. const NODE_WIDTH = NODE.width const NODE_HEIGHT = NODE.height + 30 -const LAYER_GAP = NODE.gap.vertical +/** Rendered height of a pipeline node: a kind label over a title. Fits inside + * the `NODE_HEIGHT` row the layout reserves. */ +export const PIPELINE_NODE_HEIGHT = 48 +// A step more than the flow editor's gap: the + on a node's bottom edge sits in it. +const LAYER_GAP = NODE.gap.vertical + 16 +/** Height of the "Add data source" pill the anchor draws, and its gap to the + * graph: tighter than LAYER_GAP, since no edge runs through it. */ +const ANCHOR_HEIGHT = 32 +const ANCHOR_GAP = 40 const SIBLING_GAP = NODE.gap.horizontal // Horizontal gutter between two disjoint subgraphs. Wider than the // within-subgraph sibling gap so visually-unrelated components (e.g. two @@ -15,10 +23,16 @@ const SIBLING_GAP = NODE.gap.horizontal // into each other's columns — but only ~2×, so they don't drift far apart. const COMPONENT_GAP = SIBLING_GAP * 2 -const LAYER_H = NODE_HEIGHT + LAYER_GAP +/** Extra height of an assets-only node: the trigger chip under its title. */ +export const PIPELINE_NODE_EXTRA_ROW = 20 +/** Height of the header strip naming an assets-only node's producing script. */ +export const PIPELINE_NODE_HEADER = 22 +/** Extra vertical padding of a card under a header, top plus bottom. */ +export const PIPELINE_NODE_HEADER_CARD_PAD = 12 interface GraphInput { - nodes: Array<{ id: string; data: AssetGraphNodeData }> + /** `width` defaults to `NODE.width`. */ + nodes: Array<{ id: string; data: AssetGraphNodeData; width?: number }> edges: Array<{ source: string; target: string }> } @@ -52,15 +66,18 @@ interface Band { // intersects theirs. // // y comes from longest-path layering (same top-down orientation as before: -// producers above, assets in the middle, consumers below). Returns positions -// (band centers) normalized so the component's min x,y = 0. Cyclic input is -// handled by dropping feedback edges (see the Kahn step below). +// producers above, assets in the middle, consumers below). Returns node +// centers, normalized so the component's leftmost edge and top are at 0. +// Cyclic input is handled by dropping feedback edges (see the Kahn step below). function layoutComponent( nodes: GraphInput['nodes'], - edges: GraphInput['edges'] + edges: GraphInput['edges'], + rowH: number ): Map<string, Positioned> { + const layerH = rowH + LAYER_GAP const out = new Map<string, Positioned>() const ids = new Set(nodes.map((n) => n.id)) + const nodeW = new Map(nodes.map((n) => [n.id, n.width ?? NODE_WIDTH])) const parents = new Map<string, string[]>() const children = new Map<string, string[]>() @@ -137,7 +154,7 @@ function layoutComponent( const kids = treeChildren.get(id)! const kidsW = kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * Math.max(0, kids.length - 1) - W.set(id, Math.max(NODE_WIDTH, kidsW)) + W.set(id, Math.max(nodeW.get(id)!, kidsW)) } // Vertical (pixel) extent of a subtree, for band collision checks. @@ -152,14 +169,14 @@ function layoutComponent( if (l > hi) hi = l for (const k of treeChildren.get(cur)!) stack.push(k) } - return { top: lo * LAYER_H, bottom: hi * LAYER_H + NODE_HEIGHT } + return { top: lo * layerH, bottom: hi * layerH + rowH } } // Recursive placement: node centered over its band, children packed // side-by-side and centered within it. function placeTree(id: string, left: number) { const w = W.get(id)! - out.set(id, { x: left + w / 2, y: layer.get(id)! * LAYER_H }) + out.set(id, { x: left + w / 2, y: layer.get(id)! * layerH }) const kids = treeChildren.get(id)! if (kids.length === 0) return const kidsW = kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * (kids.length - 1) @@ -211,11 +228,11 @@ function layoutComponent( placeAndRecord(id, center - w / 2) } - // Normalize so the component's min x,y = 0. + // Normalize so the component's leftmost edge and top are at 0. let minX = Infinity let minY = Infinity - for (const p of out.values()) { - if (p.x < minX) minX = p.x + for (const [id, p] of out) { + if (p.x - nodeW.get(id)! / 2 < minX) minX = p.x - nodeW.get(id)! / 2 if (p.y < minY) minY = p.y } if (isFinite(minX) && isFinite(minY)) { @@ -243,7 +260,13 @@ function layoutComponent( // // Falls back to a stable grid if the component layout throws (defensive — // cycles are already absorbed by feedback-edge dropping in layoutComponent). -export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<string, Positioned> { +export function layoutAssetGraph( + graph: GraphInput, + anchorId?: string, + extraRowHeight = 0 +): Map<string, Positioned> { + const rowH = NODE_HEIGHT + extraRowHeight + const layerH = rowH + LAYER_GAP const byId = new Map<string, Positioned>() if (graph.nodes.length === 0) return byId @@ -297,19 +320,20 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri // 3. Lay out each component and pack side-by-side. xOffset advances by // the laid-out width of each component (max node center + a node width, // since positions are node centers) plus the gutter. + const widthOf = new Map(nodes.map((n) => [n.id, n.width ?? NODE_WIDTH])) let xOffset = 0 for (let c = 0; c < nComp; c++) { - const positions = layoutComponent(compNodes[c], compEdges[c]) - let maxX = 0 - for (const p of positions.values()) if (p.x > maxX) maxX = p.x + const positions = layoutComponent(compNodes[c], compEdges[c], rowH) + let maxRight = 0 for (const [id, p] of positions) { + maxRight = Math.max(maxRight, p.x + widthOf.get(id)! / 2) byId.set(id, { x: p.x + xOffset, y: p.y }) } - xOffset += maxX + NODE_WIDTH + COMPONENT_GAP + xOffset += maxRight + COMPONENT_GAP } // 4. Re-place the anchor centered horizontally over the whole packed - // graph, one layer above it; then renormalize so the anchor sits at the + // graph, just above it; then renormalize so the anchor sits at the // top (y = 0), pushing the components down a layer to make room (mirrors // the previous "anchor is the parent of every root" behaviour). if (anchorId && graph.nodes.some((n) => n.id === anchorId)) { @@ -324,7 +348,9 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri if (p.x > maxX) maxX = p.x if (p.y < minY) minY = p.y } - byId.set(anchorId, { x: (minX + maxX) / 2, y: minY - LAYER_H }) + // One gap above the graph, sized to the pill rather than a full node row: + // a row tall enough for an assets-only card leaves it floating high. + byId.set(anchorId, { x: (minX + maxX) / 2, y: minY - ANCHOR_HEIGHT - ANCHOR_GAP }) let nMinY = Infinity for (const p of byId.values()) if (p.y < nMinY) nMinY = p.y for (const p of byId.values()) p.y -= nMinY @@ -336,7 +362,7 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri graph.nodes.forEach((n, i) => { byId.set(n.id, { x: (i % cols) * (NODE_WIDTH + SIBLING_GAP), - y: Math.floor(i / cols) * LAYER_H + y: Math.floor(i / cols) * layerH }) }) return byId diff --git a/frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts b/frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts new file mode 100644 index 0000000000..176ecf7411 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts @@ -0,0 +1,66 @@ +import { NODE } from '$lib/components/graph/util' + +const MIN_WIDTH = 160 + +let ctx: CanvasRenderingContext2D | null | undefined + +/** Rendered width of `text` at `rem` in the page font, or a rough guess without a DOM. */ +function textWidth(text: string, rem: number): number { + if (typeof document === 'undefined') return text.length * rem * 16 * 0.6 + ctx ??= document.createElement('canvas').getContext('2d') + const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 + if (!ctx) return text.length * rem * root * 0.6 + const body = getComputedStyle(document.body) + ctx.font = `${rem * root}px ${body.fontFamily}` + const spacing = parseFloat(body.letterSpacing) || 0 + return ctx.measureText(text).width + spacing * text.length +} + +/** + * Width of an assets-only asset card, from the pieces `PipelineNodeCard` and + * `AssetNode` lay out: icon and the widest of kind / title / trigger chip, or the + * header naming the producing script, if wider. The numbers mirror their Tailwind spacing; change one + * and the other drifts. Clamped to the regular node width, past which the title + * truncates as usual. + */ +export function assetsOnlyNodeWidth(n: { + kind: string + title: string + chip: string + /** The chip leads with an icon (the folded-errors Fix chip). */ + chipIcon?: boolean + /** The chip ends in the edit icon (an editable trigger). */ + chipEdit?: boolean + /** The header tab: its text (a script's summary or path, "N scripts"), icon + * count, and whether it ends in the menu chevron (else the edit icon) or has a "draft" mark; + * undefined without a header. */ + header?: { label: string; icons: number; chevron?: boolean; draft?: boolean } +}): number { + const icon = 12 + 14 + 10 + const text = Math.max( + textWidth(n.kind, 0.7), + textWidth(n.title, 0.75), + textWidth(n.chip, 0.65) + 12 + 2 + (n.chipIcon ? 14 : 0) + (n.chipEdit ? 4 + 9 : 0) + ) + const body = Math.ceil(icon + text + 4 + 8 + 2 + 8) + // Header tab: mx-2 and a 1px border around px-2, then gap-1.5 between its + // items: the icons, the label, a "draft" mark, and the chevron or edit icon. + // The run chip is left out: it comes and goes with runs, and a width that + // followed it would re-lay the graph on a click; the label truncates instead. + const h = n.header + const header = h + ? Math.ceil( + 16 + + 2 + + 16 + + h.icons * (11 + 6) + + textWidth(h.label, 0.65) + + (h.draft ? 6 + textWidth('draft', 0.65) : 0) + + // The menu chevron, or the edit icon of a single script. + 6 + + (h.chevron ? 11 : 10) + + 4 + ) + : 0 + return Math.min(NODE.width, Math.max(MIN_WIDTH, body, header)) +} diff --git a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyFolders.ts b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyFolders.ts new file mode 100644 index 0000000000..4079986ee5 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyFolders.ts @@ -0,0 +1,25 @@ +// Pipelines open in the assets-only view. This remembers the ones last left +// showing their scripts and triggers, as `<workspace>/<folder>`, so reopening one +// shows it the same way. Browser-local: a missing or unreadable list reads as +// "none", and storage that throws (private mode, blocked site data) is ignored. +const KEY = 'pipeline-full-view-folders' + +function read(): string[] { + try { + const parsed = JSON.parse(localStorage.getItem(KEY) ?? '[]') + return Array.isArray(parsed) ? parsed.filter((f) => typeof f === 'string') : [] + } catch { + return [] + } +} + +export function isAssetsOnlyFolder(folder: string): boolean { + return !read().includes(folder) +} + +export function setAssetsOnlyFolder(folder: string, on: boolean): void { + const rest = read().filter((f) => f !== folder) + try { + localStorage.setItem(KEY, JSON.stringify(on ? rest : [...rest, folder])) + } catch {} +} diff --git a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts new file mode 100644 index 0000000000..0f2cf472ac --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' +import { + assetsOnlyView, + upstreamDeletion, + noAssetNodeId, + withoutPassiveReads, + type AssetUpstream +} from './assetsOnlyView' + +const node = (id: string, type: string, data: any = {}) => ({ id, type, data }) +const edge = (source: string, target: string, kind: string) => ({ + id: `${kind}:${source}->${target}`, + source, + target, + kind +}) + +describe('assetsOnlyView', () => { + const nodes = [ + node('asset:ducklake:raw', 'asset'), + node('asset:ducklake:clean', 'asset'), + node('asset:ducklake:report', 'asset'), + node('script:f/p/clean', 'runnable'), + node('script:f/p/report', 'runnable'), + node('script:f/p/other', 'runnable'), + node('trigger:schedule:f/p/daily', 'trigger', { kind: 'schedule', schedule: '0 0 4 * * *' }) + ] + const edges = [ + edge('trigger:schedule:f/p/daily', 'script:f/p/clean', 'trigger-native'), + edge('asset:ducklake:raw', 'script:f/p/clean', 'lineage-read'), + edge('script:f/p/clean', 'asset:ducklake:clean', 'lineage-write'), + // read-modify-write: no self edge + edge('asset:ducklake:clean', 'script:f/p/clean', 'lineage-read'), + edge('asset:ducklake:clean', 'script:f/p/report', 'trigger-asset'), + edge('asset:ducklake:clean', 'script:f/p/report', 'lineage-read'), + edge('script:f/p/report', 'asset:ducklake:report', 'lineage-write'), + edge('script:f/p/other', 'asset:ducklake:report', 'lineage-write') + ] + const v = assetsOnlyView(nodes, edges) + + it('joins each script input to its outputs, once', () => { + expect(v.edges.map((e) => `${e.source} -> ${e.target}`).sort()).toEqual([ + 'asset:ducklake:clean -> asset:ducklake:report', + 'asset:ducklake:raw -> asset:ducklake:clean' + ]) + expect([...v.nodeIds].sort()).toEqual([ + 'asset:ducklake:clean', + 'asset:ducklake:raw', + 'asset:ducklake:report' + ]) + }) + + it('names the single producer and its trigger, or marks the asset ambiguous', () => { + expect(v.upstream.get('asset:ducklake:raw')).toBeUndefined() + expect(v.upstream.get('asset:ducklake:clean')).toMatchObject({ + multiple: false, + runnableId: 'script:f/p/clean', + trigger: { kind: 'schedule', data: { schedule: '0 0 4 * * *' } } + }) + expect(v.upstream.get('asset:ducklake:report')).toEqual({ + multiple: true, + runnableIds: ['script:f/p/report', 'script:f/p/other'] + }) + }) +}) + +describe('the No asset nodes', () => { + it('give each script that builds nothing its own node, fed by what it reads', () => { + const v = assetsOnlyView( + [ + node('asset:ducklake:a', 'asset'), + node('asset:ducklake:b', 'asset'), + node('script:f/p/on_a', 'runnable'), + node('script:f/p/on_b', 'runnable'), + node('script:f/p/dbt', 'runnable', { dbt: { model_count: 3 } }), + node('script:f/p/macros', 'runnable', { macros: [{ name: 'm' }] }) + ], + [ + edge('asset:ducklake:a', 'script:f/p/on_a', 'trigger-asset'), + edge('asset:ducklake:b', 'script:f/p/on_b', 'trigger-asset') + ] + ) + const onA = noAssetNodeId('script:f/p/on_a') + const onB = noAssetNodeId('script:f/p/on_b') + expect(v.noAssetNodeIds).toEqual([onA, onB]) + expect(v.upstream.get(onA)).toMatchObject({ runnableId: 'script:f/p/on_a' }) + expect(v.edges.map((e) => `${e.source} -> ${e.target}`)).toEqual([ + `asset:ducklake:a -> ${onA}`, + `asset:ducklake:b -> ${onB}` + ]) + }) +}) + +describe('upstreamDeletion', () => { + const script = { runnable_kind: 'script', path: 'f/p/a' } + const schedule = (targets: string[]) => ({ + kind: 'schedule', + nodeId: 'trigger:schedule:f/p/a_schedule', + data: { ref: 'f/p/a_schedule', runnable_paths: targets } + }) + const own = (trigger?: any): AssetUpstream & { multiple: false } => ({ + multiple: false, + runnableId: 'script:f/p/a', + trigger + }) + + it('deletes the script and a trigger that fires only it', () => { + const u = own(schedule(['f/p/a'])) + expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))).toEqual({ + script: { path: 'f/p/a', unsaved: false }, + trigger: { kind: 'schedule', path: 'f/p/a_schedule', draft: false } + }) + }) + + it('keeps a trigger that fires other scripts', () => { + const u = own(schedule(['f/p/a', 'f/p/b'])) + expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))?.trigger).toBeUndefined() + }) + + it('deletes nothing when the script also co-produces another asset', () => { + const u = own(schedule(['f/p/a'])) + const all = new Map<string, AssetUpstream>([ + ['asset:x', u], + ['asset:y', { multiple: true, runnableIds: ['script:f/p/a', 'script:f/p/b'] }] + ]) + expect(upstreamDeletion(u, script, all)).toBeUndefined() + }) + + it('deletes nothing when the script builds another asset too', () => { + const u = own(schedule(['f/p/a'])) + const all = new Map([ + ['asset:x', u], + ['asset:y', u] + ]) + expect(upstreamDeletion(u, script, all)).toBeUndefined() + }) +}) + +describe('withoutPassiveReads', () => { + it('drops reads that trigger nothing, and the assets only they drew', () => { + const nodes = [ + node('asset:src', 'asset'), + node('asset:lookup', 'asset'), + node('asset:out', 'asset'), + node('script:a', 'runnable') + ] + const edges = [ + edge('asset:src', 'script:a', 'lineage-read'), + edge('asset:src', 'script:a', 'trigger-asset'), + edge('asset:lookup', 'script:a', 'lineage-read'), + edge('asset:out', 'script:a', 'lineage-read'), + edge('script:a', 'asset:out', 'lineage-write') + ] + const r = withoutPassiveReads(nodes, edges) + expect(r.nodes.map((n) => n.id)).toEqual(['asset:src', 'asset:out', 'script:a']) + expect(r.edges.map((e) => e.id)).toEqual([ + 'lineage-read:asset:src->script:a', + 'trigger-asset:asset:src->script:a', + 'lineage-write:script:a->asset:out' + ]) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts new file mode 100644 index 0000000000..c7bf85d144 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts @@ -0,0 +1,234 @@ +import type { NativeTriggerKind } from './types' + +/** A node or edge of the canvas model, reduced to what the assets-only view reads. */ +export type ViewNode = { id: string; type: string; data: any } +export type ViewEdge = { + id: string + source: string + target: string + kind: string + unsaved?: boolean + /** Assets-only edges: the folded runnables the data goes through. */ + via?: string[] + /** Assets-only edges: a write to the source reruns one of `via`. */ + reactive?: boolean +} + +/** The assets-only node standing for a script that builds no asset: one per + * script, so scripts reacting to different inputs stay apart. */ +export const noAssetNodeId = (runnableId: string) => `no-asset:${runnableId}` +export const isNoAssetNode = (id: string) => id.startsWith('no-asset:') + +/** What an assets-only delete removes: the script, its trigger, or both. */ +export type AssetUpstreamDelete = { + script?: { path: string; unsaved: boolean } + trigger?: { kind: NativeTriggerKind; path: string; draft: boolean } +} + +/** What an asset shows of the script and trigger that produce it, in the assets-only view. */ +export type AssetUpstream = + /** Several producing scripts, or one with several triggers. */ + | { multiple: true; runnableIds: string[] } + | { + multiple: false + /** Canvas id of the producing runnable (`script:<path>`). */ + runnableId: string + /** The producer's trigger; undefined when nothing triggers it (run by hand). */ + trigger?: { nodeId?: string; kind: string; data?: any } + } + +/** + * The asset → asset view of a pipeline graph: runnables and triggers disappear, + * and every read (or asset trigger) of a script is joined to each of its writes, + * so an asset points at the assets computed from it. Each produced asset keeps a + * reference to its single producer and that producer's single trigger, or is + * marked `multiple` when either is ambiguous. + */ +export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>( + nodes: N[], + edges: E[] +): { + nodeIds: Set<string> + edges: Array<ViewEdge> + upstream: Map<string, AssetUpstream> + /** A node per script that builds no asset (`noAssetNodeId`), its upstream + * under that id. */ + noAssetNodeIds: string[] +} { + const nodeById = new Map(nodes.map((n) => [n.id, n])) + const inputsOf = new Map<string, E[]>() + const triggersOf = new Map<string, E[]>() + const producersOf = new Map<string, E[]>() + const push = (m: Map<string, E[]>, k: string, e: E) => m.set(k, [...(m.get(k) ?? []), e]) + for (const e of edges) { + if (e.kind === 'lineage-write') push(producersOf, e.target, e) + else if (e.kind === 'lineage-read' || e.kind === 'trigger-asset') { + push(inputsOf, e.target, e) + if (e.kind === 'trigger-asset') push(triggersOf, e.target, e) + } else if (e.kind === 'trigger-native') push(triggersOf, e.target, e) + } + + const triggered = new Set( + edges.filter((e) => e.kind === 'trigger-asset').map((e) => `${e.source}\n${e.target}`) + ) + const out = new Map<string, ViewEdge>() + for (const [assetId, writes] of producersOf) { + for (const w of writes) { + for (const r of inputsOf.get(w.source) ?? []) { + // A read-modify-write of the same table is not a dependency on itself. + if (r.source === assetId) continue + const id = `flow:${r.source}->${assetId}` + const prev = out.get(id) + const unsaved = !!(w.unsaved || r.unsaved) + const reactive = triggered.has(`${r.source}\n${w.source}`) + if (prev) { + prev.unsaved = prev.unsaved && unsaved + prev.reactive ||= reactive + if (!prev.via?.includes(w.source)) prev.via = [...(prev.via ?? []), w.source] + } else { + out.set(id, { + id, + source: r.source, + target: assetId, + kind: 'asset-flow', + unsaved, + reactive, + via: [w.source] + }) + } + } + } + } + for (const e of edges) if (e.kind === 'dbt-ref') out.set(e.id, e) + + function describe(runnableIds: string[]): AssetUpstream { + if (runnableIds.length !== 1) return { multiple: true, runnableIds } + const runnableId = runnableIds[0] + // Every asset trigger of a script is one cause, "an input changed". + const triggers = [ + ...new Map( + (triggersOf.get(runnableId) ?? []).map((t) => [ + t.kind === 'trigger-asset' ? 'asset' : t.source, + t + ]) + ).values() + ] + if (triggers.length > 1) return { multiple: true, runnableIds } + const t = triggers[0] + const tNode = t && nodeById.get(t.source) + return { + multiple: false, + runnableId, + trigger: !t + ? undefined + : t.kind === 'trigger-asset' + ? { kind: 'asset' } + : { nodeId: t.source, kind: tNode?.data?.kind, data: tNode?.data } + } + } + + const upstream = new Map<string, AssetUpstream>() + for (const [assetId, writes] of producersOf) { + upstream.set(assetId, describe([...new Set(writes.map((w) => w.source))])) + } + + // Scripts that build no asset have nowhere to fold into, so each gets a "No + // asset" node, fed by what it reads. dbt projects (whose writes are not drawn) + // and macro libraries (which build nothing by design) get none. + const writers = new Set([...producersOf.values()].flat().map((w) => w.source)) + const orphans = nodes + .filter( + (n) => n.type === 'runnable' && !writers.has(n.id) && !n.data?.dbt && !n.data?.macros?.length + ) + .map((n) => n.id) + for (const id of orphans) { + const nodeId = noAssetNodeId(id) + upstream.set(nodeId, describe([id])) + for (const r of inputsOf.get(id) ?? []) { + const eid = `flow:${r.source}->${nodeId}` + const reactive = triggered.has(`${r.source}\n${id}`) + const prev = out.get(eid) + if (prev) { + prev.unsaved = prev.unsaved && !!r.unsaved + prev.reactive ||= reactive + } else { + out.set(eid, { + id: eid, + source: r.source, + target: nodeId, + kind: 'asset-flow', + unsaved: !!r.unsaved, + reactive, + via: [id] + }) + } + } + } + + const nodeIds = new Set(nodes.filter((n) => n.type === 'asset').map((n) => n.id)) + return { + nodeIds, + edges: [...out.values()], + upstream, + noAssetNodeIds: orphans.map(noAssetNodeId) + } +} + +/** What deleting an asset's upstream removes: the script unless another asset + * is also built by it, and with it the trigger unless that fires other scripts. + * Undefined when nothing can go. */ +export function upstreamDeletion( + u: AssetUpstream & { multiple: false }, + r: { runnable_kind: string; path: string; unsaved?: boolean }, + all: Map<string, AssetUpstream> +): AssetUpstreamDelete | undefined { + if (r.runnable_kind !== 'script') return undefined + const producesOthers = + [...all.values()].filter((o) => + o.multiple ? o.runnableIds.includes(u.runnableId) : o.runnableId === u.runnableId + ).length > 1 + if (producesOthers) return undefined + const t = u.trigger + const kind = t?.kind as NativeTriggerKind | undefined + const targets: string[] = t?.data?.runnable_paths ?? [] + const ownTrigger = + kind && + kind !== 'webhook' && + kind !== 'data_upload' && + t?.data?.ref && + !t.data.missing && + targets.every((p) => p === r.path) + return { + script: { path: r.path, unsaved: !!r.unsaved }, + trigger: ownTrigger ? { kind, path: t.data.ref, draft: !!t.data.draft } : undefined + } +} + +/** + * The graph without its reads that start nothing: a read edge with no asset + * trigger along it, and the assets only such reads drew. + */ +export function withoutPassiveReads<N extends ViewNode, E extends ViewEdge>( + nodes: N[], + edges: E[] +): { nodes: N[]; edges: E[]; dropped: number } { + const triggers = new Set( + edges.filter((e) => e.kind === 'trigger-asset').map((e) => `${e.source}\n${e.target}`) + ) + const passive = (e: E) => + e.kind === 'lineage-read' && !triggers.has(`${e.source}\n${e.target}`) + const kept = edges.filter((e) => !passive(e)) + const dropped = edges.length - kept.length + if (dropped === 0) return { nodes, edges, dropped } + const linked = new Set( + kept.filter((e) => e.kind !== 'add-anchor').flatMap((e) => [e.source, e.target]) + ) + const readOnly = new Set( + edges.filter(passive).map((e) => e.source).filter((id) => !linked.has(id)) + ) + return { + nodes: nodes.filter((n) => !readOnly.has(n.id)), + edges: kept.filter((e) => !readOnly.has(e.source) && !readOnly.has(e.target)), + dropped + } +} diff --git a/frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts new file mode 100644 index 0000000000..2aff7a0e81 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { describeEdge } from './edgeDescription' + +const names: Record<string, string> = { + 'asset:ducklake:main/raw': 'raw', + 'asset:ducklake:main/clean': 'clean', + 'script:f/p/clean': 'Clean', + 'trigger:schedule:f/p/daily': 'Daily' +} +const nameOf = (id: string) => names[id] ?? id +const dataOf = (id: string) => + id === 'trigger:schedule:f/p/daily' + ? { kind: 'schedule', schedule: '0 0 12 * * *', timezone: 'UTC' } + : undefined + +describe('describeEdge', () => { + it('names an asset trigger by what runs it: any write, changed or not', () => { + expect( + describeEdge( + { source: 'asset:ducklake:main/raw', target: 'script:f/p/clean', kind: 'trigger-asset' }, + nameOf, + dataOf + ) + ).toEqual({ subject: 'Clean', phrase: 'runs after each write to', object: 'raw' }) + }) + + it("gives a schedule's cadence", () => { + expect( + describeEdge( + { source: 'trigger:schedule:f/p/daily', target: 'script:f/p/clean', kind: 'trigger-native' }, + nameOf, + dataOf + )?.phrase + ).toBe('runs on schedule: Every day at 12:00 UTC') + }) + + it('names the folded script on an assets-only edge', () => { + expect( + describeEdge( + { + source: 'asset:ducklake:main/raw', + target: 'asset:ducklake:main/clean', + kind: 'asset-flow', + via: ['script:f/p/clean'] + }, + nameOf, + dataOf + ) + ).toEqual({ subject: 'clean', phrase: 'is built from', object: 'raw', note: 'By Clean. A write to raw does not rerun it.' }) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts new file mode 100644 index 0000000000..72d8eb103b --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts @@ -0,0 +1,110 @@ +import { describeSchedule } from '$lib/utils/describeCron' +import { isNoAssetNode } from './assetsOnlyView' + +/** What a hovered edge says: "`subject` `phrase` `object`", then a note. */ +export type EdgeDescription = { subject: string; phrase: string; object?: string; note?: string } + +/** The edge fields the description reads (the canvas model's edge). */ +export type DescribedEdge = { + source: string + target: string + kind: string + unsaved?: boolean + muted?: boolean + missing?: boolean + via?: string[] + reactive?: boolean + macro_names?: string[] + via_use?: boolean +} + +// What starts a script, per trigger kind: "runs on <event>". +const TRIGGER_EVENT: Record<string, string> = { + kafka: 'Kafka message', + mqtt: 'MQTT message', + amqp: 'AMQP message', + nats: 'NATS message', + sqs: 'SQS message', + gcp: 'Pub/Sub message', + email: 'incoming email', + postgres: 'Postgres event', + webhook: 'webhook call', + data_upload: 'data upload' +} + +/** + * The relationship an edge draws, as a sentence about its two ends. `nameOf` + * names a node, `dataOf` gives its data (a trigger's kind and cadence). + */ +export function describeEdge( + e: DescribedEdge, + nameOf: (id: string) => string, + dataOf: (id: string) => any +): EdgeDescription | undefined { + const src = nameOf(e.source) + const tgt = nameOf(e.target) + const draft = e.unsaved ? 'Not deployed yet.' : undefined + switch (e.kind) { + case 'lineage-write': + return { subject: src, phrase: 'writes to', object: tgt, note: draft } + case 'lineage-read': + return { + subject: tgt, + phrase: 'reads from', + object: src, + note: e.muted ? 'A write to it does not run the script (muted).' : draft + } + case 'trigger-asset': + return { subject: tgt, phrase: 'runs after each write to', object: src, note: draft } + case 'trigger-native': { + const t = dataOf(e.source) ?? {} + if (e.missing) { + return { + subject: tgt, + phrase: `declares \`// on ${t.kind}\`, but no trigger of that kind targets it` + } + } + const phrase = + t.kind === 'schedule' && t.schedule + ? `runs on schedule: ${describeSchedule(t.schedule, t.timezone) ?? t.schedule}` + : `runs on ${TRIGGER_EVENT[t.kind] ?? `${t.kind} trigger`}` + return { subject: tgt, phrase, note: t.draft ? 'Draft trigger, created on save.' : draft } + } + case 'asset-flow': { + const via = (e.via ?? []).map(nameOf).join(', ') + const rerun = e.reactive + ? `Each write to ${src} reruns it.` + : `A write to ${src} does not rerun it.` + // The scripts that build nothing only read: name them, not the node. + if (isNoAssetNode(e.target)) { + return { subject: src, phrase: 'is read by', object: via || tgt, note: rerun } + } + return { + subject: tgt, + phrase: 'is built from', + object: src, + note: via ? `By ${via}. ${rerun}` : rerun + } + } + case 'dbt-ref': + return { subject: tgt, phrase: 'references', object: src, note: 'dbt ref().' } + case 'macro': + return { + subject: tgt, + phrase: e.via_use ? 'uses the macro library' : 'uses macros from', + object: src, + note: e.macro_names?.length ? e.macro_names.join(', ') : undefined + } + case 'test-dependency': + return { + subject: src, + phrase: 'must run before', + object: tgt, + note: 'Its data test reads what the first one builds.' + } + case 'data-test': + return { subject: tgt, phrase: 'tests', object: src } + default: + return undefined + } +} diff --git a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts index 197d7eca6c..d6cf76c389 100644 --- a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import type { AssetGraphResponse } from './types' import { + hoverLineage, + transitivelyImpliedEdges, buildDownstreamMap, computeDownstreamClosure, computeInducedSchedule @@ -225,3 +227,44 @@ describe('computeInducedSchedule', () => { expect(sched.cyclic.sort()).toEqual(['b', 'c']) }) }) + +describe('hoverLineage', () => { + // a → b → c → d, with a side branch x → c and c → y; z is unrelated to b. + const edges = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'x', target: 'c' }, + { source: 'c', target: 'd' }, + { source: 'c', target: 'y' }, + { source: 'z', target: 'y' } + ] + + it('reaches everything upstream and downstream, recursively', () => { + const l = hoverLineage(edges, 'b') + expect([...l.upstream].sort()).toEqual(['a', 'b']) + expect([...l.downstream].sort()).toEqual(['b', 'c', 'd', 'y']) + }) + + it('keeps only the edges on a path through the node', () => { + const l = hoverLineage(edges, 'b') + expect(edges.filter(l.hasEdge)).toEqual([ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'd' }, + { source: 'c', target: 'y' } + ]) + }) +}) + +describe('transitivelyImpliedEdges', () => { + it('flags an edge another path already implies, and only that one', () => { + const edges = [ + { id: 'ab', source: 'a', target: 'b' }, + { id: 'bc', source: 'b', target: 'c' }, + { id: 'ac', source: 'a', target: 'c' }, + { id: 'ad', source: 'a', target: 'd' }, + { id: 'ad2', source: 'a', target: 'd' } + ] + expect([...transitivelyImpliedEdges(edges)]).toEqual(['ac']) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts index cc59e8f3a8..0c9fad6c83 100644 --- a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts +++ b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts @@ -226,3 +226,76 @@ export function computeInducedSchedule( const roots = ordered.filter((n) => (cleanIndegree.get(n) ?? 0) === 0) return { nodes: ordered, edges: cleanEdges, indegree: cleanIndegree, roots, cyclic } } + +/** What is linked to a node through the drawn graph: everything it depends on + * (upstream) and everything depending on it (downstream), both recursive, the + * node itself in each. An edge lies on a path through the node exactly when + * both its ends are upstream, or both downstream. */ +export type HoverLineage = { + upstream: Set<string> + downstream: Set<string> + hasEdge: (e: { source: string; target: string }) => boolean +} + +export function hoverLineage( + edges: ReadonlyArray<{ source: string; target: string }>, + id: string +): HoverLineage { + const walk = (from: 'source' | 'target', to: 'source' | 'target') => { + const next = new Map<string, string[]>() + for (const e of edges) next.set(e[from], [...(next.get(e[from]) ?? []), e[to]]) + const seen = new Set([id]) + const stack = [id] + while (stack.length) { + for (const n of next.get(stack.pop()!) ?? []) { + if (!seen.has(n)) { + seen.add(n) + stack.push(n) + } + } + } + return seen + } + const upstream = walk('target', 'source') + const downstream = walk('source', 'target') + return { + upstream, + downstream, + hasEdge: (e) => + (upstream.has(e.source) && upstream.has(e.target)) || + (downstream.has(e.source) && downstream.has(e.target)) + } +} + +/** Ids of the edges another path already implies: an edge A → C when C can be + * reached from A through at least one other node (A → B → … → C). Edges joining + * the same two nodes don't make each other redundant. */ +export function transitivelyImpliedEdges( + edges: ReadonlyArray<{ id: string; source: string; target: string }> +): Set<string> { + const next = new Map<string, Set<string>>() + for (const e of edges) { + if (e.source === e.target) continue + next.set(e.source, (next.get(e.source) ?? new Set()).add(e.target)) + } + // Whether `to` is reachable from `from` without taking the direct step. + const reachableAround = (from: string, to: string) => { + const seen = new Set<string>([from]) + const stack = [...(next.get(from) ?? [])].filter((n) => n !== to) + for (const n of stack) seen.add(n) + while (stack.length) { + const cur = stack.pop()! + for (const n of next.get(cur) ?? []) { + if (n === to) return true + if (!seen.has(n)) { + seen.add(n) + stack.push(n) + } + } + } + return false + } + const implied = new Set<string>() + for (const e of edges) if (e.source !== e.target && reachableAround(e.source, e.target)) implied.add(e.id) + return implied +} diff --git a/frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.test.ts b/frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.test.ts new file mode 100644 index 0000000000..bcd9a2c6fd --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { onDirectiveTargets } from './onDirectiveTargets' + +describe('onDirectiveTargets', () => { + it('leaves out what the script feeds, directly or not, and what already triggers it', () => { + const nodes = [ + { id: 'script:a', type: 'runnable' }, + { id: 'script:b', type: 'runnable' }, + { id: 'asset:out', type: 'asset' }, + { id: 'asset:later', type: 'asset' }, + { id: 'asset:in', type: 'asset' }, + { id: 'asset:free', type: 'asset' } + ] + const edges = [ + { source: 'script:a', target: 'asset:out', kind: 'lineage-write' }, + { source: 'asset:out', target: 'script:b', kind: 'trigger-asset' }, + { source: 'script:b', target: 'asset:later', kind: 'lineage-write' }, + { source: 'asset:in', target: 'script:a', kind: 'trigger-asset' } + ] + expect([...onDirectiveTargets(nodes, edges, 'script:a')]).toEqual(['asset:free']) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.ts b/frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.ts new file mode 100644 index 0000000000..4fc348dd51 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.ts @@ -0,0 +1,34 @@ +/** + * The asset nodes a script can be subscribed to (`// on <asset>`) by dragging: + * every asset except those downstream of it, whose writes it would then run on + * in a loop, and those that already trigger it. + */ +export function onDirectiveTargets( + nodes: Array<{ id: string; type: string }>, + edges: Array<{ source: string; target: string; kind: string }>, + scriptId: string +): Set<string> { + const next = new Map<string, string[]>() + for (const e of edges) { + if (e.kind === 'add-anchor') continue + next.set(e.source, [...(next.get(e.source) ?? []), e.target]) + } + const downstream = new Set<string>([scriptId]) + const stack = [scriptId] + while (stack.length) { + for (const n of next.get(stack.pop()!) ?? []) { + if (!downstream.has(n)) { + downstream.add(n) + stack.push(n) + } + } + } + const triggering = new Set( + edges.filter((e) => e.kind === 'trigger-asset' && e.target === scriptId).map((e) => e.source) + ) + return new Set( + nodes + .filter((n) => n.type === 'asset' && !downstream.has(n.id) && !triggering.has(n.id)) + .map((n) => n.id) + ) +} diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.test.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.test.ts index d1d5c1b233..713ef8f6b2 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.test.ts @@ -8,7 +8,8 @@ import type { AssetGraphResponse } from './types' function makeHandle( initial: Array<[string, PipelineDraft]> = [], runnables: Array<{ path: string }> = [], - folder = 'x' + folder = 'x', + triggers: unknown[] = [] ) { let drafts = new Map(initial) let forgotten: string[] = [] @@ -16,11 +17,13 @@ function makeHandle( getFolder: () => folder, getWorkspace: () => 'w', getResolvedGraph: () => - ({ assets: [], runnables, edges: [], triggers: [] }) as unknown as AssetGraphResponse, + ({ assets: [], runnables, edges: [], triggers }) as unknown as AssetGraphResponse, getDrafts: () => drafts, setDrafts: (next) => (drafts = next), newDraftLocalId: () => 'id', - onForgetPath: (p) => forgotten.push(p) + onForgetPath: (p) => forgotten.push(p), + getTriggerDrafts: () => new Map(), + setTriggerDraft: () => true }) return { handle, drafts: () => drafts, forgotten: () => forgotten } } @@ -155,6 +158,30 @@ describe('pipeline AI direct-draft helpers', () => { expect(res.detectedWrites).toEqual(['ducklake://main/out']) }) + // The canvas draws an inactive draft's stored outputs beside its materialize + // target, so a seeded default left on a materialize node shows a phantom asset. + it('keeps no seeded or stale output on a node with a materialize target', async () => { + vi.spyOn(ScriptService, 'getScriptByPath').mockRejectedValue(new Error('404')) + const { handle, drafts } = makeHandle() + await handle.proposeNode({ + path: 'f/x/mat', + language: 'duckdb' as any, + content: '-- pipeline\n-- materialize ducklake://main/abc\nSELECT 1', + outputKind: 'ducklake' as any + }) + expect(drafts().get('f/x/mat')?.outputAssets).toBeUndefined() + + await handle.proposeNode({ + path: 'f/x/later', + language: 'duckdb' as any, + content: '-- pipeline\nSELECT 1', + outputKind: 'ducklake' as any + }) + expect(drafts().get('f/x/later')?.outputAssets?.length).toBeGreaterThan(0) + await handle.editNode('f/x/later', '-- pipeline\n-- materialize ducklake://main/abc\nSELECT 1') + expect(drafts().get('f/x/later')?.outputAssets).toBeUndefined() + }) + it('editNode rejects a path outside the open folder', async () => { const { handle, drafts } = makeHandle() await expect(handle.editNode('f/other/foo', '-- pipeline')).rejects.toThrow(/open folder/) @@ -181,4 +208,24 @@ describe('pipeline AI direct-draft helpers', () => { expect(d?.script.tag).toBe('custom') expect(d?.script.content).toBe('-- pipeline\nSELECT 2') }) + + it("does not take a flow's schedule at the same path for the script's", async () => { + const flowSchedule = { + trigger_kind: 'schedule', + runnable_kind: 'flow', + runnable_path: 'f/x/ingest', + path: 'f/x/flow_schedule' + } + const content = '# pipeline\n# on schedule\ndef main():\n return 1' + const { handle } = makeHandle( + [['f/x/ingest', draft({ script: { content, language: 'python3' } as any })]], + [], + 'x', + [flowSchedule] + ) + expect(await handle.unconfiguredTriggers('f/x/ingest')).toEqual(['schedule']) + await expect( + handle.setNodeTrigger('f/x/ingest', 'schedule', { schedule: '0 0 6 * * *', timezone: 'UTC' }) + ).resolves.toMatchObject({ path: 'f/x/ingest_schedule' }) + }) }) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts index 5b22a098b1..f84848ee95 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts @@ -9,7 +9,12 @@ import { import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' import { assetUri, autoOutputAsset, type PipelineOutputKind } from './pipelineTemplates' import { parsePipelineAnnotations, scd2CurrentTargetPath } from './parsePipelineAnnotations' -import type { AssetGraphResponse } from './types' +import type { + AssetGraphResponse, + PipelineTriggerDraft, + PipelineTriggerDraftKind +} from './types' +import { defaultTriggerPath, isDraftableTriggerKind, triggerDraftKey } from './pipelineTriggerDrafts' import type { PipelineAIChatHelpers, PipelineContext, @@ -62,20 +67,26 @@ export type PipelineAiHelperDeps = { onForgetPath?: (path: string) => void /** Notify the caller a test run started so it can light up its run UI. */ onRunStarted?: (jobId: string, path: string) => void + /** Whether the folder has trigger drafts, which live beside the script drafts. */ + hasTriggerDrafts?: () => boolean + getTriggerDrafts: () => Map<string, PipelineTriggerDraft> + /** False when another trigger draft already holds the config's path. */ + setTriggerDraft: (d: PipelineTriggerDraft, previousKey?: string) => boolean } export function makePipelineScript( language: ScriptLang, scriptPath: string, content: string, - createdAt: string + createdAt: string, + summary = '' ): Script { // Cast through unknown: a local draft only needs path/language/content/schema; // the many readonly deployment fields on Script don't matter until createScript. return { hash: '', path: scriptPath, - summary: '', + summary, description: '', content, schema: emptySchema(), @@ -199,7 +210,7 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC path: r.path, language: draft?.script.language, unsaved: r.unsaved ?? false, - summary: draft?.script.summary || undefined, + summary: (draft ? draft.script.summary : r.summary) || undefined, writes: [...new Set(writes)], reads: [...new Set(reads)], triggers: [...new Set(triggers)] @@ -213,7 +224,36 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC } } + // Declared `on <kind>` bindings of a node that a trigger row or draft fills. + // Webhook and data-upload need none, so they never count as unconfigured. + function unconfiguredTriggers(path: string, content: string): PipelineTriggerDraftKind[] { + const declared = parsePipelineAnnotations(content) + .nativeTriggers.map((n) => n.kind) + .filter(isDraftableTriggerKind) + const drafted = new Set( + [...deps.getTriggerDrafts().values()] + .filter((d) => d.config.script_path === path) + .map((d) => d.kind) + ) + const deployed = new Set( + deps + .getResolvedGraph() + .triggers.filter( + (t) => + t.trigger_kind !== 'asset' && + t.runnable_kind === 'script' && + t.runnable_path === path && + t.path && + !t.missing + ) + .map((t) => t.trigger_kind) + ) + return [...new Set(declared)].filter((k) => !drafted.has(k) && !deployed.has(k)) + } + const helpers: PipelineAIChatHelpers = { + getFolder: folderName, + hasDrafts: () => deps.getDrafts().size > 0 || (deps.hasTriggerDrafts?.() ?? false), getPipelineContext: buildContext, getNodeBody: async (path) => { const draft = deps.getDrafts().get(path) @@ -227,7 +267,7 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC return undefined } }, - proposeNode: async ({ path, language, content, outputKind }) => { + proposeNode: async ({ path, language, content, outputKind, summary }) => { deps.ensureEditable?.() // build_pipeline_node creates a NEW node in the OPEN folder. Reject a path // outside the folder (it would silently stage into this folder's bundle) @@ -267,10 +307,13 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC } const inferred = await inferDraftAssets(language, content) // Fall back to a seeded output (from the declared output_kind) when the - // body doesn't yet write anything inferable. + // body doesn't yet write anything inferable. A `materialize` target is the + // output already (the canvas adds it from the annotation): a seed beside it + // would show as a second, default-named output. + const materialized = materializeWrites(content).length > 0 const seeded = inferred.writes[0] ?? - (outputKind + (outputKind && !materialized ? autoOutputAsset(outputKind as PipelineOutputKind, folderName(), language) : undefined) // Effective outputs = what actually becomes an output edge on the canvas: @@ -280,7 +323,7 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC const next = new Map(drafts) next.set(path, { localId: deps.newDraftLocalId(), - script: makePipelineScript(language, path, content, new Date().toISOString()), + script: makePipelineScript(language, path, content, new Date().toISOString(), summary), outputAssets, inputAssets: inferred.reads }) @@ -298,7 +341,7 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC ) } }, - editNode: async (path, content) => { + editNode: async (path, content, summary) => { deps.ensureEditable?.() assertInFolder(path) assertPipelineAnnotation(content) @@ -319,11 +362,19 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC baseScript = await ScriptService.getScriptByPath({ workspace, path }) } const inferred = await inferDraftAssets(baseScript.language, content) - const outputAssets = inferred.writes.length > 0 ? inferred.writes : existing?.outputAssets + // The stored outputs only stand in for a body that writes nothing inferable; + // with a `materialize` target the annotation is the output, so stale ones + // (a seeded default name, the target before a rename) must not linger. + const outputAssets = + inferred.writes.length > 0 + ? inferred.writes + : materializeWrites(content).length > 0 + ? undefined + : existing?.outputAssets const next = new Map(drafts) next.set(path, { localId: existing?.localId ?? deps.newDraftLocalId(), - script: { ...baseScript, content }, + script: { ...baseScript, content, ...(summary !== undefined ? { summary } : {}) }, outputAssets, inputAssets: inferred.reads }) @@ -332,12 +383,61 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC deps.onProposeNode?.(path) // Deployable lineage only (see proposeNode): body writes + materialize target. return { + summary: summary ?? baseScript.summary ?? '', detectedReads: inferred.reads.map(assetUri), detectedWrites: dedupeAssets([...inferred.writes, ...materializeWrites(content)]).map( assetUri ) } }, + unconfiguredTriggers: async (path) => { + const body = await helpers.getNodeBody(path) + return body ? unconfiguredTriggers(path, body.content) : [] + }, + setNodeTrigger: async (path, kind, config) => { + deps.ensureEditable?.() + assertInFolder(path) + const body = await helpers.getNodeBody(path) + if (!body) throw new Error(`No pipeline node at '${path}'. Build it first.`) + const prefix = body.language === 'python3' || body.language === 'bash' ? '#' : body.language === 'duckdb' || body.language === 'postgresql' ? '--' : '//' + if (!parsePipelineAnnotations(body.content).nativeTriggers.some((n) => n.kind === kind)) { + throw new Error( + `'${path}' does not declare \`${prefix} on ${kind}\`. Add that annotation line with edit_pipeline_node first, so the trigger is wired to the node.` + ) + } + const row = deps + .getResolvedGraph() + .triggers.find( + (t) => + t.trigger_kind === kind && + t.runnable_kind === 'script' && + t.runnable_path === path && + t.path && + !t.missing && + !(t as { draft?: boolean }).draft + ) + if (row && 'path' in row) { + throw new Error( + `'${path}' already has a deployed ${kind} trigger at '${row.path}'. Change that one instead of adding a second.` + ) + } + const existing = [...deps.getTriggerDrafts().values()].find( + (d) => d.kind === kind && d.config.script_path === path + ) + const triggerPath = + (typeof config.path === 'string' && config.path) || + existing?.config.path || + defaultTriggerPath(path, kind) + const saved = deps.setTriggerDraft( + { kind, config: { ...config, path: triggerPath, script_path: path, is_flow: false } }, + existing ? triggerDraftKey(kind, existing.config.path) : undefined + ) + if (!saved) { + throw new Error(`Another draft trigger already uses the path '${triggerPath}'. Pass a different config.path.`) + } + deps.onShowDrafts?.() + return { path: triggerPath, replaced: !!existing } + }, removeProposedNode: async (path) => { if (!deps.getDrafts().has(path)) { throw new Error(`No unsaved draft at '${path}' to discard.`) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.test.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.test.ts new file mode 100644 index 0000000000..8759f114b4 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + addTriggerDirective, + listenToAsset, + removeTriggerDirective, + stopListeningToAsset +} from './pipelineAnnotationEdits' + +const table = { kind: 'datatable' as const, path: 'main/orders' } +const lake = { kind: 'ducklake' as const, path: 'main/orders' } + +describe('pipeline annotation edits', () => { + it('adds `on` at the end of the header, in its comment style, leaving the body', () => { + const sql = '-- pipeline\n-- materialize ducklake://main/x\nSELECT 1; -- on nothing' + expect(listenToAsset(sql, table, false)).toBe( + '-- pipeline\n-- materialize ducklake://main/x\n-- on datatable://main/orders\nSELECT 1; -- on nothing' + ) + }) + + it('adds `on` next to the existing `on` lines', () => { + const sql = '-- pipeline\n-- on schedule\n-- materialize ducklake://main/x\nSELECT 1;' + expect(listenToAsset(sql, table, false)).toBe( + '-- pipeline\n-- on schedule\n-- on datatable://main/orders\n-- materialize ducklake://main/x\nSELECT 1;' + ) + }) + + it('removes an `on` line however it is spelled, options included', () => { + const py = '# pipeline\n# on datatable://main/orders debounce=60s\nprint(1)' + expect(stopListeningToAsset(py, table, true)).toBe('# pipeline\nprint(1)') + }) + + it('unmutes an auto-triggering read rather than adding `on`, and mutes it to stop', () => { + const muted = '// pipeline\n// mute ducklake://main/orders\nexport function main() {}' + expect(listenToAsset(muted, lake, true)).toBe('// pipeline\nexport function main() {}') + expect(stopListeningToAsset('// pipeline\nexport function main() {}', lake, true)).toBe( + '// pipeline\n// mute ducklake://main/orders\nexport function main() {}' + ) + }) + + it('adds `on` for an auto-triggering kind the script does not read', () => { + expect(listenToAsset('-- pipeline\nSELECT 1;', lake, false)).toBe( + '-- pipeline\n-- on ducklake://main/orders\nSELECT 1;' + ) + }) + + it('mutes only an auto-triggering asset the script reads', () => { + const sub = '-- pipeline\n-- on ducklake://main/orders\nSELECT 1;' + expect(stopListeningToAsset(sub, lake, false)).toBe('-- pipeline\nSELECT 1;') + }) + + it('adds `on` for an auto-triggering kind when every read is muted', () => { + const all = '// pipeline\n// mute all\nexport function main() {}' + expect(listenToAsset(all, lake, true)).toBe( + '// pipeline\n// mute all\n// on ducklake://main/orders\nexport function main() {}' + ) + }) + + it('adds a trigger kind once, and drops it', () => { + expect(addTriggerDirective('// pipeline\n// on webhook\nx()', 'webhook')).toBe( + '// pipeline\n// on webhook\nx()' + ) + expect(removeTriggerDirective('// pipeline\n// on kafka\nx()', 'kafka')).toBe( + '// pipeline\nx()' + ) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.ts new file mode 100644 index 0000000000..70ab899412 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.ts @@ -0,0 +1,149 @@ +import type { AssetKind } from '$lib/gen' +import { parsePipelineAnnotations, type PipelineAnnotations } from './parsePipelineAnnotations' + +// Edits to a pipeline script's leading annotation header (`// on …`, `// mute …`), +// the comment block `parsePipelineAnnotations` reads. Lines are kept in the +// script's own comment style, and nothing past the header is touched. + +/** Asset kind → the prefix of its `// on <ref>` spelling. Mirrors ASSET_KINDS in + * backend/parsers/windmill-parser/src/asset_parser.rs. */ +export const ASSET_REF_PREFIX: Record<AssetKind, string> = { + s3object: 's3://', + resource: '$res:', + ducklake: 'ducklake://', + datatable: 'datatable://', + volume: 'volume://', + dbt: 'dbt://' +} + +// A `// pipeline` script reading one of these kinds runs after each write to it +// unless muted, with no `// on` line (see AUTO_TRIGGER_KINDS in resolveGraph). +const AUTO_TRIGGER_KINDS: ReadonlySet<AssetKind> = new Set(['ducklake', 's3object']) + +export function assetRef(asset: { kind: AssetKind; path: string }): string { + return `${ASSET_REF_PREFIX[asset.kind]}${asset.path}` +} + +const PREFIXES = ['//', '--', '#'] + +type HeaderLine = { index: number; prefix: string; inner: string } + +/** The header: leading comment lines (blank lines allowed), up to the first code. */ +function header(lines: string[]): HeaderLine[] { + const out: HeaderLine[] = [] + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trimStart() + if (t === '') continue + const prefix = PREFIXES.find((p) => t.startsWith(p)) + if (!prefix) break + out.push({ index: i, prefix, inner: t.slice(prefix.length).trim() }) + } + return out +} + +// A header line matched the way the parser reads it, so `on <ref> debounce=60s`, +// `res://` vs `$res:` spellings and dbt path quoting all count as the same line. +type LineMatch = (parsed: PipelineAnnotations) => boolean +const onAsset = + (asset: { kind: AssetKind; path: string }): LineMatch => + (p) => + p.triggerAssets.some((a) => a.kind === asset.kind && a.path === asset.path) +const muteAsset = + (asset: { kind: AssetKind; path: string }): LineMatch => + (p) => + p.muteAssets.some((a) => a.kind === asset.kind && a.path === asset.path) +const muteAll: LineMatch = (p) => p.muteAll +const onTrigger = + (kind: string): LineMatch => + (p) => + p.nativeTriggers.some((t) => t.kind === kind) + +function matches(line: HeaderLine, match: LineMatch): boolean { + return match(parsePipelineAnnotations(`${line.prefix} ${line.inner}`)) +} + +function hasDirective(content: string, match: LineMatch): boolean { + return header(content.split('\n')).some((l) => matches(l, match)) +} + +/** Adds `<prefix> <directive>` in the header's comment style: after the last line + * of the same directive (`on`, `mute`), so they stay grouped, else at its end. */ +function addDirective(content: string, directive: string): string { + const lines = content.split('\n') + const h = header(lines) + const keyword = directive.split(/\s/)[0] + const sameKind = h.filter((l) => l.inner.split(/\s/)[0] === keyword) + const after = sameKind[sameKind.length - 1] ?? h[h.length - 1] + const line = `${after?.prefix ?? '//'} ${directive}` + lines.splice(after ? after.index + 1 : 0, 0, line) + return lines.join('\n') +} + +function removeDirective(content: string, match: LineMatch): string { + const lines = content.split('\n') + const drop = new Set( + header(lines) + .filter((l) => matches(l, match)) + .map((l) => l.index) + ) + return lines.filter((_, i) => !drop.has(i)).join('\n') +} + +/** The script runs after each write to the asset: `// on <ref>`, and any + * `// mute <ref>` lifted. An auto-triggering kind it `reads` needs no `// on` + * once unmuted, unless `// mute all` still silences it. */ +export function listenToAsset( + content: string, + asset: { kind: AssetKind; path: string }, + reads: boolean +): string { + const ref = assetRef(asset) + let out = removeDirective(content, muteAsset(asset)) + const auto = reads && AUTO_TRIGGER_KINDS.has(asset.kind) && !hasDirective(out, muteAll) + if (!auto && !hasDirective(out, onAsset(asset))) out = addDirective(out, `on ${ref}`) + return out +} + +/** The script no longer runs after writes to the asset: its `// on <ref>` goes, + * and an auto-triggering kind it `reads` is muted, since the read alone would + * still trigger it. */ +export function stopListeningToAsset( + content: string, + asset: { kind: AssetKind; path: string }, + reads: boolean +): string { + const ref = assetRef(asset) + let out = removeDirective(content, onAsset(asset)) + if (reads && AUTO_TRIGGER_KINDS.has(asset.kind) && !hasDirective(out, muteAsset(asset))) { + out = addDirective(out, `mute ${ref}`) + } + return out +} + +/** Adds a `// on <trigger kind>` line (`schedule`, `webhook`, …) unless it has one. */ +export function addTriggerDirective(content: string, kind: string): string { + return hasDirective(content, onTrigger(kind)) ? content : addDirective(content, `on ${kind}`) +} + +/** Drops a `// on <trigger kind>` line (`kafka`, `schedule`, …). */ +export function removeTriggerDirective(content: string, kind: string): string { + return removeDirective(content, onTrigger(kind)) +} + +/** What each edit says it changes, for the button that applies it. */ +export function listenChangeText( + asset: { kind: AssetKind; path: string }, + reads: boolean +): string { + return reads && AUTO_TRIGGER_KINDS.has(asset.kind) + ? `Removes any \`mute ${assetRef(asset)}\` line (adds \`on ${assetRef(asset)}\` if all reads are muted)` + : `Adds \`on ${assetRef(asset)}\`` +} +export function stopListeningChangeText( + asset: { kind: AssetKind; path: string }, + reads: boolean +): string { + return reads && AUTO_TRIGGER_KINDS.has(asset.kind) + ? `Removes \`on ${assetRef(asset)}\` and adds \`mute ${assetRef(asset)}\`` + : `Removes \`on ${assetRef(asset)}\`` +} diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineAssetPrefetch.svelte.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineAssetPrefetch.svelte.ts new file mode 100644 index 0000000000..004c6ab8c1 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineAssetPrefetch.svelte.ts @@ -0,0 +1,164 @@ +import { untrack } from 'svelte' +import { ScriptService, type AssetKind, type ScriptLang } from '$lib/gen' +import { inferAssets } from '$lib/infer' +import { + extractReads, + extractWrites, + type AssetWithAltAccessType +} from '$lib/components/assets/lib' +import { parsePipelineAnnotations } from './parsePipelineAnnotations' +import type { AssetGraphResponse, NativeTriggerKind } from './types' +import type { PipelineEditorState } from './pipelineEditorState.svelte' + +type AssetRef = { kind: AssetKind; path: string } + +/** + * Reads the body of every deployed script of a pipeline's graph and infers its + * asset reads/writes and `// on <native trigger>` annotations, so the canvas + * draws the edges of scripts the user never opened. The open script's live + * buffer wins over its cached body. + * + * The caches are only-add and keyed by path: the derived maps iterate the + * current graph's scripts, so a renamed or deleted script drops out when the + * graph refetches. A stale value under a live path does not, so a save through + * the editor must `forget` its path for the sweep to read it again. + */ +export function usePipelineAssetPrefetch(deps: { + getWorkspace: () => string | undefined + getGraph: () => AssetGraphResponse | undefined + editor: PipelineEditorState +}) { + const pe = deps.editor + let bodies = $state<Map<string, string>>(new Map()) + // Deployed summary + language from the same fetch, for node titles and icons. + let scriptMeta = $state<Map<string, { summary?: string; language: ScriptLang }>>(new Map()) + let inferredAssets = $state<Map<string, AssetWithAltAccessType[]>>(new Map()) + let prefetching = $state(false) + // Bumped by every sweep so one started for an older graph stops writing. + let gen = 0 + + $effect(() => { + const ws = deps.getWorkspace() + const g = deps.getGraph() + if (!ws || !g) return + const sweep = ++gen + const targets = untrack(() => + g.runnables + .filter((r) => r.usage_kind === 'script') + .map((r) => r.path) + .filter((p) => !pe.drafts.has(p) && !bodies.has(p)) + ) + if (targets.length === 0) return + let i = 0 + const POOL = 6 + const worker = async () => { + while (i < targets.length && sweep === gen) { + const path = targets[i++] + try { + const s = await ScriptService.getScriptByPath({ workspace: ws, path }) + if (sweep !== gen) return + const content = s.content ?? '' + const res = await inferAssets(s.language, content) + if (sweep !== gen) return + const inferred = (res?.assets ?? []) as AssetWithAltAccessType[] + untrack(() => { + if (!bodies.has(path)) bodies = new Map(bodies).set(path, content) + if (!scriptMeta.has(path)) { + scriptMeta = new Map(scriptMeta).set(path, { + summary: s.summary || undefined, + language: s.language + }) + } + if (!inferredAssets.has(path)) { + inferredAssets = new Map(inferredAssets).set(path, inferred) + } + }) + } catch { + // Skip — that node just falls back to base-graph edges. + } + } + } + prefetching = true + const pool = Array.from({ length: Math.min(POOL, targets.length) }, () => worker()) + void Promise.all(pool).then(() => { + if (sweep === gen) prefetching = false + }) + }) + + // Both edge maps in one pass over the graph's scripts; they only differ by + // extractWrites vs extractReads over the same source. + const edges = $derived.by(() => { + const writes = new Map<string, AssetRef[]>() + const reads = new Map<string, AssetRef[]>() + const g = deps.getGraph() + if (!g) return { writes, reads } + for (const r of g.runnables) { + if (r.usage_kind !== 'script') continue + const assets = + pe.liveBodyAssets.scriptPath === r.path + ? pe.liveBodyAssets.assets + : inferredAssets.get(r.path) + if (!assets) continue + const w = extractWrites(assets) + if (w.length > 0) writes.set(r.path, w) + const rd = extractReads(assets) + if (rd.length > 0) reads.set(r.path, rd) + } + return { writes, reads } + }) + + const annotatedNativeKinds = $derived.by(() => { + const out = new Map<string, Set<NativeTriggerKind>>() + const g = deps.getGraph() + if (!g) return out + const livePath = pe.liveAnnotations.scriptPath + for (const r of g.runnables) { + if (r.usage_kind !== 'script') continue + let kinds: Set<NativeTriggerKind> + if (r.path === livePath) { + kinds = new Set(pe.liveAnnotations.annotations.nativeTriggers.map((n) => n.kind)) + } else { + const body = bodies.get(r.path) + if (!body) continue + kinds = new Set(parsePipelineAnnotations(body).nativeTriggers.map((n) => n.kind)) + } + if (kinds.size > 0) out.set(r.path, kinds) + } + return out + }) + + return { + get bodies(): ReadonlyMap<string, string> { + return bodies + }, + get scriptMeta(): ReadonlyMap<string, { summary?: string; language: ScriptLang }> { + return scriptMeta + }, + get inferredWritesByPath() { + return edges.writes + }, + get inferredReadsByPath() { + return edges.reads + }, + get annotatedNativeKindsByPath() { + return annotatedNativeKinds + }, + get prefetching() { + return prefetching + }, + /** Drop cached paths so the next sweep re-reads them (after a save). */ + forget(...paths: string[]) { + const nextBodies = new Map(bodies) + const nextMeta = new Map(scriptMeta) + const nextInferred = new Map(inferredAssets) + for (const p of paths) { + nextBodies.delete(p) + nextMeta.delete(p) + nextInferred.delete(p) + } + bodies = nextBodies + scriptMeta = nextMeta + inferredAssets = nextInferred + } + } +} diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDataUploads.svelte.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDataUploads.svelte.ts new file mode 100644 index 0000000000..8205b5f903 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDataUploads.svelte.ts @@ -0,0 +1,87 @@ +import { untrack } from 'svelte' +import type { AssetGraphResponse } from './types' + +type Staged = { args: Record<string, any>; valid: boolean } + +// An S3Object-shaped value (the file picker writes `{ s3: '<path>' }`). +function isS3Object(v: any): boolean { + return !!v && typeof v === 'object' && !Array.isArray(v) && 's3' in v +} + +// Whether a staged value carries actual data. Covers the two shapes a +// data-upload entry takes: an S3Object (file picker → `{ s3: '<path>' }`) and +// a plain required input (e.g. a JSON array pasted into the run form). +function hasMeaningfulValue(v: any): boolean { + if (v == null) return false + if (typeof v === 'string') return v.length > 0 + if (Array.isArray(v)) return v.length > 0 + if (typeof v === 'object') + return isS3Object(v) ? typeof v.s3 === 'string' && v.s3.length > 0 : Object.keys(v).length > 0 + return true // numbers / booleans count as provided +} + +/** + * Staged run-form input for the graph's data-upload entry scripts, keyed by path. + * Lifted out of the (transient, per-selection) run form so the uploaded/entered + * data persists across selection changes: it drives each entry node's green + * "ready" state and seeds a run with that input. `valid` is the run form's + * full-schema validity (all required fields satisfied). + */ +export function usePipelineDataUploads(getGraph: () => AssetGraphResponse) { + let staged = $state<Record<string, Staged>>({}) + + // Scripts that are data-upload entry points (a `data_upload` trigger in the + // displayed graph). They can't auto-run — they need their data first. + const entryPaths = $derived( + new Set( + getGraph() + .triggers.filter((t) => t.trigger_kind === 'data_upload' && t.runnable_kind === 'script') + .map((t) => t.runnable_path) + ) + ) + + // Ready once the user actually provided its data, not just opened the form. Two + // guards: the form's own full-schema `valid` (every required field — including + // non-file ones — is satisfied), AND that any declared S3Object file field + // actually carries a file (the picker can leave an empty `{ s3: '' }` on a + // non-required file field, which `valid` alone wouldn't catch). + function isReady(path: string): boolean { + const s = staged[path] + if (!s || !s.valid) return false + const values = Object.values(s.args) + const s3s = values.filter(isS3Object) + if (s3s.length > 0) return s3s.every(hasMeaningfulValue) + return values.some(hasMeaningfulValue) + } + + const readyPaths = $derived(new Set([...entryPaths].filter((p) => isReady(p)))) + + return { + get entryPaths(): ReadonlySet<string> { + return entryPaths + }, + /** Entry scripts with their data provided — drives the green node treatment. */ + get readyPaths(): ReadonlySet<string> { + return readyPaths + }, + isReady, + argsFor: (path: string): Record<string, any> | undefined => staged[path]?.args, + // Persist the run form's args + validity, but only for data-upload entries — + // other run forms (partitioned producers) run their own way and must not be + // mistaken for a staged upload. Idempotent: the run form re-emits on every + // keystroke/validation pass, so bail when the value is unchanged — otherwise + // each emit would reassign `staged`, giving `readyPaths` a fresh Set identity + // that re-syncs the canvas and re-fires the form's emit effect + // (effect_update_depth_exceeded). + // Called from the run forms' own effects, so its reads are untracked: a form + // must not re-run because the store it just wrote to changed. + stage: (path: string, args: Record<string, any>, valid: boolean) => + untrack(() => { + if (!entryPaths.has(path)) return + const prev = staged[path] + if (prev && prev.valid === valid && JSON.stringify(prev.args) === JSON.stringify(args)) + return + staged = { ...staged, [path]: { args, valid } } + }) + } +} diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts new file mode 100644 index 0000000000..1e0c77aa70 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DraftService, ScriptService } from '$lib/gen' +import { planPipelineDelete } from './pipelineDelete' + +afterEach(() => vi.restoreAllMocks()) + +describe('planPipelineDelete', () => { + it("takes only the pipeline's own triggers and addresses draft-only nodes by storage key", async () => { + vi.spyOn(ScriptService, 'listRunnables').mockResolvedValue({ + items: [ + { type: 'script', path: 'f/sales/ingest', auto_kind: 'pipeline' }, + { type: 'script', path: 'f/sales/report' }, + { + type: 'script', + path: 'u/alice/draft_abc', + draft_only: true, + draft_path: 'f/sales/unpublished', + auto_kind: 'pipeline' + } + ] + } as any) + vi.spyOn(DraftService, 'getOwnDraft').mockResolvedValue(undefined as any) + const trigger = (runnable_kind: string, runnable_path: string, path: string) => ({ + trigger_kind: 'schedule', + runnable_kind, + runnable_path, + path + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + triggers: [ + trigger('script', 'f/sales/ingest', 'f/sales/ingest_schedule'), + trigger('script', 'f/sales/report', 'f/sales/report_schedule'), + trigger('flow', 'f/sales/ingest', 'f/sales/flow_schedule') + ] + }) + }) + ) + + const plan = await planPipelineDelete('w', 'sales') + + expect(plan.triggers).toEqual([{ kind: 'schedule', path: 'f/sales/ingest_schedule' }]) + expect(plan.scripts).toEqual([ + { path: 'f/sales/ingest', displayPath: 'f/sales/ingest', draftOnly: false }, + { path: 'u/alice/draft_abc', displayPath: 'f/sales/unpublished', draftOnly: true } + ]) + vi.unstubAllGlobals() + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts new file mode 100644 index 0000000000..62e8e2ea4a --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts @@ -0,0 +1,135 @@ +import { DraftService, OpenAPI, ScriptService, type RunnableItem } from '$lib/gen' +import { discardDraft } from '$lib/utils_draft_deploy' +import { PIPELINE_DRAFT_KIND, pipelineBundlePath, pipelineLocalMirrorKey } from '$lib/pipelinePaths' +import { deleteTriggerRow } from './pipelineTriggerDraftDeploy' +import type { AssetGraphResponse, NativeTriggerKind } from './types' + +export type PipelineDeleteResult = { removed: number; failures: string[] } + +/** The folder's pipeline-member scripts, deployed and draft-only alike: every one of + * them makes the home list show the pipeline, so one left behind brings it back. */ +async function listPipelineScripts(workspace: string, folder: string): Promise<RunnableItem[]> { + const out: RunnableItem[] = [] + let cursor: string | undefined + do { + const res = await ScriptService.listRunnables({ + workspace, + kinds: 'script', + pathStart: `f/${folder}/`, + includeDraftOnly: true, + perPage: 100, + cursor + }) + out.push(...res.items.filter((it) => it.auto_kind === 'pipeline')) + cursor = res.next_cursor ?? undefined + } while (cursor) + return out +} + +/** Trigger rows starting one of `scripts`. The folder graph also carries the + * triggers of the folder's ordinary scripts and flows, which are not the pipeline's. */ +async function listNativeTriggers(workspace: string, folder: string, scripts: ReadonlySet<string>) { + const res = await fetch( + `${OpenAPI.BASE ?? ''}/w/${workspace}/assets/graph?${new URLSearchParams({ folder })}`, + { credentials: 'include' } + ) + if (!res.ok) throw new Error(`GET /assets/graph → ${res.status}`) + const graph = (await res.json()) as AssetGraphResponse + return graph.triggers.flatMap((t) => + t.trigger_kind !== 'asset' && + t.runnable_kind === 'script' && + scripts.has(t.runnable_path) && + t.trigger_kind !== 'webhook' && + t.trigger_kind !== 'data_upload' && + t.path && + !t.unsaved + ? [{ kind: t.trigger_kind, path: t.path }] + : [] + ) +} + +export type PipelineDeletePlan = { + /** `path` is what the API addresses (a draft-only node's storage key); + * `displayPath` is the path the user typed for it. */ + scripts: { path: string; displayPath: string; draftOnly: boolean }[] + triggers: { kind: NativeTriggerKind; path: string }[] + /** Whether the caller has an unsaved pipeline draft for this folder. */ + hasDraft: boolean +} + +/** Everything `deletePipeline` would remove, so it can be listed before it is. */ +export async function planPipelineDelete( + workspace: string, + folder: string +): Promise<PipelineDeletePlan> { + const scripts = await listPipelineScripts(workspace, folder) + const deployed = new Set(scripts.filter((s) => !s.draft_only).map((s) => s.path)) + const [triggers, draft] = await Promise.all([ + listNativeTriggers(workspace, folder, deployed), + DraftService.getOwnDraft({ + workspace, + kind: PIPELINE_DRAFT_KIND, + path: pipelineBundlePath(folder) + }).catch(() => undefined) + ]) + return { + scripts: scripts.map((s) => ({ + path: s.path, + displayPath: (s.draft_only && s.draft_path) || s.path, + draftOnly: !!s.draft_only + })), + triggers, + hasDraft: !!draft + } +} + +/** Removes what `plan` lists: the trigger rows, the scripts (deleted when + * `hardDelete`, which the backend allows admins only, archived otherwise; a + * draft-only node is discarded) and the caller's pipeline draft. The data the + * nodes wrote (DuckLake tables, S3 objects) is left alone. Triggers go first: a + * script refused afterwards loses its trigger, but no trigger outlives the script + * it starts. */ +export async function deletePipeline( + workspace: string, + folder: string, + plan: PipelineDeletePlan, + hardDelete: boolean +): Promise<PipelineDeleteResult> { + const failures: string[] = [] + let removed = 0 + const fail = (what: string, e: any) => + failures.push(`${what}: ${e?.body ?? e?.message ?? String(e)}`) + + for (const t of plan.triggers) { + try { + if (await deleteTriggerRow(t.kind, t.path, workspace)) removed++ + } catch (e) { + fail(`${t.kind} trigger ${t.path}`, e) + } + } + for (const s of plan.scripts) { + try { + if (s.draftOnly) { + const r = await discardDraft('script', s.path, workspace, true, false, false) + if (!r.success) throw new Error(r.error) + } else if (hardDelete) { + await ScriptService.deleteScriptByPath({ workspace, path: s.path }) + } else { + await ScriptService.archiveScriptByPath({ workspace, path: s.path }) + } + removed++ + } catch (e) { + fail(s.displayPath, e) + } + } + if (plan.hasDraft) { + const bundle = await discardDraft(PIPELINE_DRAFT_KIND, pipelineBundlePath(folder), workspace, true) + if (!bundle.success) failures.push(`pipeline draft: ${bundle.error}`) + } + try { + localStorage.removeItem(pipelineLocalMirrorKey(folder)) + } catch { + // Storage unavailable: nothing was mirrored either. + } + return { removed, failures } +} diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.svelte.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.svelte.ts new file mode 100644 index 0000000000..508b705e11 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.svelte.ts @@ -0,0 +1,230 @@ +import { DraftService, ScriptService, type Script } from '$lib/gen' +import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' +import type { PipelineTriggerDraft } from './types' +import type { Schema } from '$lib/common' +import { emptySchema } from '$lib/utils' +import { inferArgs, inferAssets } from '$lib/infer' +import type { AssetWithAltAccessType } from '$lib/components/assets/lib' +import type { PipelineDraft } from './pipelineAiHelpers' +import type { PipelineEditorState } from './pipelineEditorState.svelte' +import { deployTriggerDraft } from './pipelineTriggerDraftDeploy' + +// Deploying a pipeline's drafts: the scripts first, then the trigger drafts whose +// script is now deployed. Shared by the pipeline page's "Save all" and the AI +// session's "Deploy pipeline", so both deploy exactly the same way. + +/** Deploy one pipeline script draft; resolves to the new version's hash. */ +export async function deployPipelineScript( + draft: PipelineDraft, + workspace: string +): Promise<string> { + const script = structuredClone($state.snapshot(draft.script) as Script) + script.schema = script.schema ?? emptySchema() + try { + const result = await inferArgs(script.language, script.content, script.schema as Schema) + ;(script as any).auto_kind = result?.auto_kind || undefined + script.has_preprocessor = result?.has_preprocessor ?? false + } catch { + // Inference failures don't block deploys (the same fallback the per-pane + // save uses): createScript is the real validation gate. + } + // Re-infer the lineage from the CURRENT body. `script.assets` is a snapshot + // that isn't refreshed on edit, so a renamed/removed output would otherwise be + // re-deployed as a phantom write edge. + let assets: AssetWithAltAccessType[] = [] + try { + const inferred = await inferAssets(script.language, script.content) + if (inferred?.status !== 'error') assets = (inferred?.assets ?? []) as AssetWithAltAccessType[] + } catch { + // An unparsable body deploys with no lineage rather than the stale snapshot. + } + return await ScriptService.createScript({ + workspace, + requestBody: { + ...script, + language: script.language, + description: script.description ?? '', + // A brand-new draft has an empty hash (the backend rejects an empty hex + // string); a draft of a deployed script chains off its hash, since + // createScript rejects a parentless deploy to an occupied path. + parent_hash: script.hash ? String(script.hash) : undefined, + is_template: false, + tag: script.tag, + kind: script.kind as Script['kind'] | undefined, + lock: undefined, + assets: assets as any + } + }) +} + +// Every field a deploy sends: a draft differing from the live script in any of +// them still has something to deploy. +const DEPLOYED_FIELDS = [ + 'content', + 'language', + 'summary', + 'description', + 'tag', + 'kind', + 'labels' +] as const + +/** Whether two versions of a script agree on everything a deploy sends. */ +function sameDeployedFields(a: Partial<Script>, b: Partial<Script>): boolean { + const norm = (v: unknown) => JSON.stringify(Array.isArray(v) && v.length === 0 ? null : (v ?? null)) + return DEPLOYED_FIELDS.every( + (f) => + norm(f === 'description' ? (a[f] ?? '') : a[f]) === + norm(f === 'description' ? (b[f] ?? '') : b[f]) + ) +} + +/** Whether a draft equals the live script at its path, i.e. has nothing to deploy. */ +export async function matchesDeployedScript( + path: string, + d: PipelineDraft, + workspace: string +): Promise<boolean> { + try { + const live = await ScriptService.getScriptByPath({ workspace, path }) + return sameDeployedFields(live, d.script) + } catch { + return false + } +} + +/** + * A folder's draft bundle against what is deployed, one entry per node (and the + * trigger drafts), for a diff viewer: the unit a session reviews and deploys. + */ +export async function loadPipelineDraftDiff( + workspace: string, + folder: string +): Promise<{ before: unknown; after: unknown }> { + const row = await DraftService.getOwnDraft({ + workspace, + kind: PIPELINE_DRAFT_KIND, + path: pipelineBundlePath(folder) + }).catch(() => undefined) + const bundle = (row?.value ?? {}) as { + drafts?: Array<[string, PipelineDraft]> + triggerDrafts?: PipelineTriggerDraft[] + } + const drafts = Array.isArray(bundle.drafts) ? bundle.drafts : [] + const before: Record<string, string> = {} + const after: Record<string, string> = {} + await Promise.all( + drafts.map(async ([path, d]) => { + after[path] = d.script?.content ?? '' + const live = await ScriptService.getScriptByPath({ workspace, path }).catch(() => undefined) + if (live) before[path] = live.content + }) + ) + const triggers = (bundle.triggerDrafts ?? []).map((t) => ({ + kind: t.kind, + path: t.config.path, + script_path: t.config.script_path + })) + return { + before: { nodes: before }, + after: { nodes: after, ...(triggers.length > 0 ? { new_triggers: triggers } : {}) } + } +} + +export type PipelineDeployOutcome = { + savedPaths: string[] + /** Keys of the trigger drafts that deployed. */ + savedTriggers: string[] + /** Failures by script or trigger path. */ + errors: Map<string, string> +} + +/** + * Deploy every draft of `editor` into `workspace`, dropping from it what deployed: + * failed drafts stay so they can be fixed and retried. The open pane's unsaved + * keystrokes are deployed with their draft. + */ +export async function deployPipelineDrafts( + editor: PipelineEditorState, + workspace: string +): Promise<PipelineDeployOutcome> { + // Closing the pane lands an open deployed script's edits as its draft, so they + // deploy too. It reopens on that script once the deploy is done, unless another + // one was opened meanwhile. + const reopen = + editor.liveEditPath != undefined || editor.activeDraftPath != undefined + ? await editor.closePane() + : undefined + const entries = [...editor.drafts.entries()] + const errors = new Map<string, string>() + const savedPaths: string[] = [] + const hashes = new Map<string, string>() + // Parallel: every createScript is independent, and one bad body must not block + // the others. + const results = await Promise.allSettled( + entries.map(([, d]) => deployPipelineScript(d, workspace)) + ) + for (let i = 0; i < results.length; i++) { + const r = results[i] + const [path, d] = entries[i] + if (r.status === 'fulfilled') { + savedPaths.push(path) + if (r.value) hashes.set(path, r.value) + continue + } + // A refused draft that equals the deployed script has nothing left to deploy. + // A duplicate refusal alone does not prove it: it is checked against every + // past version, not just the live one. + if (await matchesDeployedScript(path, d, workspace)) { + savedPaths.push(path) + continue + } + const msg = String((r.reason as any)?.body ?? (r.reason as any)?.message ?? r.reason) + errors.set( + path, + /same hash/i.test(msg) + ? 'This is the content of an earlier version, which cannot be deployed again as is. Change anything in it (a comment will do) to deploy it.' + : msg + ) + } + + // A trigger's `script_path` must already exist, so triggers whose script is + // still an undeployed draft stay drafts. + const ready = [...editor.triggerDrafts].filter( + ([, d]) => !editor.drafts.has(d.config.script_path) || savedPaths.includes(d.config.script_path) + ) + const triggerResults = await Promise.all(ready.map(([, d]) => deployTriggerDraft(d, workspace))) + const savedTriggers: string[] = [] + triggerResults.forEach((ok, i) => { + const [key, d] = ready[i] + if (ok) savedTriggers.push(key) + else errors.set(d.config.path, `Could not create the ${d.kind} trigger, see the notification`) + }) + for (const key of savedTriggers) editor.discardTriggerDraft(key) + + if (savedPaths.length > 0) { + // A draft edited again while it deployed (its node reopened from the canvas) + // keeps the newer edits, now on top of the version just deployed; only a draft + // still holding exactly what was sent is done. + const sent = new Map(entries.map(([p, d]) => [p, d.script])) + const next = new Map<string, PipelineDraft>() + for (const [k, d] of editor.drafts) { + if (!savedPaths.includes(k)) { + next.set(k, d) + continue + } + const open = editor.liveContent.scriptPath === k ? editor.liveContent.content : undefined + const now = open === undefined ? d.script : { ...d.script, content: open } + const was = sent.get(k) + if (was && sameDeployedFields(now, was)) continue + const hash = hashes.get(k) + next.set(k, hash ? { ...d, script: { ...d.script, hash } } : d) + } + editor.drafts = next + } + if (reopen && editor.activeDraftPath === undefined && editor.selection === undefined) { + if (editor.drafts.has(reopen)) editor.activeDraftPath = reopen + else editor.selection = { kind: 'runnable', runnable_kind: 'script', path: reopen } + } + return { savedPaths, savedTriggers, errors } +} diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.test.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.test.ts new file mode 100644 index 0000000000..53df48717a --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' + +const createScript = vi.fn() +vi.mock('$lib/gen', () => ({ + ScriptService: { createScript: (a: unknown) => createScript(a) }, + DraftService: {} +})) +vi.mock('$lib/infer', () => ({ + inferArgs: async () => undefined, + inferAssets: async () => ({ status: 'ok', assets: [] }) +})) +vi.mock('./pipelineTriggerDraftDeploy', () => ({ deployTriggerDraft: async () => true })) + +const { deployPipelineDrafts } = await import('./pipelineDeploy.svelte') +const { PipelineEditorState } = await import('./pipelineEditorState.svelte') + +const draft = (content: string) => ({ + localId: 'pe-1', + script: { path: 'f/x/n', language: 'duckdb', content, hash: 'aaaa' } as any +}) + +describe('deployPipelineDrafts', () => { + it('drops a draft once what it holds is deployed', async () => { + const pe = new PipelineEditorState() + pe.drafts = new Map([['f/x/n', draft('SELECT 2')]]) + createScript.mockResolvedValueOnce('bbbb') + await deployPipelineDrafts(pe, 'ws') + expect(pe.drafts.has('f/x/n')).toBe(false) + }) + + it('keeps edits made while the deploy was in flight, on top of the new version', async () => { + const pe = new PipelineEditorState() + pe.drafts = new Map([['f/x/n', draft('SELECT 2')]]) + createScript.mockImplementationOnce(async () => { + pe.drafts = new Map([['f/x/n', draft('SELECT 3')]]) + return 'bbbb' + }) + await deployPipelineDrafts(pe, 'ws') + expect(pe.drafts.get('f/x/n')?.script.content).toBe('SELECT 3') + expect(pe.drafts.get('f/x/n')?.script.hash).toBe('bbbb') + }) +}) + +describe('deployPipelineDrafts metadata', () => { + it('keeps a summary edited while the deploy was in flight', async () => { + const pe = new PipelineEditorState() + pe.drafts = new Map([['f/x/n', draft('SELECT 2')]]) + createScript.mockImplementationOnce(async () => { + const d = draft('SELECT 2') + pe.drafts = new Map([['f/x/n', { ...d, script: { ...d.script, summary: 'Renamed' } }]]) + return 'bbbb' + }) + await deployPipelineDrafts(pe, 'ws') + expect(pe.drafts.get('f/x/n')?.script.summary).toBe('Renamed') + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.svelte.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.svelte.ts index 086abc1bf8..6f9a109f5f 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.svelte.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.svelte.ts @@ -1,6 +1,8 @@ +import { tick } from 'svelte' import type { AssetKind, Script } from '$lib/gen' import type { AssetWithAltAccessType } from '$lib/components/assets/lib' -import type { AssetGraphSelection } from './types' +import type { AssetGraphSelection, PipelineTriggerDraft } from './types' +import { defaultTriggerPath, triggerDraftKey } from './pipelineTriggerDrafts' import { parsePipelineAnnotations, type ColumnLineage, @@ -26,11 +28,18 @@ type LiveBodyAssets = { assets: AssetWithAltAccessType[] columnLineage?: ColumnLineage[] } -type LiveContent = { scriptPath: string | undefined; content: string } +type LiveContent = { + scriptPath: string | undefined + content: string + /** The deployed script the buffer was opened from; unset for a draft. */ + base?: Script +} export class PipelineEditorState { /** In-flight drafts keyed by script path (manual + AI-staged). */ drafts = $state<Map<string, PipelineDraft>>(new Map()) + /** Undeployed triggers keyed by `triggerDraftKey`, persisted in the same bundle. */ + triggerDrafts = $state<Map<string, PipelineTriggerDraft>>(new Map()) /** Draft open in the details pane (mutually exclusive with `selection`). */ activeDraftPath = $state<string | undefined>(undefined) /** The persisted node/asset selected on the canvas. */ @@ -51,23 +60,22 @@ export class PipelineEditorState { * autosave hydrate when persistence is enabled. */ loadedFromDbDraft = $state(false) - /** Folder this state is scoped to. Used by the in-session preview (where one - * instance is reused across editor hide/show) to detect a retarget to a - * different folder and reset, so stale drafts don't bleed across folders. */ + /** Folder this state is scoped to. The route page compares it on a folder + * switch and resets, so one folder's drafts don't bleed into the next. */ folder = $state<string | undefined>(undefined) /** True once the DB draft bundle for the current folder has been hydrated - * into this instance. Gated per-instance (not per component mount) so the - * in-session preview hydrates ONCE when its runtime is fresh and then keeps - * the in-memory drafts across editor hide/show — re-reading the DB on every - * remount would race a not-yet-flushed autosave and drop a just-staged draft. - * Reset to false on a folder retarget so the new folder re-hydrates. */ + * into this instance. Gated per-instance (not per component mount) so an AI + * session's folder hydrates ONCE and then keeps its in-memory drafts across + * editor remounts — re-reading the DB on every remount would race a + * not-yet-flushed autosave and drop a just-staged draft. `reset` clears it + * so a new folder re-hydrates. */ hydratedFromDb = $state(false) - /** Clear all in-flight state. Used when the session preview retargets a - * different pipeline folder (a same-folder remount keeps the drafts). */ + /** Clear all in-flight state, for the route page switching folders. */ reset = () => { this.drafts = new Map() + this.triggerDrafts = new Map() this.activeDraftPath = undefined this.selection = undefined this.clearLiveOverlays() @@ -84,6 +92,18 @@ export class PipelineEditorState { return `pe-${this.#nextDraftLocalId}` } + // A deployed script with unsaved edits is autosaved as a draft before the pane + // promotes it, so both must give it the same id. + #promotedLocalIds = new Map<string, string>() + promotedDraftLocalId = (path: string): string => { + let id = this.#promotedLocalIds.get(path) + if (!id) { + id = this.newDraftLocalId() + this.#promotedLocalIds.set(path, id) + } + return id + } + handleAnnotationsChange = (scriptPath: string | undefined, annotations: PipelineAnnotations) => { this.liveAnnotations = { scriptPath, annotations } } @@ -94,8 +114,34 @@ export class PipelineEditorState { ) => { this.liveBodyAssets = { scriptPath, assets, columnLineage } } - handleContentChange = (scriptPath: string | undefined, content: string) => { - this.liveContent = { scriptPath, content } + handleContentChange = (scriptPath: string | undefined, content: string, base?: Script) => { + this.liveContent = { scriptPath, content, base } + } + + /** The open deployed script's unsaved edits, which live only in `liveContent` + * until its pane closes: the path, or undefined when there are none. */ + get liveEditPath(): string | undefined { + const { scriptPath, content, base } = this.liveContent + if (scriptPath == undefined || !base || this.drafts.has(scriptPath)) return undefined + return content !== (base.content ?? '') ? scriptPath : undefined + } + + /** Closes the open pane and waits for its edits to land as a draft, as closing + * it by hand does: what is deployed or discarded next then includes them, and + * no pane is left holding a stale copy. Returns the path the pane was on. */ + closePane = async (): Promise<string | undefined> => { + const path = + this.activeDraftPath ?? (this.selection?.kind === 'runnable' ? this.selection.path : undefined) + if (this.activeDraftPath === undefined && this.selection === undefined) return undefined + this.activeDraftPath = undefined + this.selection = undefined + // The pane unmounts on the next flush; its save-back commits a microtask later. + await tick() + await new Promise<void>((resolve) => queueMicrotask(resolve)) + // Its buffer now lives in the draft; left here, autosave would serialize it + // again once that draft is deployed or discarded. + this.clearLiveOverlays() + return path } clearLiveOverlays = () => { @@ -125,6 +171,72 @@ export class PipelineEditorState { next.delete(path) this.drafts = next this.forgetPath(path) + this.discardTriggerDraftsFor(path) + } + + /** Summary and labels of a script draft, edited from the pane header. */ + setDraftMeta = (path: string, meta: { summary: string; labels: string[] | undefined }) => { + const d = this.drafts.get(path) + if (!d) return + this.drafts = new Map(this.drafts).set(path, { + ...d, + script: { ...d.script, summary: meta.summary, labels: meta.labels } + }) + } + + /** Add or replace a trigger draft; `previousKey` is the entry being edited, which + * a rename drops. False, and nothing changes, when the path is another draft's: + * both would deploy to one trigger. */ + setTriggerDraft = (d: PipelineTriggerDraft, previousKey?: string): boolean => { + const key = triggerDraftKey(d.kind, d.config.path) + if (key !== previousKey && this.triggerDrafts.has(key)) return false + const next = new Map(this.triggerDrafts) + if (previousKey) next.delete(previousKey) + next.set(key, d) + this.triggerDrafts = next + return true + } + + discardTriggerDraft = (key: string) => { + if (!this.triggerDrafts.has(key)) return + const next = new Map(this.triggerDrafts) + next.delete(key) + this.triggerDrafts = next + } + + /** A trigger draft cannot outlive the script draft it was configured with: + * deploying it would target a path nothing is deployed at. */ + discardTriggerDraftsFor = (scriptPath: string) => { + const next = new Map( + [...this.triggerDrafts].filter(([, d]) => d.config.script_path !== scriptPath) + ) + if (next.size !== this.triggerDrafts.size) this.triggerDrafts = next + } + + /** Follow a script draft rename, keeping default `<script>_<kind>` names in step. */ + /** Returns the trigger path another draft already holds, and changes nothing, + * when a renamed default name would land on it. */ + retargetTriggerDrafts = (oldScriptPath: string, newScriptPath: string): string | undefined => { + const moved = new Map<string, PipelineTriggerDraft>() + const kept = new Map<string, PipelineTriggerDraft>() + for (const [k, d] of this.triggerDrafts) { + if (d.config.script_path !== oldScriptPath) { + kept.set(k, d) + continue + } + const path = + d.config.path === defaultTriggerPath(oldScriptPath, d.kind) + ? defaultTriggerPath(newScriptPath, d.kind) + : d.config.path + moved.set(triggerDraftKey(d.kind, path), { + ...d, + config: { ...d.config, path, script_path: newScriptPath } + }) + } + if (moved.size === 0) return undefined + for (const [k, d] of moved) if (kept.has(k)) return d.config.path + this.triggerDrafts = new Map([...kept, ...moved]) + return undefined } /** Commit body edits + inferred outputs back into the drafts Map on pane @@ -147,7 +259,7 @@ export class PipelineEditorState { if (!snapshot.script) return const next = new Map(this.drafts) next.set(p, { - localId: this.newDraftLocalId(), + localId: this.promotedDraftLocalId(p), script: snapshot.script, outputAssets: snapshot.writes.length > 0 ? snapshot.writes : undefined, inputAssets: snapshot.reads diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.test.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.test.ts index 2ec06bc8cc..09cec13a37 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.test.ts @@ -104,3 +104,72 @@ describe('PipelineEditorState.handleDraftPersist — read capture', () => { expect(pe.drafts.get('f/x/n')?.inputAssets).toBeUndefined() }) }) + +describe('PipelineEditorState trigger drafts', () => { + it('follow their script draft: renamed with it, discarded with it', () => { + const pe = new PipelineEditorState() + pe.drafts = new Map([['f/x/n', draft('SELECT 1')]]) + pe.setTriggerDraft({ kind: 'kafka', config: { path: 'f/x/n_kafka', script_path: 'f/x/n' } }) + pe.setTriggerDraft({ kind: 'schedule', config: { path: 'f/x/nightly', script_path: 'f/x/n' } }) + + pe.retargetTriggerDrafts('f/x/n', 'f/x/m') + // A default `<script>_<kind>` name follows the script; a chosen one stays. + expect([...pe.triggerDrafts.keys()].sort()).toEqual([ + 'kafka:f/x/m_kafka', + 'schedule:f/x/nightly' + ]) + expect([...pe.triggerDrafts.values()].every((d) => d.config.script_path === 'f/x/m')).toBe(true) + + pe.drafts = new Map([['f/x/m', draft('SELECT 1')]]) + pe.discardDraft('f/x/m') + expect(pe.triggerDrafts.size).toBe(0) + }) + + it('refuse a path another draft holds, but let a draft keep its own', () => { + const pe = new PipelineEditorState() + const a = { kind: 'kafka' as const, config: { path: 'f/x/k', script_path: 'f/x/a' } } + expect(pe.setTriggerDraft(a)).toBe(true) + expect( + pe.setTriggerDraft({ kind: 'kafka', config: { path: 'f/x/k', script_path: 'f/x/b' } }) + ).toBe(false) + expect(pe.triggerDrafts.get('kafka:f/x/k')?.config.script_path).toBe('f/x/a') + expect(pe.setTriggerDraft({ ...a, config: { ...a.config, topics: ['t'] } }, 'kafka:f/x/k')).toBe( + true + ) + }) + + it('refuse a script rename that moves a default name onto another draft', () => { + const pe = new PipelineEditorState() + pe.setTriggerDraft({ kind: 'kafka', config: { path: 'f/x/a_kafka', script_path: 'f/x/a' } }) + pe.setTriggerDraft({ kind: 'kafka', config: { path: 'f/x/b_kafka', script_path: 'f/x/c' } }) + expect(pe.retargetTriggerDrafts('f/x/a', 'f/x/b')).toBe('f/x/b_kafka') + expect([...pe.triggerDrafts.keys()].sort()).toEqual(['kafka:f/x/a_kafka', 'kafka:f/x/b_kafka']) + }) +}) + +describe('PipelineEditorState.liveEditPath', () => { + const base = { path: 'f/x/n', language: 'duckdb', content: 'SELECT 1' } as PipelineDraft['script'] + + it('counts an open deployed script with unsaved edits, until it has a draft', () => { + const pe = new PipelineEditorState() + pe.liveContent = { scriptPath: 'f/x/n', content: 'SELECT 1', base } + expect(pe.liveEditPath).toBeUndefined() + pe.liveContent = { scriptPath: 'f/x/n', content: 'SELECT 2', base } + expect(pe.liveEditPath).toBe('f/x/n') + pe.drafts = new Map([['f/x/n', draft('SELECT 2')]]) + expect(pe.liveEditPath).toBeUndefined() + }) +}) + +describe('PipelineEditorState.closePane', () => { + it("leaves no open buffer behind for autosave to bring back once the draft is gone", async () => { + const pe = new PipelineEditorState() + const base = { path: 'f/x/n', language: 'duckdb', content: 'SELECT 1' } as PipelineDraft['script'] + pe.selection = { kind: 'runnable', runnable_kind: 'script', path: 'f/x/n' } + pe.liveContent = { scriptPath: 'f/x/n', content: 'SELECT 2', base } + expect(await pe.closePane()).toBe('f/x/n') + expect(pe.selection).toBeUndefined() + expect(pe.liveEditPath).toBeUndefined() + expect(pe.liveContent.scriptPath).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts index 26d3e24d84..b50491620f 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts @@ -4,7 +4,7 @@ import type { ScriptLang } from '$lib/gen' // actually reach for first: duckdb for in-place SQL on parquet/s3 (the // default), bun for ergonomic data wrangling, python for ML/pandas, then // the sql dialects for warehouse-resident transforms. Everything else -// (bash/go/deno) sits below — still creatable, just not the default +// (bash/go) sits below — still creatable, just not the default // suggestion. export const PIPELINE_LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [ { label: 'DuckDB', lang: 'duckdb' }, @@ -16,6 +16,5 @@ export const PIPELINE_LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [ { label: 'MySQL', lang: 'mysql' }, { label: 'MS SQL', lang: 'mssql' }, { label: 'Bash', lang: 'bash' }, - { label: 'Go', lang: 'go' }, - { label: 'Deno', lang: 'deno' } + { label: 'Go', lang: 'go' } ] diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy.ts new file mode 100644 index 0000000000..3b37ac43a5 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy.ts @@ -0,0 +1,89 @@ +import { usedTriggerKinds } from '$lib/stores' +import { formatCron } from '$lib/utils' +import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' +import { saveEmailTriggerFromCfg } from '$lib/components/triggers/email/utils' +import { saveKafkaTriggerFromCfg } from '$lib/components/triggers/kafka/utils' +import { saveMqttTriggerFromCfg } from '$lib/components/triggers/mqtt/utils' +import { saveAmqpTriggerFromCfg } from '$lib/components/triggers/amqp/utils' +import { saveNatsTriggerFromCfg } from '$lib/components/triggers/nats/utils' +import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils' +import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils' +import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils' +import { + AmqpTriggerService, + EmailTriggerService, + GcpTriggerService, + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + ScheduleService, + SqsTriggerService +} from '$lib/gen' +import type { NativeTriggerKind, PipelineTriggerDraft } from './types' + +/** Creates the trigger through the same helper its editor saves with; that helper + * reports the outcome as a toast and resolves to whether it succeeded. */ +export function deployTriggerDraft(d: PipelineTriggerDraft, workspace: string): Promise<boolean> { + const cfg = d.config + switch (d.kind) { + case 'schedule': + return saveScheduleFromCfg({ ...cfg, schedule: formatCron(cfg.schedule) }, false, workspace) + case 'email': + return saveEmailTriggerFromCfg('', cfg, false, workspace, false, usedTriggerKinds) + case 'kafka': + return saveKafkaTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds) + case 'mqtt': + return saveMqttTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds) + case 'amqp': + return saveAmqpTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds) + case 'nats': + return saveNatsTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds) + case 'postgres': + return savePostgresTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds) + case 'sqs': + return saveSqsTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds) + case 'gcp': + return saveGcpTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds) + } +} + +/** Deletes a deployed trigger row. False for the kinds that have none (webhook, + * data upload). */ +export async function deleteTriggerRow( + kind: NativeTriggerKind, + path: string, + workspace: string +): Promise<boolean> { + switch (kind) { + case 'schedule': + await ScheduleService.deleteSchedule({ workspace, path }) + return true + case 'kafka': + await KafkaTriggerService.deleteKafkaTrigger({ workspace, path }) + return true + case 'mqtt': + await MqttTriggerService.deleteMqttTrigger({ workspace, path }) + return true + case 'amqp': + await AmqpTriggerService.deleteAmqpTrigger({ workspace, path }) + return true + case 'nats': + await NatsTriggerService.deleteNatsTrigger({ workspace, path }) + return true + case 'postgres': + await PostgresTriggerService.deletePostgresTrigger({ workspace, path }) + return true + case 'sqs': + await SqsTriggerService.deleteSqsTrigger({ workspace, path }) + return true + case 'gcp': + await GcpTriggerService.deleteGcpTrigger({ workspace, path }) + return true + case 'email': + await EmailTriggerService.deleteEmailTrigger({ workspace, path }) + return true + default: + return false + } +} diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDrafts.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDrafts.ts new file mode 100644 index 0000000000..7fd2998923 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDrafts.ts @@ -0,0 +1,26 @@ +import type { PipelineTriggerDraftKind } from './types' + +export const DRAFTABLE_TRIGGER_KINDS: readonly PipelineTriggerDraftKind[] = [ + 'schedule', + 'email', + 'kafka', + 'mqtt', + 'amqp', + 'nats', + 'postgres', + 'sqs', + 'gcp' +] + +export function isDraftableTriggerKind(kind: string): kind is PipelineTriggerDraftKind { + return (DRAFTABLE_TRIGGER_KINDS as readonly string[]).includes(kind) +} + +/** Drafts are keyed by kind too: two kinds may share a trigger path. */ +export function triggerDraftKey(kind: string, path: string): string { + return `${kind}:${path}` +} + +export function defaultTriggerPath(scriptPath: string, kind: PipelineTriggerDraftKind): string { + return `${scriptPath}_${kind}` +} diff --git a/frontend/src/lib/components/assets/AssetGraph/resolveGraph.test.ts b/frontend/src/lib/components/assets/AssetGraph/resolveGraph.test.ts index c57687d78b..6d38a8c119 100644 --- a/frontend/src/lib/components/assets/AssetGraph/resolveGraph.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/resolveGraph.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveGraph, computeMutedReadKeys, + pipelineNodeErrors, dbtAssociations, type ResolveGraphInput } from './resolveGraph' @@ -492,6 +493,49 @@ describe('resolveGraph', () => { expect(r.edges.some((e) => e.asset_path === 'main/out' && e.access_type === 'w')).toBe(true) }) + it('drops a persisted asset the open script renamed away, unless something still uses it', () => { + const write = (runnable_path: string, asset_path: string) => ({ + runnable_path, + runnable_kind: 'script' as const, + asset_kind: 's3object' as const, + asset_path, + access_type: 'w' as const + }) + const base = baseGraph({ + assets: [ + { kind: 's3object', path: '/old.json' }, + { kind: 's3object', path: '/shared.json' }, + { kind: 's3object', path: '/unreferenced.json' } + ], + runnables: [ + { path: 'f/x/prod', usage_kind: 'script' }, + { path: 'f/x/other', usage_kind: 'script' } + ], + edges: [write('f/x/prod', '/old.json'), write('f/x/prod', '/shared.json')], + triggers: [ + { + trigger_kind: 'asset', + asset_kind: 's3object', + asset_path: '/shared.json', + runnable_kind: 'script', + runnable_path: 'f/x/other' + } + ] + }) + const r = resolveGraph( + input({ + base, + liveAnnotations: { scriptPath: 'f/x/prod', annotations: ann() }, + liveBodyAssets: { scriptPath: 'f/x/prod', assets: [s3('/new.json', 'w')] } + }) + ) + expect(r.assets.map((a) => a.path).sort()).toEqual([ + '/new.json', + '/shared.json', + '/unreferenced.json' + ]) + }) + it('editing a saved scd2 producer keeps both the base and _current persisted write edges', () => { // Deploy persists a write to both `main/dim` and `main/dim_current`. // Opening the producer for editing must not judge the companion `_current` @@ -656,6 +700,31 @@ describe('resolveGraph', () => { }) }) + it('a trigger draft stands in for its draft script’s missing trigger of that kind', () => { + const drafts = new Map([['f/x/new', { script: { content: '// on schedule\n// on kafka' } }]]) + const r = resolveGraph( + input({ + drafts, + triggerDrafts: [ + { kind: 'schedule', config: { path: 'f/x/new_schedule', script_path: 'f/x/new' } } + ] + }) + ) + expect(r.triggers.filter((t) => t.trigger_kind === 'kafka')).toEqual([ + expect.objectContaining({ runnable_path: 'f/x/new', missing: true }) + ]) + expect(r.triggers.filter((t) => t.trigger_kind === 'schedule')).toEqual([ + { + trigger_kind: 'schedule', + path: 'f/x/new_schedule', + runnable_kind: 'script', + runnable_path: 'f/x/new', + unsaved: true, + draft: true + } + ]) + }) + it('macro edges: base passes through; live `// use` adds an unsaved via_use edge', () => { const base = baseGraph({ runnables: [ @@ -884,11 +953,11 @@ describe('live buffer overlays (open script)', () => { access_type: 'w', unsaved: true }) - // …the stale write edge is dropped, but the deployed dataset node stays. + // …the stale write edge is dropped, and with nothing else using it, so is the node. expect( r.edges.filter((e) => e.asset_path === 'main/orders' && e.runnable_path === 'f/x/prod') ).toEqual([]) - expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/orders' }) + expect(r.assets).not.toContainEqual({ kind: 'ducklake', path: 'main/orders' }) }) it('open saved script: unchanged materialize target dedups against the persisted write edge', () => { @@ -1083,9 +1152,7 @@ describe('dbtAssociations', () => { it('counts only what the project materializes as its transforms', () => { const { writesByOwner } = dbtAssociations(runnables, edges) // Hovering the project highlights the models it builds, not its inputs. - expect([...(writesByOwner.get('script:f/a/dbtproj') ?? [])]).toEqual([ - 'asset:dbt:wh/s/model_a' - ]) + expect([...(writesByOwner.get('script:f/a/dbtproj') ?? [])]).toEqual(['asset:dbt:wh/s/model_a']) }) it('ignores runnables that are not dbt', () => { @@ -1162,8 +1229,160 @@ describe('dbtAssociations with two writers of one relation', () => { writeBy('f/a/downstream') ]) expect(writesByOwner.get('script:f/a/upstream')).toEqual(new Set(['asset:dbt:wh/s/shared'])) - expect(writesByOwner.get('script:f/a/downstream')).toEqual( - new Set(['asset:dbt:wh/s/shared']) + expect(writesByOwner.get('script:f/a/downstream')).toEqual(new Set(['asset:dbt:wh/s/shared'])) + }) +}) + +describe('pipelineNodeErrors', () => { + const edge = (runnable_path: string, asset_path: string, access_type: 'r' | 'w') => ({ + runnable_path, + runnable_kind: 'script' as const, + asset_kind: 'ducklake' as const, + asset_path, + access_type + }) + const graph = baseGraph({ + assets: [ + { kind: 'ducklake', path: 'main/orphan' }, + { kind: 'ducklake', path: 'main/produced' }, + { kind: 'ducklake', path: 'main/external' } + ], + runnables: [ + { path: 'f/x/prod', usage_kind: 'script' }, + { path: 'f/x/cons', usage_kind: 'script' } + ], + edges: [ + edge('f/x/prod', 'main/produced', 'w'), + edge('f/x/cons', 'main/produced', 'r'), + edge('f/x/cons', 'main/external', 'r') + ] + }) + + it('flags an unused asset, and a `// on` with no producer and its script, but not a plain read', () => { + const onRefs = (...paths: string[]) => + paths.map((path) => ({ kind: 'ducklake' as const, path })) + const none = pipelineNodeErrors(graph, new Map()) + expect([...none.assets.keys()]).toEqual(['ducklake:main/orphan']) + expect(none.scripts.size).toBe(0) + const errors = pipelineNodeErrors( + graph, + new Map([['f/x/cons', onRefs('main/produced', 'main/external')]]) ) + // The subscriber is at fault, not the asset nothing writes. + expect([...errors.assets.keys()]).toEqual(['ducklake:main/orphan']) + expect([...errors.scripts.keys()]).toEqual(['f/x/cons']) + expect([...errors.unwritten.keys()]).toEqual(['ducklake:main/external']) + }) + + it('flags a pipeline script nothing starts, but not a macro library or a triggered one', () => { + const g = baseGraph({ + runnables: [ + { path: 'f/x/stray', usage_kind: 'script', in_pipeline: true }, + { path: 'f/x/lib', usage_kind: 'script', in_pipeline: true, macros: [{ name: 'm' }] }, + { path: 'f/x/timed', usage_kind: 'script', in_pipeline: true } + ] as any, + triggers: [ + { + trigger_kind: 'schedule', + path: 'f/x/timed_schedule', + runnable_kind: 'script', + runnable_path: 'f/x/timed' + } + ] + }) + expect([...pipelineNodeErrors(g, new Map()).untriggered.keys()]).toEqual(['f/x/stray']) + }) + + it('flags a script only started by reads of assets nothing writes', () => { + const g = baseGraph({ + assets: [{ kind: 'ducklake', path: 'main/raw' }], + runnables: [{ path: 'f/x/reader', usage_kind: 'script', in_pipeline: true }] as any, + edges: [ + { + runnable_path: 'f/x/reader', + runnable_kind: 'script', + asset_kind: 'ducklake', + asset_path: 'main/raw', + access_type: 'r' + } + ], + triggers: [ + { + trigger_kind: 'asset', + asset_kind: 'ducklake', + asset_path: 'main/raw', + runnable_kind: 'script', + runnable_path: 'f/x/reader' + } + ] + }) + expect(pipelineNodeErrors(g, new Map()).untriggered.get('f/x/reader')).toBe('unwritten-inputs') + expect( + pipelineNodeErrors(g, new Map(), new Set(['ducklake:main/raw'])).untriggered.size + ).toBe(0) + }) + + it('does not count a flow write as starting an asset-triggered script', () => { + const g = baseGraph({ + assets: [{ kind: 'ducklake', path: 'main/raw' }], + runnables: [ + { path: 'f/x/reader', usage_kind: 'script', in_pipeline: true }, + { path: 'f/x/loader', usage_kind: 'flow' } + ] as any, + edges: [ + { + runnable_path: 'f/x/loader', + runnable_kind: 'flow', + asset_kind: 'ducklake', + asset_path: 'main/raw', + access_type: 'w' + } + ], + triggers: [ + { + trigger_kind: 'asset', + asset_kind: 'ducklake', + asset_path: 'main/raw', + runnable_kind: 'script', + runnable_path: 'f/x/reader' + } + ] + } as any) + expect(pipelineNodeErrors(g, new Map()).untriggered.get('f/x/reader')).toBe('unwritten-inputs') + }) + + it('clears a subscription the workspace writes outside the pipeline', () => { + const errors = pipelineNodeErrors( + graph, + new Map([['f/x/cons', [{ kind: 'ducklake' as const, path: 'main/external' }]]]), + new Set(['ducklake:main/external']) + ) + expect(errors.scripts.size).toBe(0) + expect(errors.unwritten.size).toBe(0) + }) + + it('treats dbt models as used and written by their (hidden) project, but not dbt sources', () => { + const dbt = (path: string, resource_type: string) => ({ + kind: 'dbt' as const, + path, + dbt: { unique_id: `x.${path}`, resource_type } as any + }) + const errors = pipelineNodeErrors( + baseGraph({ + assets: [dbt('mart', 'model'), dbt('raw', 'source')], + runnables: [{ path: 'f/x/s', usage_kind: 'script' }] + }), + new Map([ + [ + 'f/x/s', + [ + { kind: 'dbt', path: 'mart' }, + { kind: 'dbt', path: 'raw' } + ] + ] + ]) + ) + expect([...errors.unwritten.keys()]).toEqual(['dbt:raw']) + expect([...errors.scripts.keys()]).toEqual(['f/x/s']) }) }) diff --git a/frontend/src/lib/components/assets/AssetGraph/resolveGraph.ts b/frontend/src/lib/components/assets/AssetGraph/resolveGraph.ts index 5c8aa697a4..b178de3387 100644 --- a/frontend/src/lib/components/assets/AssetGraph/resolveGraph.ts +++ b/frontend/src/lib/components/assets/AssetGraph/resolveGraph.ts @@ -1,4 +1,9 @@ -import type { AssetGraphMacroEdge, AssetGraphResponse, NativeTriggerKind } from './types' +import type { + AssetGraphMacroEdge, + AssetGraphResponse, + NativeTriggerKind, + PipelineTriggerDraft +} from './types' import { mergeColumnLineage, parsePipelineAnnotations, @@ -46,6 +51,8 @@ export type ResolveGraphInput = { * annotation has no matching trigger row in `base.triggers`. */ annotatedNativeKindsByPath: Map<string, Set<NativeTriggerKind>> + /** Undeployed triggers; each stands in for its script's missing trigger of that kind. */ + triggerDrafts?: Iterable<PipelineTriggerDraft> } /** Mutable bag the sequential passes accumulate the resolved graph into. */ @@ -264,16 +271,200 @@ export function resolveGraph(input: ResolveGraphInput): AssetGraphResponse { // there is nothing to show (also keeps the no-macros response shape // byte-identical to before the feature). const macroEdges = resolveMacroEdges(input) + const triggers = withTriggerDrafts( + [...baseTriggers, ...acc.extraTriggers], + input.triggerDrafts ?? [] + ) return { ...base, - assets: acc.assets, + assets: dropOrphanedAssets(acc.assets, base, acc.edges, triggers), runnables: acc.runnables, edges: acc.edges, - triggers: [...baseTriggers, ...acc.extraTriggers], + triggers, ...(macroEdges.length > 0 || base.macro_edges ? { macro_edges: macroEdges } : {}) } } +function withTriggerDrafts( + triggers: AssetGraphResponse['triggers'], + triggerDrafts: Iterable<PipelineTriggerDraft> +): AssetGraphResponse['triggers'] { + const drafts = [...triggerDrafts] + if (drafts.length === 0) return triggers + const drafted = new Set(drafts.map((d) => `${d.kind}:${d.config.script_path}`)) + return [ + ...triggers.filter( + (t) => + !( + t.trigger_kind !== 'asset' && + t.missing && + t.runnable_kind === 'script' && + drafted.has(`${t.trigger_kind}:${t.runnable_path}`) + ) + ), + ...drafts.map((d) => ({ + trigger_kind: d.kind, + path: d.config.path, + runnable_kind: 'script' as const, + runnable_path: d.config.script_path, + unsaved: true, + draft: true, + ...(d.kind === 'schedule' + ? { schedule: d.config.schedule as string, timezone: d.config.timezone as string } + : {}), + ...(d.config.summary ? { summary: d.config.summary as string } : {}) + })) + ] +} + +/** Why a node is red, and what the Fix actions act on. */ +export type PipelineNodeErrors = { + /** `kind:path` → reason. */ + assets: Map<string, string> + /** Script path → reason. */ + scripts: Map<string, string> + /** Subscribed assets nothing writes: `kind:path` → the asset and its subscribers. */ + unwritten: Map<string, { asset: { kind: AssetKind; path: string }; subscribers: string[] }> + /** Script path → the unwritten asset it runs on (its first, when several). */ + waitsOn: Map<string, { kind: AssetKind; path: string }> + /** Pipeline scripts nothing starts → why: no trigger at all, or only asset + * triggers on assets nothing writes (an auto-triggering read included). */ + untriggered: Map<string, 'no-trigger' | 'unwritten-inputs'> + /** Assets some asset trigger waits on that nothing in the pipeline writes: the + * ones to look up across the workspace for `writtenElsewhere`. */ + unwrittenTriggerAssets: Set<string> +} + +/** + * Nodes the pipeline cannot work with, with the reason. Assets (keyed + * `kind:path`): one nothing uses at all. Scripts (keyed by path): one + * subscribed (`// on <asset>`) to an asset nothing writes, in the pipeline or, + * per `writtenElsewhere`, by a script anywhere else in the workspace, since the subscription can never fire; the asset itself is + * fine, and is reported in `unwritten`. A body read of an external asset is fine and is not passed here: + * only the explicit `// on` annotations, per script path, count as subscriptions. + */ +export function pipelineNodeErrors( + graph: AssetGraphResponse, + explicitOnByPath: Map<string, Array<{ kind: AssetKind; path: string }>>, + writtenElsewhere: ReadonlySet<string> = new Set() +): PipelineNodeErrors { + const referenced = referencedAssetKeys(graph) + // Only a script's write fires an asset trigger: a flow's never does. + const written = new Set( + graph.edges + .filter( + (e) => e.runnable_kind === 'script' && (e.access_type === 'w' || e.access_type === 'rw') + ) + .map((e) => `${e.asset_kind}:${e.asset_path}`) + ) + for (const k of writtenElsewhere) written.add(k) + // The dbt project's own edges are hidden from the pipeline graph (see + // `hideDbtRunnables`), so its relations are judged by their provenance: the + // project uses every one, and materializes all but its sources. + for (const a of graph.assets) { + if (!a.dbt) continue + referenced.add(`${a.kind}:${a.path}`) + if (a.dbt.resource_type !== 'source') written.add(`${a.kind}:${a.path}`) + } + const errors = new Map<string, string>() + const scriptErrors = new Map<string, string>() + const unwritten: PipelineNodeErrors['unwritten'] = new Map() + const waitsOn: PipelineNodeErrors['waitsOn'] = new Map() + for (const a of graph.assets) { + const key = `${a.kind}:${a.path}` + if (!referenced.has(key)) errors.set(key, 'nothing in the pipeline uses it') + } + const scripts = new Set( + graph.runnables.filter((r) => r.usage_kind === 'script').map((r) => r.path) + ) + for (const [path, refs] of explicitOnByPath) { + if (!scripts.has(path)) continue + for (const ref of refs) { + const key = `${ref.kind}:${ref.path}` + if (written.has(key)) continue + const entry = unwritten.get(key) ?? { asset: ref, subscribers: [] } + if (!entry.subscribers.includes(path)) entry.subscribers.push(path) + unwritten.set(key, entry) + if (!scriptErrors.has(path)) { + scriptErrors.set(path, `runs on ${ref.path}, which nothing writes`) + waitsOn.set(path, ref) + } + } + } + // A pipeline script nothing starts runs only by hand. An asset trigger starts + // it only if something writes that asset, which covers the automatic trigger + // of a ducklake/s3 read too. dbt projects and macro libraries are never + // started on their own, so they are left out. + const assetKey = (t: { asset_kind: AssetKind; asset_path: string }) => + `${t.asset_kind}:${t.asset_path}` + const unwrittenTriggerAssets = new Set<string>() + const hasTrigger = new Set<string>() + const started = new Set<string>() + for (const t of graph.triggers) { + hasTrigger.add(t.runnable_path) + if (t.trigger_kind !== 'asset') started.add(t.runnable_path) + else if (written.has(assetKey(t))) started.add(t.runnable_path) + else unwrittenTriggerAssets.add(assetKey(t)) + } + for (const k of unwritten.keys()) unwrittenTriggerAssets.add(k) + const untriggered: PipelineNodeErrors['untriggered'] = new Map() + for (const r of graph.runnables) { + if (r.usage_kind !== 'script' || !r.in_pipeline || r.dbt || r.macros?.length) continue + if (started.has(r.path) || scriptErrors.has(r.path)) continue + const why = hasTrigger.has(r.path) ? 'unwritten-inputs' : 'no-trigger' + untriggered.set(r.path, why) + scriptErrors.set( + r.path, + why === 'no-trigger' + ? 'nothing starts it: it has no trigger' + : 'nothing starts it: nothing writes the assets it runs after' + ) + } + return { + assets: errors, + scripts: scriptErrors, + unwritten, + waitsOn, + untriggered, + unwrittenTriggerAssets + } +} + +function referencedAssetKeys( + graph: Pick<AssetGraphResponse, 'edges' | 'triggers' | 'test_edges' | 'dbt_edges'> +): Set<string> { + const keys = new Set<string>() + for (const e of graph.edges) keys.add(`${e.asset_kind}:${e.asset_path}`) + for (const t of graph.triggers) + if (t.trigger_kind === 'asset') keys.add(`${t.asset_kind}:${t.asset_path}`) + for (const e of graph.test_edges ?? []) keys.add(`${e.asset_kind}:${e.asset_path}`) + for (const e of graph.dbt_edges ?? []) { + keys.add(`dbt:${e.from_asset_path}`) + keys.add(`dbt:${e.to_asset_path}`) + } + return keys +} + +/** + * The backend only returns assets something references, so a deployed asset + * whose every edge and trigger the draft/live overlays stripped — an output the + * open script renamed — would linger as an edgeless node until the deploy + * lands. Assets that were already unreferenced in `base` are left alone. + */ +function dropOrphanedAssets( + assets: AssetGraphResponse['assets'], + base: AssetGraphResponse, + edges: AssetGraphResponse['edges'], + triggers: AssetGraphResponse['triggers'] +): AssetGraphResponse['assets'] { + const before = referencedAssetKeys(base) + const after = referencedAssetKeys({ ...base, edges, triggers }) + return assets.filter((a) => { + const key = `${a.kind}:${a.path}` + return !before.has(key) || after.has(key) + }) +} + /** * Macro-library → consumer edges: the deployed base edges, with `// use` * declarations of overlaid scripts (drafts + the open buffer) taking over @@ -848,7 +1039,6 @@ function overlayInferredLineage(acc: Accumulator, input: ResolveGraphInput) { overlayLineage(inferredReadsByPath, 'r') } - /** dbt project ↔ relation association, for a graph that does NOT draw it. * * A dbt script owns every relation of its project, so drawing one edge per diff --git a/frontend/src/lib/components/assets/AssetGraph/types.ts b/frontend/src/lib/components/assets/AssetGraph/types.ts index 023f71e218..4ee7aa0216 100644 --- a/frontend/src/lib/components/assets/AssetGraph/types.ts +++ b/frontend/src/lib/components/assets/AssetGraph/types.ts @@ -6,6 +6,12 @@ export type GraphUsageKind = 'script' | 'flow' export interface AssetGraphAssetNode { kind: AssetKind path: string + // Filled in client-side: the node is misconfigured (see `pipelineNodeErrors`). + error?: string + // Filled in client-side: no script in the workspace writes it, so a step + // downstream of it would never run on its writes (a flow's write fires no + // asset trigger). + never_written?: boolean // Fork workspaces only: 'fork' when this ducklake asset was materialized in // the fork itself, 'deferred' when reads fall back to the parent workspace's // current table via a defer view. Absent outside forks / for other kinds / @@ -70,6 +76,12 @@ export interface DbtDataTest { export interface AssetGraphRunnableNode { path: string usage_kind: GraphUsageKind + // Filled in client-side (not sent by the backend): the canvas titles the + // node with the summary over the path, and shows the language's icon. + summary?: string + language?: import('$lib/gen').ScriptLang + // Why the script is misconfigured, when it is (see `pipelineNodeErrors`). + error?: string // Script has `// pipeline` annotation. Drives the pipeline-member // visual state; unrelated to what the script actually writes (that's // parsed separately into lineage edges). @@ -170,6 +182,17 @@ export type NativeTriggerKind = | 'gcp' | 'data_upload' +export type PipelineTriggerDraftKind = Exclude<NativeTriggerKind, 'webhook' | 'data_upload'> + +/** A trigger configured in the pipeline editor but not deployed yet. It lives only + * in the pipeline's draft bundle, never as a workspace trigger draft, and is + * created when the pipeline is saved. `config` is what the kind's editor saves + * (for a schedule, a `NewSchedule`). */ +export type PipelineTriggerDraft = { + kind: PipelineTriggerDraftKind + config: Record<string, any> & { path: string; script_path: string } +} + export type AssetGraphTrigger = | { trigger_kind: 'asset' @@ -192,6 +215,13 @@ export type AssetGraphTrigger = // the canvas renders a red placeholder with a "Create trigger" // affordance instead of a fully-wired source. missing?: boolean + // A pipeline-local `PipelineTriggerDraft`: `path` is the trigger it + // will be deployed as, and no row exists yet. + draft?: boolean + // A schedule's cron, zone and summary, filled in client-side, for the node's labels. + schedule?: string + timezone?: string + summary?: string } // Macro-library → consumer edge: the consumer calls `macro_names` of diff --git a/frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts b/frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts new file mode 100644 index 0000000000..43090adb48 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts @@ -0,0 +1,32 @@ +import type { Item } from '$lib/utils' +import type { ScriptLang } from '$lib/gen' +import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte' +import { Code2 } from 'lucide-svelte' + +export type UpstreamScript = { path: string; summary?: string; language?: ScriptLang } + +/** A menu of the scripts that produce an asset, to pick the one to open. */ +export function upstreamScriptItems( + scripts: UpstreamScript[], + onPick: (path: string) => void +): Item[] { + return scripts.map((s) => ({ + displayName: s.summary || s.path, + description: s.summary ? s.path : undefined, + ...(s.language + ? { icon: LanguageIcon, iconProps: { lang: s.language, width: 14, height: 14 } } + : { icon: Code2 }), + action: () => onPick(s.path) + })) +} + +/** An upstream trigger the asset pane can open, and how. */ +export type UpstreamTriggerAction = { label: string; detail?: string; onOpen: () => void } + +export function upstreamTriggerItems(triggers: UpstreamTriggerAction[]): Item[] { + return triggers.map((t) => ({ + displayName: t.label, + description: t.detail, + action: () => t.onOpen() + })) +} diff --git a/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte index f1c123bb11..f3d84f872b 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte @@ -14,7 +14,7 @@ contentClasses="p-4 flex flex-col w-80 pt-8 bg-surface" > {#snippet trigger()} - <Button variant="accent" unifiedSize="md" nonCaptureEvent={true} startIcon={{ icon: Filter }}> + <Button variant="accent" unifiedSize="sm" nonCaptureEvent={true} startIcon={{ icon: Filter }}> Filters </Button> {/snippet} diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 9817b667f7..e2e502b30c 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -18,7 +18,10 @@ import type { ActionKind } from '$lib/common' import Button from '$lib/components/common/button/Button.svelte' - import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte' + import TimeframeSelect, { + auditTimeframes, + useSyncedTimeframe + } from '$lib/components/runs/TimeframeSelect.svelte' import { type AuditLog, ResourceService, @@ -29,7 +32,7 @@ } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' - import { ChevronDown, Download, Loader2, RefreshCcw } from 'lucide-svelte' + import { ChevronDown, Download } from 'lucide-svelte' import { onDestroy, onMount, untrack } from 'svelte' import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' @@ -52,7 +55,7 @@ actionKind?: ActionKind | 'all' scope?: undefined | 'all_workspaces' | 'instance' loading?: boolean - /** Lay the filters out in a row (page header) rather than stacked (popover). */ + /** Lay the filters out in a row (the page header band) rather than stacked (the popover). */ inline?: boolean onRefresh?: () => void } @@ -265,12 +268,35 @@ ;[username, perPage, before, after, operation, resource, actionKind, scope] untrack(() => updateQueryParams()) }) + + // The bounds stay the filters' own `after`/`before`, because the timeline's zoom writes them + // too; picking a preset resolves it to a concrete pair here rather than keeping a label that + // the chart could then contradict. + const _timeframe = useSyncedTimeframe( + auditTimeframes, + () => ({ minTs: after, maxTs: before }), + (v) => { + const { minTs, maxTs } = v.timeframe + ? (auditTimeframes.find((t) => t.label === v.timeframe)?.computeMinMax() ?? { + minTs: null, + maxTs: null + }) + : { minTs: v.minTs ?? null, maxTs: v.maxTs ?? null } + after = minTs ?? undefined + before = maxTs ?? undefined + } + ) </script> -<div class={inline ? 'flex flex-row gap-2' : 'flex flex-col gap-8 mt-4 xl:mt-0'}> +<!-- No labels above the controls: each one says what it filters while it is unset ("all usernames"), + and once it is set the value names the field by itself. That halves the row's height, which is + what lets it sit in the header band, and halves its width, which is what lets it stay there. + `inline` rather than a breakpoint: the caller measures whether the row fits the width the band + leaves it, which a media query cannot see — the sidebar, a side panel and the filters' own + values all move that width without moving the viewport's. --> +<div class={inline ? 'flex flex-row gap-2 items-center pr-2' : 'flex flex-col gap-2'}> {#if $workspaceStore == 'admins'} - <div class="flex gap-1 relative"> - <span class="text-xs absolute font-semibold text-emphasis -top-4">Scope</span> + <div class="flex min-w-0 shrink-0"> <ToggleButtonGroup selected={scope ?? 'admins'} on:selected={({ detail }) => { @@ -300,49 +326,31 @@ </ToggleButtonGroup> </div> {/if} - <div class="flex relative bg-surface-input"> - <span class="text-xs absolute font-semibold text-emphasis -top-4">From</span> - <input type="text" value={after ?? 'From'} disabled /> - <CalendarPicker - clearable - date={after} - placement="bottom-end" - label="From" - on:change={({ detail }) => { - after = new Date(detail).toISOString() - }} - on:clear={() => { - after = undefined - }} - /> - </div> - <div class="flex relative bg-surface-input"> - <span class="text-xs absolute font-semibold text-emphasis -top-4">To</span> - <input type="text" value={before ?? 'To'} disabled /> - <CalendarPicker - clearable - bind:date={before} - label="To" - placement="bottom-end" - on:change={({ detail }) => { - before = new Date(detail).toISOString() - }} - on:clear={() => { - before = undefined - }} - /> - </div> + <!-- The same control the runs page uses: presets and a two-calendar range in one popover, + in place of the two date fields this row used to carry. --> + <TimeframeSelect + unifiedSize="sm" + items={auditTimeframes} + bind:value={_timeframe.val} + {loading} + wrapperClasses="shrink-0" + onClick={() => { + loadUsers() + resources.refresh() + onRefresh?.() + }} + /> - <div class="flex relative"> - <span class="text-xs absolute font-semibold text-emphasis -top-4">Username</span> + <div class="w-40 min-w-0 shrink"> <Select class="w-full" + size="sm" bind:value={username} RightIcon={ChevronDown} items={usernames ? [ ...($userStore?.is_admin || $userStore?.is_super_admin - ? [{ value: 'all', label: 'all' }] + ? [{ value: 'all', label: 'all usernames' }] : []), ...usernames.map((e) => ({ value: e, @@ -353,41 +361,41 @@ : []} /> </div> - <div class="flex relative"> - <span class="text-xs absolute font-semibold text-emphasis -top-4">Resource</span> - + <div class="w-40 min-w-0 shrink"> <Select class="w-full" + size="sm" onCreateItem={(r) => (resources.value?.push(r), (resource = r))} createText="Press enter to use this value" bind:value={resource} - items={safeSelectItems(['all', ...(resources.value ?? [])])} + items={[{ value: 'all', label: 'all resources' }, ...safeSelectItems(resources.value ?? [])]} inputClass="dark:!bg-gray-700" RightIcon={ChevronDown} /> </div> - <div class="flex relative"> - <span class="text-xs absolute font-semibold text-emphasis -top-4">Operation</span> - + <div class="w-40 min-w-0 shrink"> <Select class="w-full" + size="sm" bind:value={operation} - items={['all', ...Object.values(operations)].map((r) => ({ value: r, label: r }))} + items={[ + { value: 'all', label: 'all operations' }, + ...Object.values(operations).map((r) => ({ value: r, label: r })) + ]} inputClass="dark:!bg-gray-700" RightIcon={ChevronDown} /> </div> - <div class="flex relative"> - <span class="text-xs absolute font-semibold text-emphasis -top-4">Action</span> - + <div class="w-36 min-w-0 shrink"> <Select class="w-full" + size="sm" bind:value={actionKind} RightIcon={ChevronDown} items={[ - { value: 'all', label: 'all' }, + { value: 'all', label: 'all actions' }, { value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }, @@ -396,7 +404,7 @@ /> </div> - <div class="flex flex-row"> + <div class="flex flex-row items-center gap-1"> <Button variant="subtle" on:click={() => { @@ -409,36 +417,17 @@ resource = 'all' scope = undefined }} - unifiedSize="md" + unifiedSize="sm" > Clear filters </Button> <Button variant="subtle" on:click={downloadAuditLogsAsJson} - unifiedSize="md" + unifiedSize="sm" title="Downloads currently displayed logs only (up to {perPage} entries)" startIcon={{ icon: Download }} iconOnly /> - <Button - variant="accent" - on:click={() => { - loadUsers() - resources.refresh() - onRefresh?.() - }} - unifiedSize="md" - wrapperClasses="ml-auto" - > - <div class="flex flex-row gap-1 items-center"> - {#if loading} - <Loader2 size={14} class="animate-spin" /> - {:else} - <RefreshCcw size={14} /> - {/if} - Refresh - </div> - </Button> </div> </div> diff --git a/frontend/src/lib/components/breadcrumbPath.test.ts b/frontend/src/lib/components/breadcrumbPath.test.ts new file mode 100644 index 0000000000..427b153800 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { splitItemPath } from './breadcrumbPath' + +describe('splitItemPath', () => { + it('walks the scope, each subfolder, then the item, each with its cumulative path', () => { + expect(splitItemPath('f/demo/sub/weather_report')).toEqual({ + dirs: [ + { name: 'f/demo', fullPath: 'f/demo' }, + { name: 'sub', fullPath: 'f/demo/sub' } + ], + leaf: { name: 'weather_report', fullPath: 'f/demo/sub/weather_report' } + }) + }) + + it('gives up before there is a scope', () => { + expect(splitItemPath('f/demo')).toBeUndefined() + }) + + it('keeps the folders while the name is still being typed', () => { + // The editor binds the path live, so every keystroke reaches the breadcrumb: the trail must + // not drop out and come back on the first character of the name. + expect(splitItemPath('f/demo/')).toEqual({ + dirs: [{ name: 'f/demo', fullPath: 'f/demo' }], + leaf: undefined + }) + }) +}) diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts new file mode 100644 index 0000000000..880d558360 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -0,0 +1,30 @@ +/** A directory level of an item's path, with the full path down to it. */ +export type PathDir = { name: string; fullPath: string } + +/** + * Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/<folder>` or + * `u/<user>`), then each subfolder, then the item itself. Each level carries the cumulative path, + * which is what the picker opens and highlights. + * + * Returns undefined before there is even a scope. The leaf is undefined while the path names a + * folder but no item yet (`f/demo/`) — the levels it does name still come back. + */ +export function splitItemPath( + path: string +): { dirs: PathDir[]; leaf: PathDir | undefined } | undefined { + const parts = path.split('/') + if (parts.length < 3) return undefined + const scope = parts.slice(0, 2).join('/') + const slug = parts.slice(2) + const dirs: PathDir[] = [{ name: scope, fullPath: scope }] + let acc = scope + for (let i = 0; i < slug.length - 1; i++) { + acc = `${acc}/${slug[i]}` + dirs.push({ name: slug[i], fullPath: acc }) + } + // A leaf with an empty name draws a blank segment. The editor binds the path live, so the + // breadcrumb sees every keystroke: the folders stay put while the name is being typed rather + // than the whole trail dropping out and coming back. + const name = slug[slug.length - 1] + return { dirs, leaf: name ? { name, fullPath: path } : undefined } +} diff --git a/frontend/src/lib/components/common/alert/Alert.svelte b/frontend/src/lib/components/common/alert/Alert.svelte index 7d22320867..b7639e1104 100644 --- a/frontend/src/lib/components/common/alert/Alert.svelte +++ b/frontend/src/lib/components/common/alert/Alert.svelte @@ -17,6 +17,8 @@ bgClass?: string | undefined bgStyle?: string | undefined iconClass?: string | undefined + /** Drop the type's icon; the text starts at the alert's edge. */ + hideIcon?: boolean iconStyle?: string | undefined titleClass?: string | undefined titleStyle?: string | undefined @@ -41,6 +43,7 @@ bgClass = undefined, bgStyle = undefined, iconClass = undefined, + hideIcon = false, iconStyle = undefined, titleClass = undefined, titleStyle = undefined, @@ -78,14 +81,16 @@ <div class="flex flex-row items-center"> <!-- shrink-0: the badge's min-content width is the 16px icon, so without it a narrow container squeezes the circle into an oval. --> - <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"> - <SvelteComponent - class={twMerge(classes[type].iconClass, iconClass)} - style={iconStyle} - size={16} - /> - </div> - <div class={twMerge('ml-1 w-full')}> + {#if !hideIcon} + <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"> + <SvelteComponent + class={twMerge(classes[type].iconClass, iconClass)} + style={iconStyle} + size={16} + /> + </div> + {/if} + <div class={twMerge(!hideIcon && 'ml-1', 'w-full')}> {#if hasTitleRow} <div class={twMerge('w-full flex flex-row items-center justify-between')}> <span diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index c57d41029c..09a56c427a 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -85,7 +85,8 @@ ['dark-indigo']: 'bg-indigo-700 text-indigo-100 dark:bg-indigo-800 dark:text-indigo-200', ['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200', ['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200', - transparent: 'bg-surface-accent-selected text-accent border-gray-400 dark:border-gray-500' + // Keeps the unselected chip's border, so selecting one does not change its height. + transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected' } const hovers: Partial<Record<BadgeColor, string>> = { diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index f684d79a9f..f3cfc78478 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -148,6 +148,13 @@ }: Props = $props() const activeTone = $derived(dropdownItems ? undefined : tone) + // `dropdownItems` may be a function, whose `.length` is its arity — 0 for the `() => [...]` + // every caller writes. Asking it for its items is the only way to know whether there are any, + // and a split button that answers `.length` instead renders no caret at all. + const hasDropdown = $derived( + !!dropdownItems && + (typeof dropdownItems === 'function' ? dropdownItems().length : dropdownItems.length) > 0 + ) function computeDropdowns(menuItems: MenuItem[] | (() => MenuItem[])): Item[] { const items = typeof menuItems === 'function' ? menuItems() : menuItems @@ -197,12 +204,7 @@ ] : ButtonType.VariantStyles[variant] // For default variant with dropdowns, remove border from button since it's on wrapper - if ( - variant === 'default' && - dropdownItems && - ((typeof dropdownItems === 'function' && dropdownItems().length > 0) || - dropdownItems.length > 0) - ) { + if (variant === 'default' && hasDropdown) { style = style.replace('border border-border-light', '') } return style @@ -278,7 +280,7 @@ getSpacingClass(variant, size, spacingSize, iconOnly, unifiedSize, extendedSize), unifiedSize ? ButtonType.UnifiedFontSizes[unifiedSize] : '', 'focus-visible:ring-2', - dropdownItems && dropdownItems.length > 0 ? 'rounded-l-md' : 'rounded-md', + hasDropdown ? 'rounded-l-md' : 'rounded-md', 'justify-center items-center text-center inline-flex gap-2', 'active:opacity-80 transition-[background-color,opacity] duration-150', disabled @@ -345,7 +347,7 @@ <div class={twMerge( - dropdownItems && dropdownItems.length > 0 ? dividerClass : '', + hasDropdown ? dividerClass : '', 'shrink-0', wrapperClasses, 'flex flex-row rounded-md', @@ -385,6 +387,7 @@ class={buttonClass} {id} {target} + {title} tabindex={disabled ? -1 : 0} {...rest} {style} @@ -484,7 +487,7 @@ {/if} {/if} - {#if dropdownItems && dropdownItems.length > 0} + {#if hasDropdown && dropdownItems} <Dropdown aiId={aiId ? `${aiId}-dropdown` : undefined} aiDescription={aiDescription ? `${aiDescription} dropdown` : undefined} diff --git a/frontend/src/lib/components/common/button/model.ts b/frontend/src/lib/components/common/button/model.ts index 8497ab1a87..c40e0b5b29 100644 --- a/frontend/src/lib/components/common/button/model.ts +++ b/frontend/src/lib/components/common/button/model.ts @@ -264,7 +264,9 @@ export namespace ButtonType { export const UnifiedSizingClasses: Record<ButtonType.UnifiedSize, string> = { '2xs': 'px-1', // Compact horizontal padding xs: 'px-2', - sm: 'px-2', // Regular horizontal padding + // One step of its own rather than sharing xs's: sm is the size a page's header buttons are, + // where a label needs room to read as a button rather than as text with a box around it. + sm: 'px-3', md: 'px-4', lg: 'px-6' } diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index 524817ba34..4b419536fc 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -29,6 +29,8 @@ * up confines it. Off by default: it moves the dialog out of its DOM position, so opt * in per call site rather than assuming every caller wants it. */ alwaysPortal?: boolean + /** For a notice with nothing to decide: the confirm button is the only one. */ + hideCancel?: boolean children?: Snippet onConfirmed?: () => void | Promise<void> onCanceled?: () => void @@ -47,6 +49,7 @@ trashbin = false, zIndexClass = 'z-[9999]', alwaysPortal = false, + hideCancel = false, children, onConfirmed, onCanceled @@ -228,15 +231,17 @@ {/if} <span class="min-w-20">{confirmationText} </span> </Button> - <Button - disabled={loading} - on:click={() => (dispatch('canceled'), onCanceled?.())} - variant="default" - size="sm" - shortCut={{ key: 'Esc', hide: !keyListen, withoutModifier: true }} - > - Cancel - </Button> + {#if !hideCancel} + <Button + disabled={loading} + on:click={() => (dispatch('canceled'), onCanceled?.())} + variant="default" + size="sm" + shortCut={{ key: 'Esc', hide: !keyListen, withoutModifier: true }} + > + Cancel + </Button> + {/if} </div> </div> </div> diff --git a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte index 20c29286b5..63e6b13994 100644 --- a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte +++ b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte @@ -15,7 +15,6 @@ import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { sendUserToast } from '$lib/toast' - import { base } from '$app/paths' import { goto } from '$app/navigation' import StaleDraftModal from './StaleDraftModal.svelte' import ConfirmationModal from './ConfirmationModal.svelte' @@ -163,7 +162,7 @@ } const target = destination ?? newPath sendUserToast(`This item was moved to ${target}. You are now editing it there.`) - await goto(`${base}/${seg}/${target}`) + await goto(`/${seg}/${target}`) }) }) </script> diff --git a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte index 49356d888e..b6ec78f336 100644 --- a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte @@ -7,7 +7,8 @@ */ import { DraftService, type UserDraftItemKind } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { Users, Pencil, GitCompareArrows, Wrench } from 'lucide-svelte' + import { Users, Pencil, GitCompareArrows, Wrench, Trash2 } from 'lucide-svelte' + import ConfirmationModal from './ConfirmationModal.svelte' import Modal2 from '$lib/components/common/modal/Modal2.svelte' import Button from '$lib/components/common/button/Button.svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -17,6 +18,10 @@ import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte' import { displayDate } from '$lib/utils' import { userStore } from '$lib/stores' + import { armRestartOnFirstInteraction } from '$lib/userDraftToast' + import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + import { goto } from '$app/navigation' export type OtherDraftUser = { username?: string | null; draft_saved_at?: string } @@ -54,8 +59,70 @@ let diffDrawer: DiffDrawer | undefined = $state(undefined) let migrateOpen = $state(false) - // Legacy (no-owner) drafts can only be resolved by workspace admins / superadmins. - const canMigrateLegacy = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin) + // One owner's draft, or every draft at the path. + let deleting = $state<OtherDraftUser | 'all' | undefined>(undefined) + let deleteBusy = $state(false) + + // Legacy (no-owner) drafts can only be resolved by workspace admins / superadmins, + // and only they can delete a draft that belongs to someone else. + const isAdmin = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin) + + async function deleteDraft(target: OtherDraftUser | 'all') { + const username = target === 'all' ? undefined : target.username + if (target !== 'all' && !username) return + deleteBusy = true + const query = { workspace, itemKind, path } + try { + // A path-wide delete takes our own draft too. Mute autosave and let any save + // already in flight land first: one committing after the delete would put the + // draft back for the reload below to read. + if (target === 'all') { + UserDraft.stopSync(itemKind, path, { workspace }) + await UserDraftDbSyncer.quiesce(query) + } + await DraftService.deleteDraftForUser({ + workspace, + kind: itemKind, + path, + username: username ?? undefined + }) + } catch (e) { + if (target === 'all') { + UserDraft.restartSync(itemKind, path, { workspace }) + // `quiesce` cancelled the timer of an edit still waiting to save. + void UserDraftDbSyncer.flush(query, { honorAutosaveToggle: true }) + } + sendUserToast(`Could not delete draft: ${e.body ?? e.message}`, true) + return + } finally { + deleteBusy = false + } + sendUserToast(username ? `Deleted ${username}'s draft` : `Deleted all drafts at ${path}`) + deleting = undefined + isOpen = false + + // Drop the editor's copy of our draft as well, or its next write would save + // it right back. Autosave re-arms on the next interaction. + if (target === 'all') { + UserDraft.remove(itemKind, path, { workspace }) + await UserDraftDbSyncer.flush(query) + armRestartOnFirstInteraction(workspace, itemKind, path) + } + // Nothing is left at a never-deployed path once its last draft is gone, so + // there is no item to reload the editor on. + const lastDraftGone = + target === 'all' || + (!hasOwnDraft && otherDraftsUsers.every((o) => ownerKey(o) === ownerKey(target))) + if (draftOnly && lastDraftGone) { + await goto('/') + return + } + try { + await onReload?.() + } catch (e) { + sendUserToast(`Could not reload the editor: ${e.body ?? e.message}`, true) + } + } function ownerLabel(owner: OtherDraftUser): string { return owner.username ?? 'Legacy draft' @@ -184,7 +251,7 @@ > Load </Button> - {#if !owner.username && canMigrateLegacy} + {#if !owner.username && isAdmin} <Button variant="default" size="xs" @@ -193,12 +260,35 @@ > Migrate </Button> + {:else if owner.username && isAdmin} + <Button + variant="default" + unifiedSize="sm" + destructive + startIcon={{ icon: Trash2 }} + disabled={busyFor !== null} + onclick={() => (deleting = owner)} + > + Delete + </Button> {/if} </li> {/each} </ul> - <div class="flex justify-end"> + <div class="flex justify-end gap-2"> + {#if isAdmin} + <Button + variant="default" + unifiedSize="md" + destructive + startIcon={{ icon: Trash2 }} + disabled={busyFor !== null} + onclick={() => (deleting = 'all')} + > + Delete all drafts + </Button> + {/if} <Button variant="default" size="sm" on:click={() => (isOpen = false)}>Close</Button> </div> </div> @@ -206,6 +296,29 @@ <DiffDrawer bind:this={diffDrawer} isFlow={itemKind === 'flow'} /> +<ConfirmationModal + open={deleting !== undefined} + title={deleting === 'all' ? 'Delete all drafts?' : `Delete ${deleting?.username}'s draft?`} + confirmationText="Delete" + loading={deleteBusy} + onConfirmed={() => { + if (deleting) void deleteDraft(deleting) + }} + onCanceled={() => (deleting = undefined)} +> + <span class="text-sm"> + {#if deleting === 'all'} + This permanently deletes every draft at + <span class="font-medium text-primary">{path}</span>, yours included. All unsaved work there + is lost and can't be recovered. + {:else} + This permanently deletes the draft {deleting?.username} has at + <span class="font-medium text-primary">{path}</span>. Their unsaved work there is lost and + can't be recovered. + {/if} + </span> +</ConfirmationModal> + <MigrateLegacyDraftModal bind:isOpen={migrateOpen} {workspace} diff --git a/frontend/src/lib/components/common/contextmenu/fromMenuItems.ts b/frontend/src/lib/components/common/contextmenu/fromMenuItems.ts new file mode 100644 index 0000000000..1ba6c0bac8 --- /dev/null +++ b/frontend/src/lib/components/common/contextmenu/fromMenuItems.ts @@ -0,0 +1,18 @@ +import type { Item } from '$lib/utils' +import type { ContextMenuItem } from './ContextMenu.svelte' + +/** A dropdown's items as the same entries of a right-click menu. */ +export function contextMenuItemsFromMenu(items: Item[]): ContextMenuItem[] { + return items.flatMap((item) => [ + ...(item.separatorTop ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] : []), + { + id: item.displayName, + label: item.displayName, + icon: item.icon, + disabled: item.disabled, + type: item.type, + shortcut: item.shortcut, + onClick: item.action as (() => void) | undefined + } + ]) +} diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 886c14042b..4fd472729f 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -32,6 +32,9 @@ children?: import('svelte').Snippet<[any]> onOpen?: () => void onClose?: () => void + /** Asked before a click away or Escape closes it; resolving false keeps it open, e.g. to + * confirm discarding unsaved changes. */ + confirmClose?: () => boolean | Promise<boolean> } let { @@ -42,9 +45,25 @@ minZIndex = 0, children, onOpen, - onClose + onClose, + confirmClose }: Props = $props() + // While the question is on screen, the keys and clicks are its own. + let confirming = false + async function requestClose() { + if (confirming) return + if (confirmClose) { + confirming = true + try { + if (!(await confirmClose())) return + } finally { + confirming = false + } + } + closeDrawer() + } + let offset = $state(untrack(() => initialOffset)) // Note: when a Modal with minZIndex is open, all disposables (including // already-open Drawers) are elevated. This is acceptable — relative @@ -115,7 +134,7 @@ const last = stack.val[stack.val.length - 1] if (last === id) { e.stopPropagation() - closeDrawer() + requestClose() } } @@ -125,7 +144,16 @@ if (open) { switch (event.key) { case 'Escape': + // The confirmation on screen answers this Escape itself, as its Cancel. + if (confirming) break if ((id == stack.val[stack.val.length - 1] || stack.val.length == 0) && !preventEscape) { + if (confirmClose) { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + requestClose() + break + } stack.val.pop() event.preventDefault() event.stopPropagation() diff --git a/frontend/src/lib/components/common/drawer/Drawer.svelte b/frontend/src/lib/components/common/drawer/Drawer.svelte index 76502735ca..45cf99c7be 100644 --- a/frontend/src/lib/components/common/drawer/Drawer.svelte +++ b/frontend/src/lib/components/common/drawer/Drawer.svelte @@ -28,6 +28,8 @@ positionClass?: string | undefined name?: string children?: import('svelte').Snippet<[any]> + /** Asked before a click away or Escape closes it; resolving false keeps it open. */ + confirmClose?: () => boolean | Promise<boolean> } let { @@ -43,7 +45,8 @@ class: clazz = '', positionClass = undefined, name = undefined, - children: children_render + children: children_render, + confirmClose }: Props = $props() if (open === undefined) { @@ -146,6 +149,7 @@ onOpen={() => dispatch('open')} onClose={() => dispatch('close')} {preventEscape} + {confirmClose} > {#snippet children({ handleClickAway, zIndex, isTop })} <aside diff --git a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte index c1a29fae70..7f9187da62 100644 --- a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte +++ b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { Download } from 'lucide-svelte' - import { base } from '$lib/base' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -26,7 +25,7 @@ s3object?.storage ? `&storage=${s3object.storage}` : '' }${appPath && s3object?.presigned ? `&${s3object?.presigned}` : ''}` ) - let href = $derived(`${base}/api${apiPath}`) + let href = $derived(`/api${apiPath}`) const sharedClass = `relative center-center flex w-full text-center font-normal text-primary text-sm border border-dashed border-gray-400 hover:border-blue-500 diff --git a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte index 617f5206dc..7635b900bb 100644 --- a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte +++ b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte @@ -28,6 +28,7 @@ import RIcon from '$lib/components/icons/RIcon.svelte' import ClaudeIcon from '$lib/components/icons/ClaudeIcon.svelte' import DbtIcon from '$lib/components/icons/DbtIcon.svelte' + import { Pi } from 'lucide-svelte' interface Props { lang: @@ -41,6 +42,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' width?: number height?: number scale?: number @@ -50,7 +52,10 @@ let { lang, width = 30, height = 30, scale = 1, size = undefined, ...rest }: Props = $props() - const languageLabel: Record<Script['language'] | 'bunnative' | 'claudesandbox', String> = { + const languageLabel: Record< + Script['language'] | 'bunnative' | 'claudesandbox' | 'pisandbox', + String + > = { python3: 'Python', deno: 'TypeScript', go: 'Go', @@ -76,12 +81,21 @@ ruby: 'Ruby', rlang: 'R', dbt: 'dbt', - claudesandbox: 'Claude Sandbox' + claudesandbox: 'Claude Sandbox', + pisandbox: 'Pi Sandbox' // for related places search: ADD_NEW_LANG } const langToComponent: Record< - SupportedLanguage | 'pgsql' | 'javascript' | 'fetch' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox', + | SupportedLanguage + | 'pgsql' + | 'javascript' + | 'fetch' + | 'docker' + | 'powershell' + | 'bunnative' + | 'claudesandbox' + | 'pisandbox', any > = { go: GoIcon, @@ -114,7 +128,8 @@ rlang: RIcon, duckdb: DuckDbIcon, dbt: DbtIcon, - claudesandbox: TypeScriptIcon + claudesandbox: TypeScriptIcon, + pisandbox: TypeScriptIcon // for related places search: ADD_NEW_LANG } @@ -166,4 +181,14 @@ /> </div> {/if} + {#if lang === 'pisandbox'} + <div + class="absolute -top-1.5 -right-1.5 bg-surface rounded-full flex items-center justify-center text-primary" + style={`width: ${width * scale * subIconScale}px; height: ${ + height * scale * subIconScale + }px;`} + > + <Pi size={width * scale * (subIconScale - 0.1)} /> + </div> + {/if} </div> diff --git a/frontend/src/lib/components/common/radioCard/RadioCard.svelte b/frontend/src/lib/components/common/radioCard/RadioCard.svelte index f9b7f675a4..6e7aa67ef8 100644 --- a/frontend/src/lib/components/common/radioCard/RadioCard.svelte +++ b/frontend/src/lib/components/common/radioCard/RadioCard.svelte @@ -10,6 +10,7 @@ onSelect, disabled = false, icon = undefined, + badge = undefined, showRadio = true, class: className = '' }: { @@ -25,6 +26,8 @@ disabled?: boolean /** Optional leading icon, rendered after the radio */ icon?: Snippet + /** Optional badge rendered right after the label */ + badge?: Snippet /** Draw the radio glyph. Turn it off where the card itself is the only * control and the border and tint already say which one is picked — the dot * is then a second, redundant answer to the same question. The group still @@ -51,8 +54,10 @@ // (FileExplorer, TriggersTable, RunnableRow, ArtifactVersionPicker). selected ? 'border-border-selected bg-surface-accent-selected' - : 'border-border-light hover:bg-surface-hover', - disabled && 'opacity-50 cursor-not-allowed', + : 'border-border-light enabled:hover:bg-surface-hover', + // `disabled:` variants, not plain utilities: the global `button:disabled` + // rule sets opacity and cursor and outranks a single class. + 'disabled:opacity-50 disabled:cursor-not-allowed', className )} > @@ -68,7 +73,10 @@ <div class="shrink-0 mt-0.5">{@render icon()}</div> {/if} <div class="flex-1 min-w-0"> - <div class="text-xs font-semibold text-emphasis">{label}</div> + <div class="flex items-center gap-2 text-xs font-semibold text-emphasis"> + {label} + {@render badge?.()} + </div> {#if describedBySnippet} <div class="text-xs font-normal text-secondary mt-0.5"> {@render (description as Snippet)()} diff --git a/frontend/src/lib/components/common/slack/SlackConnectionStatus.svelte b/frontend/src/lib/components/common/slack/SlackConnectionStatus.svelte index 8658c462ab..d79491b0fc 100644 --- a/frontend/src/lib/components/common/slack/SlackConnectionStatus.svelte +++ b/frontend/src/lib/components/common/slack/SlackConnectionStatus.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import { Badge, Button } from '$lib/components/common' import { Plug, RotateCw, Unplug } from 'lucide-svelte' - import { base } from '$lib/base' interface Props { isConnected: boolean | undefined @@ -16,8 +15,8 @@ // Connection URLs based on mode let connectUrl = $derived( mode === 'instance' - ? `${base}/api/oauth/connect_slack?instance=true` - : `${base}/workspace_settings?tab=slack` + ? '/api/oauth/connect_slack?instance=true' + : '/workspace_settings?tab=slack' ) </script> diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index ecde985be4..aca6ae7f9c 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import Dropdown from '$lib/components/DropdownV2.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' import DraftBadge from '$lib/components/DraftBadge.svelte' @@ -16,6 +15,7 @@ import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import Row from './Row.svelte' + import { selectMenuItems, type RowSelection } from './rowSelection' /** * A home-page row for a saved agent. The agent is an `ai_agent` resource, so the row opens the @@ -30,6 +30,7 @@ depth?: number menuOpen: boolean keyboardSelected?: boolean + rowSelection?: RowSelection } let { @@ -40,7 +41,8 @@ deleteConfirmedCallback = $bindable(), depth = 0, menuOpen = $bindable(), - keyboardSelected = false + keyboardSelected = false, + rowSelection = undefined }: Props = $props() const dispatch = createEventDispatcher() @@ -49,9 +51,9 @@ if (await deleteAgent($workspaceStore!, path)) dispatch('change') } - let editHref = $derived(`${base}/agents/edit/${agent.path}`) + let editHref = $derived(`/agents/edit/${agent.path}`) // A draft-only agent has nothing deployed for its page to run, so it opens in the editor. - let rowHref = $derived(agent.draft_only ? editHref : `${base}/agents/get/${agent.path}`) + let rowHref = $derived(agent.draft_only ? editHref : `/agents/get/${agent.path}`) </script> {#snippet chatBadge()} @@ -69,6 +71,7 @@ workspaceId={agent.workspace_id ?? $workspaceStore ?? ''} canFavorite={false} {depth} + {rowSelection} > {#snippet badges()} <SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} /> @@ -99,8 +102,9 @@ </span> {/if} <Dropdown - items={async () => - agentMenuItems({ + items={async () => [ + ...selectMenuItems(rowSelection), + ...agentMenuItems({ deployUiSettings: await getDeployUiSettings(), path: agent.path, canWrite: agent.canWrite, @@ -113,7 +117,8 @@ if (event?.shiftKey) remove(path) else deleteConfirmedCallback = () => remove(path) } - })} + }) + ]} on:open={() => { menuOpen = true }} diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index bd881a31b7..a318c1f412 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import Dropdown from '$lib/components/DropdownV2.svelte' import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -7,6 +6,7 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import { AppService, type ListableApp } from '$lib/gen' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { useOperatorBuilderApps } from '$lib/operatorWriteRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Button from '../button/Button.svelte' @@ -68,6 +68,7 @@ }: Props = $props() const dispatch = createEventDispatcher() + const operatorBuilderApps = useOperatorBuilderApps() let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined) let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined) @@ -106,8 +107,8 @@ <Row href={app.draft_only - ? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}` - : `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`} + ? `/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}` + : `/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`} kind="app" {keyboardSelected} {marked} @@ -159,24 +160,22 @@ {/snippet} {#snippet actions()} <span class="hidden md:inline-flex gap-x-1"> - {#if !$userStore?.operator} - {#if showEditButton && app.canWrite} - <div> - <Button - aiId={`edit-app-button-${app.summary?.length > 0 ? app.summary : app.path}`} - aiDescription={`Edits the app ${app.summary?.length > 0 ? app.summary : app.path}`} - variant="subtle" - wrapperClasses="w-20" - startIcon={{ icon: Pen }} - href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}" - > - Edit - </Button> - </div> - {/if} - {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)} - <EditInForkButton itemType={app.raw_app ? 'raw_app' : 'app'} path={app.path} /> - {/if} + {#if showEditButton && app.canWrite} + <div> + <Button + aiId={`edit-app-button-${app.summary?.length > 0 ? app.summary : app.path}`} + aiDescription={`Edits the app ${app.summary?.length > 0 ? app.summary : app.path}`} + variant="subtle" + wrapperClasses="w-20" + startIcon={{ icon: Pen }} + href="/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}" + > + Edit + </Button> + </div> + {/if} + {#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)} + <EditInForkButton itemType={app.raw_app ? 'raw_app' : 'app'} path={app.path} /> {/if} </span> <Dropdown @@ -224,7 +223,7 @@ // list endpoint only surfaces own/legacy draft-only rows), so // discarding it never requires write permission on the path. disabled: !showEditButton, - hide: $userStore?.operator + hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app) }, { displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON', @@ -240,7 +239,7 @@ { displayName: 'Duplicate/Fork', icon: GitFork, - href: `${base}/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`, + href: `/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`, disabled: !showEditButton, hide: $userStore?.operator }, @@ -287,7 +286,7 @@ displayName: 'Deployments', icon: History, action: () => appDeploymentHistory?.open(), - hide: $userStore?.operator + hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app) }, { displayName: 'Permissions', @@ -295,7 +294,7 @@ action: () => { shareModal.openDrawer && shareModal.openDrawer(path, 'app') }, - hide: $userStore?.operator + hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app) }, { displayName: 'Copy path', @@ -341,7 +340,7 @@ }, type: 'delete', disabled: !canEdit, - hide: $userStore?.operator + hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app) } ] }} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index ca8648baed..65690121ab 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { goto } from '$lib/navigation' - import { base } from '$lib/base' import Dropdown from '$lib/components/DropdownV2.svelte' import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' @@ -9,7 +8,7 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import { FlowService, type Flow } from '$lib/gen' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useEditRights } from '$lib/operatorWriteRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' @@ -43,7 +42,7 @@ import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' - const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() interface Props { flow: Flow & { @@ -125,7 +124,7 @@ let scheduleEditor: ScheduleEditor | undefined = $state(undefined) let flowHistory: FlowHistory | undefined = $state(undefined) - let hideForOperator = $derived($userStore?.operator && !$operatorBuilderFlows) + let hideForOperator = $derived(!editRights.roleCanAuthor('flow')) </script> {#if menuOpen} @@ -141,8 +140,8 @@ aiId={`flow-row-${flow.path}`} aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`} href={flow.draft_only - ? `${base}/flows/edit/${flow.path}` - : `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`} + ? `/flows/edit/${flow.path}` + : `/flows/get/${flow.path}?workspace=${$workspaceStore}`} kind="flow" workspaceId={flow.workspace_id ?? $workspaceStore ?? ''} {keyboardSelected} @@ -200,7 +199,7 @@ wrapperClasses="w-20" unifiedSize="md" startIcon={{ icon: Pen }} - href="{base}/flows/edit/{flow.path}" + href="/flows/edit/{flow.path}" aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`} > @@ -264,12 +263,12 @@ { displayName: 'View runs', icon: List, - href: `${base}/runs/${path}` + href: `/runs/${path}` }, { displayName: 'Duplicate/Fork', icon: GitFork, - href: `${base}/flows/add?template=${path}`, + href: `/flows/add?template=${path}`, disabled: !showEditButton, hide: hideForOperator }, @@ -287,7 +286,7 @@ { displayName: 'Audit logs', icon: Eye, - href: `${base}/audit_logs?resource=${path}`, + href: `/audit_logs?resource=${path}`, hide: $userStore?.operator }, { diff --git a/frontend/src/lib/components/common/table/PipelineRow.svelte b/frontend/src/lib/components/common/table/PipelineRow.svelte new file mode 100644 index 0000000000..334c8fb5fd --- /dev/null +++ b/frontend/src/lib/components/common/table/PipelineRow.svelte @@ -0,0 +1,154 @@ +<script lang="ts"> + import Dropdown from '$lib/components/DropdownV2.svelte' + import ConfirmationModal from '../confirmationModal/ConfirmationModal.svelte' + import { + deletePipeline, + planPipelineDelete, + type PipelineDeletePlan + } from '$lib/components/assets/AssetGraph/pipelineDelete' + import { userStore, workspaceStore } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { Loader2, Trash } from 'lucide-svelte' + import Row from './Row.svelte' + + interface Props { + folder: string + depth?: number + /** Shown instead of `f/<folder>`, e.g. under the folder's own tree node. */ + label?: string + onDeleted?: () => void + } + + let { folder, depth = 0, label, onDeleted }: Props = $props() + + let confirmOpen = $state(false) + let deleting = $state(false) + let plan = $state<PipelineDeletePlan | undefined>(undefined) + let planError = $state<string | undefined>(undefined) + // The backend deletes scripts for admins only; everyone else archives them. + let hardDelete = $derived(!!($userStore?.is_admin || $userStore?.is_super_admin)) + + async function openConfirm() { + const ws = $workspaceStore + if (!ws) return + plan = undefined + planError = undefined + confirmOpen = true + try { + plan = await planPipelineDelete(ws, folder) + } catch (e: any) { + planError = e?.body ?? e?.message ?? String(e) + } + } + + async function confirmDelete() { + const ws = $workspaceStore + if (!ws || !plan) return + deleting = true + try { + const { removed, failures } = await deletePipeline(ws, folder, plan, hardDelete) + // Loaded on demand: the home list should not pull in the session runtime. + const { forgetDeletedPipeline } = await import( + '$lib/components/sessions/sessionRuntime.svelte' + ) + forgetDeletedPipeline(ws, folder) + if (failures.length) { + sendUserToast(`Pipeline f/${folder} was only partly removed`, true, [], failures.join('\n')) + } else { + sendUserToast(`Removed pipeline f/${folder} (${removed} item${removed === 1 ? '' : 's'})`) + } + onDeleted?.() + } catch (e: any) { + sendUserToast(`Could not remove pipeline f/${folder}: ${e?.body ?? e?.message ?? e}`, true) + } finally { + deleting = false + confirmOpen = false + } + } +</script> + +<Row + kind="data_pipeline" + marked={undefined} + href="/pipeline/{encodeURIComponent(folder)}" + workspaceId={$workspaceStore ?? ''} + path={`f/${folder}`} + summary={label ?? `Pipeline · ${folder}`} + canFavorite={false} + {depth} +> + {#snippet actions()} + {#if !$userStore?.operator} + <Dropdown + items={[ + { + displayName: 'Delete', + icon: Trash, + type: 'delete', + action: () => void openConfirm() + } + ]} + /> + {/if} + {/snippet} +</Row> + +<ConfirmationModal + open={confirmOpen} + title="Delete pipeline f/{folder}" + confirmationText={hardDelete ? 'Delete' : 'Archive'} + loading={deleting} + confirmDisabled={!plan} + trashbin={hardDelete} + on:canceled={() => (confirmOpen = false)} + on:confirmed={confirmDelete} +> + <div class="flex flex-col gap-3 text-xs"> + {#if planError} + <span class="text-red-600 dark:text-red-400">Could not list the pipeline: {planError}</span> + {:else if !plan} + <span class="flex items-center gap-2 text-secondary"> + <Loader2 size={14} class="animate-spin" /> Listing what this removes… + </span> + {:else} + {#if plan.scripts.some((s) => !s.draftOnly)} + {@render group( + hardDelete ? 'Scripts deleted' : 'Scripts archived', + plan.scripts.filter((s) => !s.draftOnly).map((s) => s.displayPath) + )} + {/if} + {#if plan.scripts.some((s) => s.draftOnly)} + {@render group( + 'Draft scripts discarded', + plan.scripts.filter((s) => s.draftOnly).map((s) => s.displayPath) + )} + {/if} + {#if plan.triggers.length} + {@render group( + 'Triggers deleted', + plan.triggers.map((t) => `${t.path} (${t.kind})`) + )} + {/if} + {#if plan.hasDraft} + {@render group('Unsaved pipeline changes discarded', ['Your pipeline draft'])} + {/if} + {#if !plan.scripts.length && !plan.triggers.length && !plan.hasDraft} + <span class="text-secondary">Nothing left to remove in this pipeline.</span> + {/if} + <span class="text-secondary"> + The tables and files the pipeline wrote are kept, and so is the folder f/{folder}. + </span> + {/if} + </div> +</ConfirmationModal> + +{#snippet group(title: string, entries: string[])} + <div class="flex flex-col gap-1"> + <span class="font-semibold text-emphasis">{title} ({entries.length})</span> + <ul class="max-h-40 overflow-auto rounded-md border bg-surface-secondary px-3 py-1.5"> + {#each entries as entry (entry)} + <li class="font-mono text-2xs text-primary truncate">{entry}</li> + {/each} + </ul> + </div> +{/snippet} diff --git a/frontend/src/lib/components/common/table/RawAppRow.svelte b/frontend/src/lib/components/common/table/RawAppRow.svelte index ff31eb1aea..be1c9f0150 100644 --- a/frontend/src/lib/components/common/table/RawAppRow.svelte +++ b/frontend/src/lib/components/common/table/RawAppRow.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import Dropdown from '$lib/components/DropdownV2.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' @@ -33,7 +32,7 @@ </script> <Row - href="{base}/apps_raw/get/{app.path}" + href="/apps_raw/get/{app.path}" kind="raw_app" {keyboardSelected} {marked} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 938086a0e8..7ba327f343 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { goto } from '$lib/navigation' - import { base } from '$lib/base' import Dropdown from '$lib/components/DropdownV2.svelte' import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' @@ -157,8 +156,8 @@ aiId={`script-run-button-${script.path}`} aiDescription={`Button to access the form to run the script ${script.summary ?? script.path}`} href={script.draft_only || (script.auto_kind === 'lib' && script.kind !== 'preprocessor') - ? `${base}/scripts/edit/${script.path}` - : `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`} + ? `/scripts/edit/${script.path}` + : `/scripts/get/${script.hash}?workspace=${$workspaceStore}`} kind="script" {keyboardSelected} {marked} @@ -261,7 +260,7 @@ wrapperClasses="w-20" unifiedSize="md" startIcon={{ icon: Pen }} - href="{base}/scripts/edit/{script.path}" + href="/scripts/edit/{script.path}" > Edit </Button> @@ -351,7 +350,7 @@ { displayName: 'Duplicate/Fork', icon: GitFork, - href: `${base}/scripts/add?template=${script.path}`, + href: `/scripts/add?template=${script.path}`, disabled: !showEditButton, hide: $userStore?.operator }, @@ -391,7 +390,7 @@ { displayName: 'View runs', icon: List, - href: `${base}/runs/${script.path}` + href: `/runs/${script.path}` }, { displayName: 'Versions', @@ -403,7 +402,7 @@ { displayName: 'Audit logs', icon: Eye, - href: `${base}/audit_logs?resource=${script.path}`, + href: `/audit_logs?resource=${script.path}`, hide: $userStore?.operator }, { diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte index f3870b3bb6..ae2c4c4bae 100644 --- a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte +++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte @@ -50,6 +50,9 @@ /** Render after the right-pinned tabs, outside the scroll area so it stays * pinned (e.g. a "Split with Preview" toggle). */ trailing?: import('svelte').Snippet + /** Tab height. `sm` (the default) is the 24px pill the panel strips use; `md` is 28px, for a + * strip that fills a 44px bar, where the smaller pill leaves too much air around it. */ + size?: 'sm' | 'md' /** Render inside each tab (after the label; receives the tab + isActive). Clicks * bubble to the tab unless the accessory stops them. The tab is position:relative * so an `absolute inset-0 pointer-events-none` child can anchor a whole-tab @@ -65,6 +68,7 @@ onReorder, onActiveClick, class: c = '', + size = 'sm', afterTabs, trailing, tabAccessory @@ -180,7 +184,8 @@ function tabClasses(isActive: boolean) { return twMerge( - 'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + 'group relative inline-flex items-center gap-1.5 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + size === 'md' ? 'h-7 px-3 gap-2' : 'h-6 px-2.5', isActive ? 'bg-surface-tertiary text-emphasis' : 'bg-transparent text-hint hover:text-secondary' diff --git a/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte b/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte index ce368e93b9..64b9269f3d 100644 --- a/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte +++ b/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import { Badge, Button } from '$lib/components/common' import { Plug, RotateCw, Unplug } from 'lucide-svelte' - import { base } from '$lib/base' interface Props { isConnected: boolean | undefined @@ -15,7 +14,7 @@ // Connection URLs based on mode let connectUrl = $derived( - mode === 'instance' ? `${base}/#superadmin-settings` : `${base}/workspace_settings?tab=teams` + mode === 'instance' ? `/#superadmin-settings` : `/workspace_settings?tab=teams` ) </script> diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte index 1300dc4322..86f04dfa9f 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte @@ -70,7 +70,14 @@ <div class="flex items-center"> <!-- The tooltip fixes a bug where the other tooltips won't disappear --> <Tooltip disablePopup> - <DropdownV2 {btnText} enableFlyTransition {items} size={small ? 'sm' : 'md'} /> + <!-- fixedHeight pins the wrapper at h-8, which overhangs a small group's 28px row. --> + <DropdownV2 + {btnText} + enableFlyTransition + {items} + size={small ? 'sm' : 'md'} + fixedHeight={!small} + /> </Tooltip> </div> </div> diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index b4526f1153..48a46ad182 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -8,7 +8,6 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { copilotInfo } from '$lib/aiStore' import { logFeatureUsage } from '$lib/utils/featureUsage' - import { pageHref } from '$lib/components/sessions/previewPaths' interface Props { /** Unset for a viewer who cannot edit the item: the card then offers no way to the editor. */ @@ -45,7 +44,7 @@ const sessionSource = $derived( path ? { - page: () => pageHref(`/${runnableType}s/get/${path}`), + page: () => `/${runnableType}s/get/${path}`, workspaceId: $operatingWorkspace ?? undefined, beforeOpen: logAsked } diff --git a/frontend/src/lib/components/copilot/CronGen.svelte b/frontend/src/lib/components/copilot/CronGen.svelte index 94715a0f18..fb5806234c 100644 --- a/frontend/src/lib/components/copilot/CronGen.svelte +++ b/frontend/src/lib/components/copilot/CronGen.svelte @@ -5,7 +5,6 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import { sendUserToast } from '$lib/toast' - import { base } from '$lib/base' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' import { copilotInfo } from '$lib/aiStore' import { untrack } from 'svelte' @@ -132,7 +131,7 @@ <div class="block text-primary"> <p class="text-sm" >Enable Windmill AI in the <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" >workspace settings <ExternalLink size={16} /></a diff --git a/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte b/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte index 29ce045042..45df865c11 100644 --- a/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte +++ b/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte @@ -3,7 +3,6 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import Button from '../common/button/Button.svelte' import { ExternalLink, WandSparkles } from 'lucide-svelte' - import { base } from '$lib/base' import { twMerge } from 'tailwind-merge' import { aiChatManager, AIMode } from './chat/AIChatManager.svelte' import { chatState } from './chat/sharedChatState.svelte' @@ -97,7 +96,7 @@ <div class="p-4"> <p class="text-sm"> Enable Windmill AI in the <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" > diff --git a/frontend/src/lib/components/copilot/RegexGen.svelte b/frontend/src/lib/components/copilot/RegexGen.svelte index f399726988..32fdc4a5f1 100644 --- a/frontend/src/lib/components/copilot/RegexGen.svelte +++ b/frontend/src/lib/components/copilot/RegexGen.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import { run } from 'svelte/legacy' - import { base } from '$lib/base' import { Button } from '../common' import { getNonStreamingMetadataCompletion } from './lib' @@ -172,7 +171,7 @@ {:else} <p class="text-sm"> Enable Windmill AI in the <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" > diff --git a/frontend/src/lib/components/copilot/ResourceGen.svelte b/frontend/src/lib/components/copilot/ResourceGen.svelte index 2fc3cfa646..0d21635e77 100644 --- a/frontend/src/lib/components/copilot/ResourceGen.svelte +++ b/frontend/src/lib/components/copilot/ResourceGen.svelte @@ -4,7 +4,6 @@ import { getNonStreamingMetadataCompletion } from './lib' import Popover from '$lib/components/meltComponents/Popover.svelte' import { sendUserToast } from '$lib/toast' - import { base } from '$lib/base' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' import { copilotInfo } from '$lib/aiStore' import type { Schema } from '$lib/common' @@ -175,7 +174,7 @@ <div class="block text-primary"> <p class="text-sm" >Enable Windmill AI in the <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" >workspace settings <ExternalLink size={16} /></a diff --git a/frontend/src/lib/components/copilot/ScriptFix.svelte b/frontend/src/lib/components/copilot/ScriptFix.svelte index 78c4804cc8..bc6fca10d5 100644 --- a/frontend/src/lib/components/copilot/ScriptFix.svelte +++ b/frontend/src/lib/components/copilot/ScriptFix.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { Button } from '../common' import { SUPPORTED_LANGUAGES } from './lib' @@ -139,7 +138,7 @@ <p class="text-sm" >Enable Windmill AI in the <a class="inline-flex flex-row items-center gap-1" - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank">workspace settings</a ></p ></div diff --git a/frontend/src/lib/components/copilot/ScriptGen.svelte b/frontend/src/lib/components/copilot/ScriptGen.svelte index fea738b8f1..4b141ec228 100644 --- a/frontend/src/lib/components/copilot/ScriptGen.svelte +++ b/frontend/src/lib/components/copilot/ScriptGen.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { Button } from '../common' import { MAX_SCHEMA_LENGTH, SUPPORTED_LANGUAGES, addThousandsSeparator, copilot } from './lib' @@ -528,7 +527,7 @@ {:else} <p class="text-sm"> Enable Windmill AI in the <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" > diff --git a/frontend/src/lib/components/copilot/StepInputsGen.svelte b/frontend/src/lib/components/copilot/StepInputsGen.svelte index c9ef616aa5..2fd93866d3 100644 --- a/frontend/src/lib/components/copilot/StepInputsGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputsGen.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import YAML from 'yaml' import { yamlStringifyExceptKeys } from './utils' import { sliceModules } from '../flows/flowStateUtils.svelte' @@ -252,7 +251,7 @@ input_name2: expression2 {#if !$copilotInfo.enabled} Enable Windmill AI in the{' '} <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" > diff --git a/frontend/src/lib/components/copilot/chat/AIButton.svelte b/frontend/src/lib/components/copilot/chat/AIButton.svelte index 7420e8d6b7..8b23237ebf 100644 --- a/frontend/src/lib/components/copilot/chat/AIButton.svelte +++ b/frontend/src/lib/components/copilot/chat/AIButton.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { copilotInfo } from '$lib/aiStore' import { aiUserDisabled } from '$lib/stores' import Popover from '$lib/components/meltComponents/Popover.svelte' @@ -50,7 +49,7 @@ {:else} <p class="text-sm" >Enable Windmill AI in the <a - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" target="_blank" class="inline-flex flex-row items-center gap-1" >workspace settings <ExternalLink size={16} /></a diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index fd95613f2e..301a8dba54 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -7,7 +7,6 @@ import { getAiChatManager } from './aiChatManagerContext' const aiChatManager = getAiChatManager() - import { base } from '$lib/base' import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core' import { copilotInfo } from '$lib/aiStore' @@ -74,7 +73,7 @@ : $aiUserDisabled ? 'Windmill AI is disabled in your account settings' : isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + ? 'Enable Windmill AI in your [workspace settings](/workspace_settings?tab=ai) to use this chat' : 'Ask an admin to enable Windmill AI in this workspace to use this chat' : aiChatManager.mode === AIMode.SCRIPT && aiChatManager.scriptEditorOptions?.lang && diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index d5406aeca2..1060a64110 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -71,7 +71,6 @@ import { sendUserToast } from '$lib/toast' import Alert from '$lib/components/common/alert/Alert.svelte' import { copilotInfo } from '$lib/aiStore' - import { base } from '$lib/base' const MAX_YOLO_TOOLTIP_TOOLS = 8 const chatHost = getChatViewHost() @@ -713,7 +712,7 @@ unifiedSize="2xs" variant="accent" startIcon={{ icon: KeyRound }} - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" > Add your own API key </Button> @@ -733,7 +732,7 @@ unifiedSize="2xs" variant="default" startIcon={{ icon: KeyRound }} - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" > Configure your API key </Button> @@ -1107,16 +1106,13 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> </Tooltip> {:else if canAttachFiles} <DropdownV2 - items={async () => { - // Both submenus fetch on the menu's first open, so they start - // together: awaited inline they queue, and the whole menu — - // attachments included — waits out two round trips. + items={() => { const closeMenu = () => (plusMenuOpen = false) const inGlobal = chatHost.mode === AIMode.GLOBAL - const [skillItems, mcpItems] = await Promise.all([ - inGlobal ? skillsMenu.items(closeMenu) : undefined, - inGlobal ? mcpMenu.items(closeMenu) : undefined - ]) + if (inGlobal) { + skillsMenu.refresh() + mcpMenu.refresh() + } return [ { displayName: 'Attach file or image', @@ -1156,23 +1152,24 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> } ] : []), - ...(skillItems + ...(inGlobal ? [ { displayName: 'Skills', icon: BookOpen, separatorTop: true, - submenuItems: skillItems - } - ] - : []), - ...(mcpItems - ? [ + // Getters so the submenus fill in when their fetch lands + // rather than the whole menu waiting on it. + get submenuItems() { + return skillsMenu.items(closeMenu) + } + }, { displayName: 'MCP connections', icon: Plug, - separatorTop: !skillItems, - submenuItems: mcpItems + get submenuItems() { + return mcpMenu.items(closeMenu) + } } ] : []) diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index bcea3a26d6..41ea788fac 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -50,6 +50,7 @@ type AttachedBlob } from './blobUtils' import { MessageDraft } from './messageDraft.svelte' + import { useSessionPermissions } from '$lib/components/sessions/sessionPermissions.svelte' import ExpandableImage, { isImageViewerOpen } from '$lib/components/common/image/ExpandableImage.svelte' @@ -134,10 +135,28 @@ 'Refactor a script to add error handling', 'List my workspace flows and scripts' ] - const globalSuggestion = - GLOBAL_PLACEHOLDER_SUGGESTIONS[ - Math.floor(Math.random() * GLOBAL_PLACEHOLDER_SUGGESTIONS.length) - ] + // An operator authors nothing, so nothing above is theirs to ask for; one with the + // workspace's builder right can compose flows out of deployed scripts as well. + const OPERATOR_PLACEHOLDER_SUGGESTIONS = [ + 'List my workspace flows and scripts', + 'Show the runs that failed today and why', + 'Find the script that sends a report and run it', + 'Rerun the last failed run of a flow' + ] + const BUILDER_PLACEHOLDER_SUGGESTIONS = [ + ...OPERATOR_PLACEHOLDER_SUGGESTIONS, + 'Compose a flow that chains two deployed scripts' + ] + const permissions = useSessionPermissions(() => chatHost.operatingWorkspace) + const suggestionPick = Math.random() + const globalSuggestion = $derived.by(() => { + const pool = permissions.canOpenEditor('script') + ? GLOBAL_PLACEHOLDER_SUGGESTIONS + : permissions.canOpenEditor('flow') + ? BUILDER_PLACEHOLDER_SUGGESTIONS + : OPERATOR_PLACEHOLDER_SUGGESTIONS + return pool[Math.floor(suggestionPick * pool.length)] + }) // Generate mode-specific placeholder const modePlaceholder = $derived.by(() => { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index dcb5879169..066446c26e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -168,7 +168,11 @@ import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRout import { appendAttachedFilesRoster } from './files/fileTools' import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode' import { PlanModeController, type PlanModeHost } from './planModeController.svelte' -import { navigationOperatorBuilderFlows } from '$lib/operatorWriteRights' +import { + navigationOperatorBuilderApps, + navigationOperatorBuilderFlows, + navigationOperatorBuilderRights +} from '$lib/operatorWriteRights' // Compaction of the stored history: once the projected request size // (contextTokens — the provider's report when current, a fresh chars/4 @@ -301,7 +305,9 @@ export function supportsPlanMode(mode: AIMode): boolean { return PLAN_MODES.has(mode) } +const isOperatorBuilder = fromStore(navigationOperatorBuilderRights) const isOperatorBuilderFlows = fromStore(navigationOperatorBuilderFlows) +const isOperatorBuilderApps = fromStore(navigationOperatorBuilderApps) export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() @@ -501,11 +507,17 @@ export class AIChatManager implements ChatViewHost { skipResponsesApi = $state(false) mode = $state<AIMode>(AIMode.NAVIGATOR) - pipelineAiChatHelpers = $state<PipelineAIChatHelpers | undefined>(undefined) + // One per mounted pipeline editor; a session can have several folders open. + #pipelineEditors = new Set<PipelineAIChatHelpers>() // Resolved when a pipeline editor registers its tools. open_preview(pipeline) // awaits this so the model's next build_pipeline_node call can't race ahead of // the async canvas mount and hit "Unknown tool call". - #pipelineHelpersWaiters = new Set<() => void>() + #pipelineHelpersWaiters = new Set<(helpers: PipelineAIChatHelpers) => void>() + // A session's pipeline editor unmounts whenever another tab takes the preview + // panel. Its tools stay offered for every folder the session opened, and a call + // reopens the tab through this, so a page shown in between costs no failed call. + #pipelineReopener: ((folder: string) => void) | undefined = undefined + #openedPipelineFolders = new Set<string>() readonly isOpen = $derived(chatState.size > 0) savedSize = $state<number>(0) instructions = $state<string>('') @@ -912,6 +924,15 @@ export class AIChatManager implements ChatViewHost { await this.#persistModifiedItems() } + // Record the items deploying a chat-modified item produced — a pipeline's + // scripts — so a fork review selects exactly them. Persisted at once, like a + // discard: the deploy can run outside any turn. + async recordDeployedItems(itemKind: UserDraftItemKind, storagePaths: string[]) { + if (!this.modifiedItems || storagePaths.length === 0) return + for (const p of storagePaths) this.modifiedItems.add(maskKey(itemKind, p)) + await this.#persistModifiedItems() + } + // Move a mask entry to the path a draft actually deployed to. A draft-only // flow/app parks at a synthetic `draft_{uuid}` storage path and deploys to // its chosen path — without the move, the existence check at the synthetic @@ -1464,20 +1485,23 @@ export class AIChatManager implements ChatViewHost { .map((s) => ({ ...s, kind: 'skill' as const })) ]) - // The flow and script builders both write code, which the backend refuses from an operator - // with the builder right: leaving them reachable would only produce work that cannot be + // The flow, app and script builders all write code, which the backend refuses from an + // operator with builder rights: leaving them reachable would only produce work that cannot be // deployed. allowedModes: Record<AIMode, boolean> = $derived({ script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined && !this.disabledModes.script && - !isOperatorBuilderFlows.current, + !isOperatorBuilder.current, flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow && !isOperatorBuilderFlows.current, - app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, + app: + this.appAiChatHelpers !== undefined && + !this.disabledModes.app && + !isOperatorBuilderApps.current, navigator: !this.disabledModes.navigator, ask: !this.disabledModes.ask, API: !this.disabledModes.API, @@ -2518,8 +2542,27 @@ export class AIChatManager implements ChatViewHost { // Public because it is purely local, unlike `changeMode(GLOBAL)`, which also // fires the three network refreshes. + setPipelineReopener = (reopen: (folder: string) => void) => { + this.#pipelineReopener = reopen + } + + #pipelineFolders = (): string[] => [ + ...new Set([ + ...[...this.#pipelineEditors].map((p) => p.getFolder()), + ...(this.#pipelineReopener ? this.#openedPipelineFolders : []) + ]) + ] + + #ensurePipeline = async (folder: string): Promise<PipelineAIChatHelpers | undefined> => { + const find = () => [...this.#pipelineEditors].find((p) => p.getFolder() === folder) + const live = find() + if (live || !this.#pipelineReopener) return live + this.#pipelineReopener(folder) + return (await this.waitForPipelineHelpers(folder)) ? find() : undefined + } + configureGlobalMode = () => { - const pipeline = this.pipelineAiChatHelpers + const hasPipelines = this.#pipelineFolders().length > 0 const opts = this.globalAssemblyOpts() const baseHelpers: GlobalToolHelpers = { // A session targets its own fixed (possibly forked) workspace, so capture it for @@ -2551,7 +2594,14 @@ export class AIChatManager implements ChatViewHost { } } this.#assembledTools = assembleGlobalTools(opts) - this.helpers = pipeline ? { ...baseHelpers, pipeline } : baseHelpers + this.helpers = hasPipelines + ? { + ...baseHelpers, + pipelines: () => [...this.#pipelineEditors], + pipelineFolders: this.#pipelineFolders, + ensurePipeline: this.#ensurePipeline + } + : baseHelpers this.systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), opts) this.syncArtifactsSession() } @@ -2564,7 +2614,7 @@ export class AIChatManager implements ChatViewHost { mcpServers: this.mcpServers, access: this.sessionAccess, sessionContext: this.sessionContextResolver?.(), - pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext() + pipelineFolders: this.#pipelineFolders() }) // Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild @@ -4904,19 +4954,18 @@ export class AIChatManager implements ChatViewHost { // AI edits apply directly as drafts, so there is nothing to auto-accept. // Returns a cleanup that tears the registration back down. setPipelineHelpers = (pipelineHelpers: PipelineAIChatHelpers) => { - this.pipelineAiChatHelpers = pipelineHelpers + this.#pipelineEditors.add(pipelineHelpers) + this.#openedPipelineFolders.add(pipelineHelpers.getFolder()) untrack(() => { if (this.mode === AIMode.GLOBAL) { this.configureGlobalMode() } }) // The pipeline tools are now registered — release anything awaiting them. - const waiters = [...this.#pipelineHelpersWaiters] - this.#pipelineHelpersWaiters.clear() - waiters.forEach((resolve) => resolve()) + ;[...this.#pipelineHelpersWaiters].forEach((notify) => notify(pipelineHelpers)) return () => { - this.pipelineAiChatHelpers = undefined + this.#pipelineEditors.delete(pipelineHelpers) untrack(() => { if (this.mode === AIMode.GLOBAL) { this.configureGlobalMode() @@ -4926,15 +4975,16 @@ export class AIChatManager implements ChatViewHost { } /** - * Await the pipeline editor's tool registration. Resolves `true` immediately - * when a pipeline editor is already mounted, or when the next one registers; - * resolves `false` after `timeoutMs` if none registers (e.g. a backgrounded - * session whose preview tab is not mounted, or a closed tab). Callers must - * treat `false` as "tools NOT available" rather than silently reporting - * success — the open_preview handler surfaces that to the model. + * Await the tool registration of the pipeline editor for `folder` (any folder + * when unset). Resolves `true` immediately when one is already mounted, or when + * it registers; resolves `false` after `timeoutMs` if none registers (e.g. a + * session whose runtime is not mounted, or a closed tab). Callers must treat + * `false` as "tools NOT available" rather than silently reporting success — + * the open_preview handler surfaces that to the model. */ - waitForPipelineHelpers = (timeoutMs = 8000): Promise<boolean> => { - if (this.pipelineAiChatHelpers) return Promise.resolve(true) + waitForPipelineHelpers = (folder?: string, timeoutMs = 8000): Promise<boolean> => { + const matches = (p: PipelineAIChatHelpers) => folder === undefined || p.getFolder() === folder + if ([...this.#pipelineEditors].some(matches)) return Promise.resolve(true) return new Promise<boolean>((resolve) => { let settled = false const finish = (registered: boolean) => { @@ -4943,7 +4993,9 @@ export class AIChatManager implements ChatViewHost { this.#pipelineHelpersWaiters.delete(onRegister) resolve(registered) } - const onRegister = () => finish(true) + const onRegister = (p: PipelineAIChatHelpers) => { + if (matches(p)) finish(true) + } this.#pipelineHelpersWaiters.add(onRegister) setTimeout(() => finish(false), timeoutMs) }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index dbb3ac51e3..19d432bd37 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -24,7 +24,6 @@ vi.mock('$app/navigation', () => ({ afterNavigate: vi.fn(), beforeNavigate: vi.fn() })) -vi.mock('$app/paths', () => ({ base: '', assets: '' })) const mocks = vi.hoisted(() => ({ getCurrentModel: vi.fn(), @@ -4329,11 +4328,15 @@ describe('DOM selector chips scoped by app path', () => { describe('AIChatManager.waitForPipelineHelpers', () => { function fakePipelineHelpers(): PipelineAIChatHelpers { return { + getFolder: () => 'f', + hasDrafts: () => false, getPipelineContext: () => ({ folder: 'f', mode: 'edit', nodes: [], assets: [] }), getNodeBody: async () => undefined, proposeNode: async () => ({ path: '', detectedReads: [], detectedWrites: [] }), editNode: async () => ({ detectedReads: [], detectedWrites: [] }), removeProposedNode: async () => {}, + unconfiguredTriggers: async () => [], + setNodeTrigger: async () => ({ path: '', replaced: false }), testNode: async () => undefined } } @@ -4341,13 +4344,13 @@ describe('AIChatManager.waitForPipelineHelpers', () => { it('resolves true immediately when a pipeline editor is already registered', async () => { const manager = new AIChatManager() manager.setPipelineHelpers(fakePipelineHelpers()) - await expect(manager.waitForPipelineHelpers(1000)).resolves.toBe(true) + await expect(manager.waitForPipelineHelpers(undefined, 1000)).resolves.toBe(true) }) it('resolves true once a pipeline editor registers', async () => { const manager = new AIChatManager() let outcome: boolean | undefined - const wait = manager.waitForPipelineHelpers(1000).then((v) => (outcome = v)) + const wait = manager.waitForPipelineHelpers(undefined, 1000).then((v) => (outcome = v)) await Promise.resolve() expect(outcome).toBeUndefined() manager.setPipelineHelpers(fakePipelineHelpers()) @@ -4355,12 +4358,24 @@ describe('AIChatManager.waitForPipelineHelpers', () => { expect(outcome).toBe(true) }) + it('waits for the editor of the requested folder, not any editor', async () => { + const manager = new AIChatManager() + manager.setPipelineHelpers(fakePipelineHelpers()) + let outcome: boolean | undefined + const wait = manager.waitForPipelineHelpers('sales', 1000).then((v) => (outcome = v)) + await Promise.resolve() + expect(outcome).toBeUndefined() + manager.setPipelineHelpers({ ...fakePipelineHelpers(), getFolder: () => 'sales' }) + await wait + expect(outcome).toBe(true) + }) + // The false result is the signal the open_preview handler needs: a backgrounded // session's editor never mounts, so it must report "tools unavailable" rather // than silently claim success. it('resolves false after the timeout when no editor ever registers', async () => { const manager = new AIChatManager() - await expect(manager.waitForPipelineHelpers(10)).resolves.toBe(false) + await expect(manager.waitForPipelineHelpers(undefined, 10)).resolves.toBe(false) }) }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index 7bf1d99693..d11375b89b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -22,7 +22,6 @@ } from '$lib/aiStore' import { WorkspaceService, type AIProvider, type AIProviderModel } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { base } from '$lib/base' import { getAiChatManager } from './aiChatManagerContext' import { thinkingPreferences } from './thinkingPreferences.svelte' import { @@ -47,7 +46,7 @@ }: { promptSettings?: boolean } = $props() const aiChatManager = getAiChatManager() - const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai` + const AI_SETTINGS_HREF = '/workspace_settings?tab=ai' let providerModel = $derived( ($copilotSessionModel ?? diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 1a1412b9f8..3e0351f249 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -3,14 +3,12 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte' import { zIndexes } from '$lib/zIndexes' - import { userStore, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import { chatState } from './sharedChatState.svelte' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { copilotInfo } from '$lib/aiStore' import { aiChatManager } from './AIChatManager.svelte' import { onDestroy } from 'svelte' - import Button from '$lib/components/common/button/Button.svelte' - import { Menu } from 'lucide-svelte' import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte' interface Props { @@ -20,7 +18,6 @@ transitionClass?: string isMobile?: boolean children: any - onMenuOpen?: () => void disableAi?: boolean // Whether this layout loads the workspace AI config. It gates far more than // the docked pane (code completion, metadata generation, the "open in AI @@ -40,15 +37,14 @@ transitionClass = 'transition-all ease-in-out duration-200', isMobile = false, children, - onMenuOpen, disableAi, loadAiConfig = true, showSessionsBetaBanner = false }: Props = $props() // The desktop rail is fixed-positioned, so the content is offset by a matching - // left padding (in rem, matching the rail). Mobile/operator/borderless: no rail. - let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth) + // left padding (in rem, matching the rail). Drawer (mobile or detached)/borderless: no rail. + let contentPadLeft = $derived(noBorder || isMobile ? 0 : sidebarWidth) $effect(() => { chatState.dockedChatAvailable = !disableAi @@ -83,23 +79,6 @@ }) </script> -{#snippet burgerRow()} - <div - class={classNames( - 'py-0.5 px-4 sm:px-4 shadow-sm max-w-7xl md:hidden justify-start flex', - noBorder || $userStore?.operator ? 'hidden' : '' - )} - > - <Button - variant="subtle" - unifiedSize="lg" - onClick={() => onMenuOpen?.()} - startIcon={{ icon: Menu }} - iconOnly - /> - </div> -{/snippet} - {#if !disableAi} <CreatedResourceActionDrawers /> <Splitpanes horizontal={false} class="flex-1 min-h-0"> @@ -111,7 +90,6 @@ > <main class="flex-1 flex flex-col min-h-0"> <div class="relative w-full flex-1 flex flex-col min-h-0"> - {@render burgerRow()} <div class="flex-1 min-h-0"> {@render children?.()} </div> @@ -138,12 +116,14 @@ {/if} </Splitpanes> {:else} + <!-- The scroll lives here, below the page header, exactly as it does in the docked-chat layout + above: a page taller than the window scrolls inside this box, so the band stays on screen + instead of leaving with the content. --> <div id="content" - class={classNames('flex-1 min-h-0 flex flex-col', transitionClass)} + class={classNames('flex-1 min-h-0 flex flex-col overflow-y-auto', transitionClass)} style:padding-left="{contentPadLeft}rem" > - {@render burgerRow()} <div class="flex-1 min-h-0 flex flex-col"> {@render children?.()} </div> diff --git a/frontend/src/lib/components/copilot/chat/AssistantInstructionsSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantInstructionsSection.svelte index 5d825bd5c8..324082e3e0 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantInstructionsSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantInstructionsSection.svelte @@ -20,7 +20,6 @@ writes whichever of the two changed, including the tab that is not on screen. import { userStore, type UserExt } from '$lib/stores' import { getUserExt } from '$lib/user' import { sendUserToast } from '$lib/toast' - import { base } from '$lib/base' import { Building2, ExternalLink, User } from 'lucide-svelte' import { untrack } from 'svelte' import { getAiChatManager } from './aiChatManagerContext' @@ -50,7 +49,7 @@ writes whichever of the two changed, including the tab that is not on screen. const aiChatManager = getAiChatManager() // The section acts on `ws`, which in a session is not the nav workspace, so the link // has to name it or it opens settings the user may not administer. - let aiSettingsHref = $derived(`${base}/workspace_settings?workspace=${ws}&tab=ai`) + let aiSettingsHref = $derived(`/workspace_settings?workspace=${ws}&tab=ai`) let mode = $derived(aiChatManager.mode) let tab = $state<'workspace' | 'user'>('workspace') diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index b6455980a3..3050dcedf3 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -15,7 +15,6 @@ import DisplayResult from '$lib/components/DisplayResult.svelte' import { Bot, ExternalLink } from 'lucide-svelte' import CopyButton from '$lib/components/common/button/CopyButton.svelte' - import { base } from '$lib/base' import { displayDate, truncateRev } from '$lib/utils' interface Props { @@ -35,7 +34,7 @@ // the browser — so the job link is absent rather than empty elsewhere. const jobId = $derived(message.role === 'assistant' ? message.jobId : undefined) const createdAt = $derived(message.role === 'assistant' ? message.createdAt : undefined) - const runHref = $derived(jobId ? `${base}/run/${jobId}?workspace=${workspace}` : undefined) + const runHref = $derived(jobId ? `/run/${jobId}?workspace=${workspace}` : undefined) // Today's answers show the time alone; the day earns its place only on a conversation // read back later. Resolved at render, so a chat left open across midnight keeps // yesterday's format until it is reopened. diff --git a/frontend/src/lib/components/copilot/chat/AssistantPermissionsSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantPermissionsSection.svelte new file mode 100644 index 0000000000..ee7b4a689c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AssistantPermissionsSection.svelte @@ -0,0 +1,86 @@ +<!-- +@component +The Permissions section of the assistant settings modal: what the user's role lets them create +and edit in the chat's workspace, next to what the assistant can make for them from the chat. +The assistant acts with the user's own rights, but its write tools go through drafts, so for +operators the second column is narrower. +--> +<script lang="ts"> + import { Badge, Section } from '$lib/components/common' + import { Check, Minus } from 'lucide-svelte' + import { userWorkspaces } from '$lib/stores' + import { useActingUser } from '$lib/actingUser.svelte' + import { capitalize } from '$lib/utils/capitalize' + import { OPERATOR_SETTINGS_LOCATION, permissionRows, roleName } from './assistantPermissions' + + let { ws }: { ws: string } = $props() + + const acting = useActingUser(() => ws) + + let user = $derived(acting.current) + let operatorSettings = $derived($userWorkspaces.find((w) => w.id === ws)?.operator_settings) + let role = $derived(user ? roleName(user) : undefined) + let rows = $derived(user ? permissionRows(user, operatorSettings) : []) + let chatNarrower = $derived(rows.some((r) => r.user && !r.assistant)) +</script> + +<Section + label="Permissions" + description="What you can create and edit in this workspace, and what the assistant can make for you from this chat." + class="flex flex-col gap-4" +> + {#snippet action()} + {#if role} + <Badge color={role === 'operator' ? 'yellow' : 'gray'}>{capitalize(role)}</Badge> + {/if} + {/snippet} + + {#if !user} + <p class="text-xs text-secondary"> + {acting.resolved(ws) + ? 'Your role in this workspace could not be read.' + : 'Checking your role…'} + </p> + {:else} + <div class="grid max-w-sm grid-cols-[1fr_auto_auto] gap-x-6 gap-y-1.5 text-xs"> + <span class="text-2xs text-secondary">Create and edit</span> + <span class="text-2xs text-secondary text-center">You</span> + <span class="text-2xs text-secondary text-center">From this chat</span> + {#snippet cell(allowed: boolean)} + <span class="flex justify-center"> + {#if allowed} + <Check size={14} class="text-primary" aria-hidden="true" /> + {:else} + <Minus size={14} class="text-hint" aria-hidden="true" /> + {/if} + <span class="sr-only">{allowed ? 'Allowed' : 'Not allowed'}</span> + </span> + {/snippet} + {#each rows as row (row.label)} + <span class="text-primary">{row.label}</span> + {@render cell(row.user)} + {@render cell(row.assistant)} + {/each} + </div> + + <div class="flex flex-col gap-2 text-xs text-secondary"> + {#if chatNarrower} + <p> + The assistant acts as you, with your rights, but it saves every change as a draft before + deploying it, and your role can't save drafts of everything you can edit. Make those on + their own pages instead. + </p> + {/if} + <p>Anyone in the workspace can run the scripts and flows they can see.</p> + <p> + Items in folders you can't read are hidden from you and from the assistant. Someone with + access to the folder can run them. + </p> + {#if role === 'operator'} + <p> + A workspace admin can give operators more rights in {OPERATOR_SETTINGS_LOCATION}. + </p> + {/if} + </div> + {/if} +</Section> diff --git a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte index 5c8eda40ad..534e550b89 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte @@ -10,7 +10,13 @@ The trigger sits in the composer next to the model pill; `open(section)` is for callers that already know which section they mean, such as the "+" menu's Manage entries. --> <script lang="ts" module> - export type AssistantSettingsSection = 'tools' | 'skills' | 'instructions' | 'mcp' | 'files' + export type AssistantSettingsSection = + | 'tools' + | 'skills' + | 'instructions' + | 'mcp' + | 'files' + | 'permissions' </script> <script lang="ts"> @@ -21,6 +27,7 @@ callers that already know which section they mean, such as the "+" menu's Manage Paperclip, Plug, ScrollText, + ShieldCheck, SlidersHorizontal } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' @@ -35,6 +42,7 @@ callers that already know which section they mean, such as the "+" menu's Manage import AssistantInstructionsSection from './AssistantInstructionsSection.svelte' import AssistantMcpSection from './AssistantMcpSection.svelte' import AssistantFilesSection from './AssistantFilesSection.svelte' + import AssistantPermissionsSection from './AssistantPermissionsSection.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' // Prefilled title marks the issue as AI Sessions feedback for triage. @@ -71,7 +79,8 @@ callers that already know which section they mean, such as the "+" menu's Manage skills: skillsBusy, instructions: instructionsBusy, mcp: mcpBusy, - files: filesBusy + files: filesBusy, + permissions: false }) let blockingSection = $derived<AssistantSettingsSection | undefined>( busyBySection[section] @@ -121,7 +130,8 @@ callers that already know which section they mean, such as the "+" menu's Manage { id: 'mcp', label: 'MCP connections', icon: Plug, count: mcpCount }, // The count is what is readable rather than what is attached: a locked folder under // a heading about what the assistant can use would say the opposite of the truth. - { id: 'files', label: 'Files & folders', icon: Paperclip, count: fileCount } + { id: 'files', label: 'Files & folders', icon: Paperclip, count: fileCount }, + { id: 'permissions', label: 'Permissions', icon: ShieldCheck } ]) /** Opens on `target`, or back on whichever section was last read. */ @@ -267,6 +277,11 @@ callers that already know which section they mean, such as the "+" menu's Manage <div class="{section === 'files' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2"> <AssistantFilesSection bind:count={fileCount} bind:blocksClose={filesBusy} /> </div> + <div + class="{section === 'permissions' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2" + > + <AssistantPermissionsSection {ws} /> + </div> </div> </div> </Modal2> diff --git a/frontend/src/lib/components/copilot/chat/JobsSegment.svelte b/frontend/src/lib/components/copilot/chat/JobsSegment.svelte index b8a59d5bc9..31fce8fd6e 100644 --- a/frontend/src/lib/components/copilot/chat/JobsSegment.svelte +++ b/frontend/src/lib/components/copilot/chat/JobsSegment.svelte @@ -8,7 +8,6 @@ import JobStatusIcon from '$lib/components/runs/JobStatusIcon.svelte' import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte' import { ChevronUp, Hourglass, ThumbsUp, TimerOff } from 'lucide-svelte' - import { base } from '$lib/base' import { slide } from 'svelte/transition' import { JobService, type Job } from '$lib/gen' import { msToReadableTime } from '$lib/utils' @@ -211,7 +210,7 @@ } function runHref(job: ChatJob): string { - return `${base}/run/${job.jobId}?workspace=${job.workspace}` + return `/run/${job.jobId}?workspace=${job.workspace}` } function openRun(job: ChatJob) { diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index ca5e717891..4b3fa0b5be 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -10,7 +10,6 @@ import { msToReadableTime } from '$lib/utils' import JobArgs from '$lib/components/JobArgs.svelte' import { JobService, type CompletedJob, type Job } from '$lib/gen' - import { base } from '$lib/base' import { getAiChatManager } from './aiChatManagerContext' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import RunArgsFormDisplay from './RunArgsFormDisplay.svelte' @@ -102,7 +101,16 @@ canceled || (chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status))) ) - const running = $derived(!pending && !settled) + // The form is answered but no job exists yet: the round trip that creates it, or a start + // that is about to fail. Saying Running here would claim a job before there is one. + const starting = $derived(!pending && !settled && !ran) + const running = $derived(!pending && !settled && ran) + // A new job is registered as `queued` with no snapshot, so until the first poll returns one + // its status is a placeholder, not something seen: the row keeps saying Starting. + const unseen = $derived(running && !chatJob?.job) + // The job exists but no worker has taken it. Still `running` for everything that only needs + // a live job (cancel, logs); only the row tells the two apart. As fresh as the last poll. + const waiting = $derived(running && !unseen && chatJob?.status === 'queued') // The job the card is about has not been read yet, or could not be: no pane has anything // to show, but the call's own result still has. const jobPending = $derived(Boolean(inspected) && !inspectedJob) @@ -170,7 +178,17 @@ // Inspecting is done the moment the tool returned, whatever the run it looked at is // still doing — the tense belongs to the call, not to its subject. const verb = $derived( - inspected ? 'Inspected' : running ? verbs.present : settled && ran ? verbs.past : verbs.future + inspected + ? 'Inspected' + : starting || unseen + ? 'Starting' + : waiting + ? '' + : running + ? verbs.present + : settled && ran + ? verbs.past + : verbs.future ) // Being cancelled is an outcome like any other, and it is the one the card has to say out @@ -331,12 +349,28 @@ // keeps no timers at all. let now = $state(Date.now()) $effect(() => { - if (!running || !chatJob) return + if (!running || unseen || waiting || !chatJob) return + // `now` last moved whenever the card mounted or a previous run stopped ticking, so the + // job's first frame would otherwise read as a negative elapsed time. + now = Date.now() const timer = setInterval(() => (now = Date.now()), 500) return () => clearInterval(timer) }) - const elapsed = $derived(chatJob ? msToReadableTime(now - chatJob.createdAt, 2) : '') + // The clock counts from when a worker took the job, as `duration_ms` does, so the time in the + // queue never shows as run time. The server's own `started_at - created_at` is added to the + // local creation time rather than reading `started_at` against the browser's clock, which + // can be off from the server's by more than the run lasts. + const runStartedAt = $derived.by(() => { + if (!chatJob) return undefined + const j = chatJob.job + const queuedMs = + j?.started_at && j.created_at ? Date.parse(j.started_at) - Date.parse(j.created_at) : NaN + return chatJob.createdAt + (Number.isFinite(queuedMs) ? Math.max(0, queuedMs) : 0) + }) + const elapsed = $derived( + runStartedAt !== undefined ? msToReadableTime(Math.max(0, now - runStartedAt), 2) : '' + ) const duration = $derived( chatJob?.durationMs !== undefined ? msToReadableTime(chatJob.durationMs, 2) : '' ) @@ -370,7 +404,17 @@ // has no time to give, so its outcome takes the slot — as a word, never "Not run", which // stutters against the "Run <name>" label beside it. const outcome = $derived(failed ? 'Failed' : canceled ? 'Cancelled' : 'Done') - const statusTime = $derived(running ? elapsed : duration || outcome) + // No job seen yet, so no time and no outcome: the verb already says Starting. A queued job has + // no run time either, and says why in the slot, in the queued hue, in place of the verb. + const statusTime = $derived( + starting || unseen + ? '' + : waiting + ? 'Waiting for a worker ·' + : running + ? elapsed + : duration || outcome + ) // What the preview button opens changes with the card: the form while the call is still // waiting on one, the run once a job exists. Neither, and there is nothing to open, so @@ -404,7 +448,7 @@ if (aiChatManager.openRunInPreview) { aiChatManager.openRunInPreview({ jobId: job.jobId, workspace: job.workspace, label }) } else { - window.open(`${base}/run/${job.jobId}?workspace=${job.workspace}`, '_blank', 'noreferrer') + window.open(`/run/${job.jobId}?workspace=${job.workspace}`, '_blank', 'noreferrer') } } </script> @@ -416,7 +460,7 @@ {#snippet status()} <!-- An inspection reports nothing here: the card is a snapshot the chat will never update, so a status on it would be frozen at whatever the run happened to be. --> - {#if !pending && !inspected} + {#if !pending && !inspected && statusTime} <span class={twMerge('shrink-0 whitespace-nowrap text-2xs font-medium', statusClass)}> {statusTime} </span> @@ -591,7 +635,7 @@ : 'No logs.'} </p> {/if} - {#if running && !inspected} + {#if running && !waiting && !inspected} <div class="mt-1 flex items-center gap-1.5 text-2xs text-tertiary"> <Loader2 class="h-3 w-3 animate-spin" /> streaming diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 38f91353a2..832a96d6b4 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -13,7 +13,6 @@ ExternalLink, BookOpen } from 'lucide-svelte' - import { base } from '$lib/base' import { truncateRev } from '$lib/utils' import { EXIT_PLAN_MODE_TOOL, @@ -301,7 +300,7 @@ {#snippet jobLink()} <a - href="{base}/run/{message.jobId}?workspace={chatHost.operatingWorkspace}" + href="/run/{message.jobId}?workspace={chatHost.operatingWorkspace}" target="_blank" rel="noopener noreferrer" class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline" diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index ab69d0b1a3..ce11b0dade 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -19,6 +19,7 @@ import { applyReasoningToConfig } from '../reasoningRegistry' import { appendPendingToolImages, processToolCall, + type LiveToolSet, queuedToolStatus, type Tool, type ToolCallbacks, @@ -197,7 +198,11 @@ export async function parseAnthropicCompletion( tools: Tool<any>[], helpers: any, abortController?: AbortController, - options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void } + options?: { + workspace?: string + onTokenUsage?: (usage: ChatTokenUsage) => void + live?: LiveToolSet + } ): Promise<ParsedCompletionResult> { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error = null @@ -447,7 +452,8 @@ export async function parseAnthropicCompletion( helpers, toolCallbacks: callbacks, workspace: options?.workspace, - messages + messages, + live: options?.live }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index b013c58ebc..166df65a16 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -37,6 +37,8 @@ export interface BackendRunnable { inlineScript?: InlineScript // For workspace scripts/flows and hub scripts path?: string + // Inline only: data table connections naming no role hold the viewer's roles + viewerDataAccess?: boolean } export interface AppFiles { @@ -327,6 +329,12 @@ const getSetBackendRunnableSchema = memo(() => .optional() .describe( 'Required when type is "script", "flow", or "hubscript". The fully qualified path to the workspace script/flow or hub script.' + ), + viewerDataAccess: z + .boolean() + .optional() + .describe( + "Inline only. When true, the runnable reads data as the person viewing the app instead of as the app's identity: data table connections that name no role hold the viewer's roles, and object storage permission rules are checked against the viewer, so row-level security and per-user storage paths answer per viewer. Resources and variables stay the app's. Anonymous viewers are refused. Omit to keep the current setting." ) }) ) @@ -710,7 +718,8 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [ ...args, staticInputs: args.staticInputs === null ? undefined : args.staticInputs, inlineScript: args.inlineScript === null ? undefined : args.inlineScript, - path: args.path === null ? undefined : args.path + path: args.path === null ? undefined : args.path, + viewerDataAccess: args.viewerDataAccess === null ? undefined : args.viewerDataAccess } const parsedArgs = getSetBackendRunnableSchema().parse(cleanedArgs) @@ -723,7 +732,10 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [ type: parsedArgs.type, ...(parsedArgs.staticInputs && { staticInputs: parsedArgs.staticInputs }), ...(parsedArgs.inlineScript && { inlineScript: parsedArgs.inlineScript }), - ...(parsedArgs.path && { path: parsedArgs.path }) + ...(parsedArgs.path && { path: parsedArgs.path }), + ...(parsedArgs.viewerDataAccess !== undefined && { + viewerDataAccess: parsedArgs.viewerDataAccess + }) } toolCallbacks.setToolStatus(toolId, { diff --git a/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.test.ts b/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.test.ts index 115343117a..da3ac686b3 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.test.ts +++ b/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -vi.mock('$lib/base', () => ({ base: '' })) - import { shareWorkspaceId } from './artifactSharing' describe('shareWorkspaceId', () => { diff --git a/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts b/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts index 57b08cba9b..269eb6354b 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts +++ b/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts @@ -1,5 +1,3 @@ -import { base } from '$lib/base' - /** * The workspace a share from `workspaceId` lands in: the topmost ancestor the user still * belongs to. A session often runs in a fork, whose members are its creator alone, so a link @@ -20,7 +18,7 @@ export function shareWorkspaceId( } export function sharedArtifactUrl(workspaceId: string, shareId: string): string { - return `${window.location.origin}${base}/shared_artifacts/${encodeURIComponent( + return `${window.location.origin}/shared_artifacts/${encodeURIComponent( shareId )}?workspace=${encodeURIComponent(workspaceId)}` } diff --git a/frontend/src/lib/components/copilot/chat/assistantPermissions.ts b/frontend/src/lib/components/copilot/chat/assistantPermissions.ts new file mode 100644 index 0000000000..4df3f605e2 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/assistantPermissions.ts @@ -0,0 +1,68 @@ +import type { OperatorSettings } from '$lib/gen' +import type { UserExt } from '$lib/stores' +import { roleCanAuthor, type EditKind } from '$lib/editRights' +import { capabilitiesForRole } from './global/sessionAccess' +import type { SessionAccess } from './sessionCapabilities' + +/** Where a workspace admin changes what operators may do. Named in the prompt and the + * permissions section alike, so the two send the user to the same place. */ +export const OPERATOR_SETTINGS_LOCATION = 'Workspace settings → Users → Operator settings' + +type PermissionRow = { + kind: EditKind + label: string + /** The draft capability the kind's write tool declares in `requires`, plus the schedule or + * trigger write gate where the kind has one. */ + assistant: (access: SessionAccess) => boolean +} + +const ROWS: PermissionRow[] = [ + { kind: 'script', label: 'Scripts', assistant: (a) => a.has('write_draft') }, + { kind: 'flow', label: 'Flows', assistant: (a) => a.has('write_flow_draft') }, + { kind: 'raw_app', label: 'Apps', assistant: (a) => a.has('write_app_draft') }, + { + kind: 'schedule', + label: 'Schedules', + assistant: (a) => a.has('write_draft') && a.has('manage_schedules') + }, + { + kind: 'trigger', + label: 'Triggers', + assistant: (a) => a.has('write_draft') && a.has('manage_triggers') + }, + { kind: 'resource', label: 'Resources', assistant: (a) => a.has('write_draft') }, + { kind: 'variable', label: 'Variables', assistant: (a) => a.has('write_draft') } +] + +export type RoleName = 'admin' | 'developer' | 'operator' + +export function roleName( + user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> +): RoleName { + if (user.is_admin || user.is_super_admin) return 'admin' + return user.operator ? 'operator' : 'developer' +} + +/** + * Per item kind, whether the user's role lets them create and edit it themselves, and whether + * the chat can for them. Both act with the user's rights, but the user's column follows the + * item handlers (`roleCanAuthor`) and the chat's the draft endpoint every write tool goes + * through (`capabilitiesForRole`), which refuses operators more than the handlers do. + */ +export function permissionRows( + user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>, + operatorSettings: OperatorSettings | null | undefined +): { label: string; user: boolean; assistant: boolean }[] { + const access = capabilitiesForRole({ + isAdmin: !!user.is_admin || !!user.is_super_admin, + operator: !!user.operator, + // Deploy rules gate deploying, not the drafts these rows are about. + deployRulesPass: true, + operatorSettings + }) + return ROWS.map((row) => ({ + label: row.label, + user: roleCanAuthor(row.kind, user, operatorSettings), + assistant: row.assistant(access) + })) +} diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index cde9d554cd..1e27becba0 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -453,7 +453,8 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul const parseOptions = { workspace, provider: modelProvider.provider, - onTokenUsage: reportUsage + onTokenUsage: reportUsage, + live: () => ({ tools: config.tools, helpers: config.helpers }) } if (isOpenAI) { diff --git a/frontend/src/lib/components/copilot/chat/datatableMigrationTools.test.ts b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.test.ts new file mode 100644 index 0000000000..086adce9e9 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { statusMock, createMock, upsertMock, runMock } = vi.hoisted(() => ({ + upsertMock: vi.fn(), + statusMock: vi.fn(), + createMock: vi.fn(), + runMock: vi.fn() +})) + +vi.mock('./shared', () => ({ + createToolDef: (_schema: unknown, name: string, description: string) => ({ + type: 'function', + function: { name, description, parameters: {} } + }) +})) + +vi.mock('$lib/gen', () => ({ + WorkspaceService: { + getDatatableMigrationsStatus: statusMock, + createDatatableMigration: createMock, + upsertDatatableMigration: upsertMock, + runDatatableMigrations: runMock + } +})) + +import { getDatatableMigrationTools } from './datatableMigrationTools' + +function create(args: Record<string, unknown>) { + const tool = getDatatableMigrationTools().find( + (entry) => entry.def.function.name === 'create_datatable_migration' + )! + return tool.fn({ + args: { + datatable_name: 'main', + name: 'add_refunds', + code_up: 'BEGIN;\nCREATE TABLE refunds ();\nEND;', + ...args + }, + workspace: 'test-workspace', + helpers: {}, + toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() } as any, + toolId: 'tool-1' + }) +} + +const applied = { timestamp: 10, name: 'init', code_up: '', status: 'ran' } +const created = { timestamp: 30, name: 'add_refunds', code_up: '', status: 'not_run' } + +beforeEach(() => { + for (const mock of [statusMock, createMock, upsertMock, runMock]) mock.mockReset() + createMock.mockResolvedValue({ timestamp: 30 }) + statusMock.mockResolvedValue({ enabled: true, migrations: [applied, created] }) + runMock.mockResolvedValue({ applied: [{ version: 30, name: 'add_refunds' }] }) +}) + +describe('create_datatable_migration', () => { + it('applies only the migration it created', async () => { + const result = await create({}) + expect(runMock).toHaveBeenCalledWith({ + workspace: 'test-workspace', + datatableName: 'main', + only: 30 + }) + expect(result).toContain('Applied migration 30 (add_refunds)') + }) + + it('leaves it pending when an earlier migration is not applied', async () => { + statusMock.mockResolvedValue({ + enabled: true, + migrations: [ + applied, + { timestamp: 20, name: 'earlier', code_up: '', status: 'not_run' }, + created + ] + }) + const result = await create({}) + expect(runMock).not.toHaveBeenCalled() + expect(result).toContain('20 (earlier)') + }) + + it('keeps a migration that fails to apply and rewrites it on request', async () => { + runMock.mockRejectedValueOnce({ body: 'relation "refunds" already exists' }) + const failed = await create({}) + expect(failed).toContain('relation "refunds" already exists') + expect(failed).toContain('with version 30') + + statusMock.mockResolvedValue({ + enabled: true, + migrations: [applied, { ...created, code_down: 'DROP TABLE refunds;' }] + }) + await create({ version: 30, code_up: 'BEGIN;\nCREATE TABLE refunds2 ();\nEND;' }) + expect(createMock).toHaveBeenCalledTimes(1) + expect(upsertMock).toHaveBeenCalledWith({ + workspace: 'test-workspace', + datatableName: 'main', + requestBody: { + timestamp: 30, + code_down: 'DROP TABLE refunds;', + name: 'add_refunds', + code_up: 'BEGIN;\nCREATE TABLE refunds2 ();\nEND;' + } + }) + expect(runMock).toHaveBeenLastCalledWith({ + workspace: 'test-workspace', + datatableName: 'main', + only: 30 + }) + }) + + it('refuses to rewrite a version that is not pending', async () => { + const result = await create({ version: 10 }) + expect(upsertMock).not.toHaveBeenCalled() + expect(runMock).not.toHaveBeenCalled() + expect(result).toContain('cannot be rewritten') + }) + + it('does not call unreadable earlier migrations pending', async () => { + statusMock.mockResolvedValue({ + enabled: true, + migrations: [ + { ...applied, status: 'unknown' }, + { ...created, status: 'unknown' } + ], + error: 'connection refused' + }) + const result = await create({}) + expect(runMock).not.toHaveBeenCalled() + expect(result).toContain('connection refused') + expect(result).not.toContain('10 (init)') + }) + + it('declares the role above the SQL of both directions', async () => { + await create({ role: 'app_rw', code_down: 'DROP TABLE refunds;', apply: false }) + expect(createMock.mock.calls[0][0].requestBody).toEqual({ + name: 'add_refunds', + code_up: '-- role app_rw\nBEGIN;\nCREATE TABLE refunds ();\nEND;', + code_down: '-- role app_rw\nDROP TABLE refunds;' + }) + expect(runMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts new file mode 100644 index 0000000000..3a2476b9d0 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts @@ -0,0 +1,391 @@ +import { z } from 'zod' +import { WorkspaceService } from '$lib/gen' +import { withMigrationRole } from '$lib/components/datatableMigrationRole' +import { createToolDef, type ToolCallbacks } from './shared' +import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities' + +/** + * Tools over a data table's versioned SQL migrations (`datatable_migrations.rs`). + * + * A data table with migrations enabled keeps its schema as an ordered list of definitions, each + * applied once per database and recorded there. That list is what git sync and a workspace merge + * carry, so a schema change made with plain SQL exists in one database only. + */ + +const memo = <T>(factory: () => T): (() => T) => { + let cached: T | undefined + return () => (cached ??= factory()) +} + +function apiErrorMessage(e: unknown): string { + const err = e as { body?: unknown; message?: unknown } | undefined + const body = err?.body + if (typeof body === 'string' && body !== '') return body + if (typeof err?.message === 'string') return err.message + return String(e) +} + +function fail(toolCallbacks: ToolCallbacks, toolId: string, message: string): string { + toolCallbacks.setToolStatus(toolId, { content: message, error: message }) + return message +} + +const migrationLabel = (m: { version: number; name: string }) => `${m.version} (${m.name})` + +const datatableNameSchema = () => + z.string().describe('The datatable the migrations belong to, e.g. "main".') + +// ============= Tool definitions ============= + +const getListMigrationsSchema = memo(() => + z.object({ + datatable_name: datatableNameSchema(), + version: z + .number() + .optional() + .describe('Return the SQL of this one migration instead of the list.') + }) +) +const getListMigrationsToolDef = memo(() => + createToolDef( + getListMigrationsSchema(), + 'list_datatable_migrations', + "List a datatable's migrations in order, each with its version, name and whether it is applied or pending. Pass `version` to read one migration's SQL." + ) +) + +const getCreateMigrationSchema = memo(() => + z.object({ + datatable_name: datatableNameSchema(), + name: z + .string() + .regex(/^[A-Za-z0-9_-]+$/) + .describe( + 'Short name of the change, e.g. "add_refunds_table". Letters, digits, "_" and "-" only.' + ), + code_up: z + .string() + .describe( + 'The SQL that makes the change. Wrap the statements in `BEGIN; ... END;` so they apply atomically, unless one of them cannot run in a transaction.' + ), + code_down: z + .string() + .optional() + .describe( + 'The SQL that undoes `code_up`. Without it the migration cannot be rolled back, so provide it whenever the change is reversible.' + ), + role: z + .string() + .regex(/^[A-Za-z0-9_-]{1,63}$/) + .optional() + .describe( + 'The datatable role the migration runs as, on a datatable that has roles. Omit to run as admin, which only the datatable admins may do.' + ), + version: z + .number() + .optional() + .describe( + 'Rewrite this migration instead of adding one: only for a pending migration, typically the one that just failed to apply.' + ), + apply: z + .boolean() + .optional() + .describe('Set false to save the migration as pending without running it. Defaults to true.') + }) +) +const getCreateMigrationToolDef = memo(() => + createToolDef( + getCreateMigrationSchema(), + 'create_datatable_migration', + 'Change the schema of a datatable that has migrations enabled: saves the change as a new migration and applies it. One that fails to apply stays saved as pending: call this again with its `version` to fix it.' + ) +) + +const getRunMigrationsSchema = memo(() => z.object({ datatable_name: datatableNameSchema() })) +const getRunMigrationsToolDef = memo(() => + createToolDef( + getRunMigrationsSchema(), + 'run_datatable_migrations', + "Apply a datatable's pending migrations, oldest first. It stops at the first one that fails." + ) +) + +const getRollbackMigrationSchema = memo(() => + z.object({ + datatable_name: datatableNameSchema(), + version: z + .number() + .describe('The applied migration to undo, normally the most recent one, as listed.') + }) +) +const getRollbackMigrationToolDef = memo(() => + createToolDef( + getRollbackMigrationSchema(), + 'rollback_datatable_migration', + 'Undo one applied migration of a datatable by running its down SQL. The migration stays in the list as pending.' + ) +) + +// ============= Tools ============= + +export function getDatatableMigrationTools(): SessionTool<{}>[] { + return [ + { + requires: NONE, + def: getListMigrationsToolDef(), + planModeSafe: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const { datatable_name, version } = getListMigrationsSchema().parse(args) + toolCallbacks.setToolStatus(toolId, { + content: `Listing migrations of "${datatable_name}"...` + }) + try { + const status = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName: datatable_name + }) + if (!status.enabled) { + toolCallbacks.setToolStatus(toolId, { + content: `Migrations are not enabled on "${datatable_name}"` + }) + return `Migrations are not enabled on datatable "${datatable_name}": its schema is changed with plain SQL and there is no history to list. Enabling them is a workspace admin's decision, made in Workspace settings → Data Tables.` + } + if (version !== undefined) { + const migration = status.migrations.find((m) => m.timestamp === version) + if (!migration) { + return fail( + toolCallbacks, + toolId, + `No migration ${version} on datatable "${datatable_name}"` + ) + } + toolCallbacks.setToolStatus(toolId, { + content: `Read migration ${migrationLabel({ version, name: migration.name })}` + }) + return JSON.stringify( + { + version, + name: migration.name, + status: migrationStatus(migration.status), + code_up: migration.code_up, + ...(migration.code_down != null && { code_down: migration.code_down }) + }, + null, + 2 + ) + } + toolCallbacks.setToolStatus(toolId, { + content: `Listed ${status.migrations.length} migration(s) of "${datatable_name}"` + }) + return JSON.stringify( + { + migrations: status.migrations.map((m) => ({ + version: m.timestamp, + name: m.name, + status: migrationStatus(m.status) + })), + // Set when the database could not be read, which is why every status is unknown. + ...(status.error && { error: status.error }) + }, + null, + 2 + ) + } catch (e) { + return fail(toolCallbacks, toolId, `Error listing migrations: ${apiErrorMessage(e)}`) + } + } + }, + { + // `ensure_not_operator` in datatable_migrations.rs: the condition `run_preview` stands for. + requires: RUN_PREVIEW, + def: getCreateMigrationToolDef(), + requiresConfirmation: true, + confirmationMessage: (args) => + `${args?.version === undefined ? 'Create' : 'Rewrite'}${args?.apply === false ? '' : ' and apply'} data table migration`, + queuedLabel: (args) => `Write migration "${args?.name ?? ''}"`, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = getCreateMigrationSchema().parse(args) + const datatableName = parsed.datatable_name + toolCallbacks.setToolStatus(toolId, { + content: `Saving migration "${parsed.name}" on "${datatableName}"...` + }) + // The role is an annotation in the SQL, read from above everything else, and a rollback + // has to run as the role that created what it drops. + const definition = { + name: parsed.name, + code_up: withRole(parsed.code_up, parsed.role), + ...(parsed.code_down !== undefined && { + code_down: withRole(parsed.code_down, parsed.role) + }) + } + let version: number + try { + if (parsed.version === undefined) { + const created = await WorkspaceService.createDatatableMigration({ + workspace, + datatableName, + requestBody: definition + }) + version = created.timestamp + } else { + version = parsed.version + // The server inserts at a version it does not know, which would wedge a new + // migration into the middle of the history, and stores a down left out as none. + const { migrations } = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName + }) + const existing = migrations.find((m) => m.timestamp === version) + if (existing?.status !== 'not_run') { + return fail( + toolCallbacks, + toolId, + `Migration ${version} is not a pending migration of datatable "${datatableName}", so it cannot be rewritten. Omit \`version\` to add a new migration.` + ) + } + await WorkspaceService.upsertDatatableMigration({ + workspace, + datatableName, + requestBody: { + timestamp: version, + ...(existing.code_down != null && { code_down: existing.code_down }), + ...definition + } + }) + } + } catch (e) { + return fail(toolCallbacks, toolId, `Error saving migration: ${apiErrorMessage(e)}`) + } + const label = migrationLabel({ version, name: parsed.name }) + if (parsed.apply === false) { + toolCallbacks.setToolStatus(toolId, { content: `Saved migration ${label} as pending` }) + return `Saved migration ${label} as pending. It is not applied yet.` + } + + // Applying it alone would run it ahead of migrations it may depend on, and applying + // those with it would run SQL the user was never shown. + let earlierPending: string[] + try { + const status = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName + }) + const earlier = status.migrations.filter((m) => m.timestamp < version) + // Unreadable is not pending: reporting it so would send the user to apply + // migrations that already ran. + if (status.error || earlier.some((m) => m.status === 'unknown')) { + throw new Error(status.error ?? 'the applied migrations could not be read') + } + earlierPending = earlier + .filter((m) => m.status === 'not_run') + .map((m) => migrationLabel({ version: m.timestamp, name: m.name })) + } catch (e) { + return fail( + toolCallbacks, + toolId, + `Saved migration ${label} as pending, but could not check what else is pending, so it was not applied: ${apiErrorMessage(e)}` + ) + } + if (earlierPending.length > 0) { + toolCallbacks.setToolStatus(toolId, { + content: `Saved migration ${label}; ${earlierPending.length} earlier migration(s) still pending` + }) + return `Saved migration ${label} as pending without applying it: earlier migrations are not applied yet (${earlierPending.join(', ')}) and it may depend on them. Tell the user, then apply them all in order with run_datatable_migrations.` + } + + toolCallbacks.setToolStatus(toolId, { content: `Applying migration ${label}...` }) + try { + await WorkspaceService.runDatatableMigrations({ + workspace, + datatableName, + only: version + }) + } catch (e) { + // Kept whatever failed: part of it may be applied, or all of it with only the record + // missing, and the definition with its down is then the one trace of that. + return fail( + toolCallbacks, + toolId, + `Migration ${label} is saved but failed to apply: ${apiErrorMessage(e)}. It stays pending and blocks later migrations. If nothing of it was applied, fix it by calling this tool again with version ${version}; otherwise, or to drop it, tell the user, who can edit or delete it in Workspace settings → Data Tables.` + ) + } + toolCallbacks.setToolStatus(toolId, { content: `Applied migration ${label}` }) + return `Applied migration ${label} on datatable "${datatableName}".` + } + }, + { + requires: RUN_PREVIEW, + def: getRunMigrationsToolDef(), + requiresConfirmation: true, + confirmationMessage: 'Apply pending data table migrations', + queuedLabel: (args) => `Apply migrations on "${args?.datatable_name ?? 'datatable'}"`, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const { datatable_name } = getRunMigrationsSchema().parse(args) + toolCallbacks.setToolStatus(toolId, { + content: `Applying migrations on "${datatable_name}"...` + }) + try { + const { applied } = await WorkspaceService.runDatatableMigrations({ + workspace, + datatableName: datatable_name + }) + if (applied.length === 0) { + toolCallbacks.setToolStatus(toolId, { content: 'No pending migration to apply' }) + return `No pending migration to apply on datatable "${datatable_name}".` + } + toolCallbacks.setToolStatus(toolId, { + content: `Applied ${applied.length} migration(s) on "${datatable_name}"` + }) + return `Applied ${applied.length} migration(s) on datatable "${datatable_name}": ${applied.map(migrationLabel).join(', ')}.` + } catch (e) { + // Those before the failing one are applied: the list is what says where it stopped. + return fail( + toolCallbacks, + toolId, + `Error applying migrations: ${apiErrorMessage(e)}. Migrations before the failing one may have been applied: list them to see.` + ) + } + } + }, + { + requires: RUN_PREVIEW, + def: getRollbackMigrationToolDef(), + requiresConfirmation: true, + confirmationMessage: (args) => `Roll back data table migration ${args?.version ?? ''}`, + queuedLabel: (args) => `Roll back migration ${args?.version ?? ''}`, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const { datatable_name, version } = getRollbackMigrationSchema().parse(args) + toolCallbacks.setToolStatus(toolId, { + content: `Rolling back migration ${version} on "${datatable_name}"...` + }) + try { + const { rolled_back } = await WorkspaceService.rollbackDatatableMigrations({ + workspace, + datatableName: datatable_name, + only: version + }) + if (rolled_back.length === 0) { + toolCallbacks.setToolStatus(toolId, { content: `Migration ${version} is not applied` }) + return `Migration ${version} is not applied on datatable "${datatable_name}", so there is nothing to roll back.` + } + const label = rolled_back.map(migrationLabel).join(', ') + toolCallbacks.setToolStatus(toolId, { content: `Rolled back migration ${label}` }) + return `Rolled back migration ${label} on datatable "${datatable_name}". It is pending again.` + } catch (e) { + return fail(toolCallbacks, toolId, `Error rolling back: ${apiErrorMessage(e)}`) + } + } + } + ] +} + +function migrationStatus(status: 'ran' | 'not_run' | 'unknown'): 'applied' | 'pending' | 'unknown' { + return status === 'ran' ? 'applied' : status === 'not_run' ? 'pending' : 'unknown' +} + +function withRole(sql: string, role: string | undefined): string { + return role === undefined ? sql : withMigrationRole(sql, role) +} diff --git a/frontend/src/lib/components/copilot/chat/datatableTools.ts b/frontend/src/lib/components/copilot/chat/datatableTools.ts index 7a743ab783..e05b2e4325 100644 --- a/frontend/src/lib/components/copilot/chat/datatableTools.ts +++ b/frontend/src/lib/components/copilot/chat/datatableTools.ts @@ -286,6 +286,7 @@ export function getDatatableTools(): SessionTool<{}>[] { datatable_name: d.datatable_name, schemas: d.schemas, ...(d.error && { error: d.error }), + ...(d.migrations_enabled && { migrations_enabled: true }), ...(d.permissioned && { usable_roles: d.usable_roles, default_role: d.default_role }) })), null, diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index 2224d89b78..11cee5b724 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.825.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-<type>' (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.<step_id>'"}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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/<flow_path>-<step_id>"},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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/MemoryCompaction"},{"$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"},{"$ref":"#/components/schemas/AiDecision"}],"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.<its_own_id>' 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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"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.825.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-<type>' (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: the n-th retry waits multiplier × seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"},"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.<step_id>'"}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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/<flow_path>-<step_id>"},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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/MemoryCompaction"},{"$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"},{"$ref":"#/components/schemas/AiDecision"}],"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.<its_own_id>' 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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"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/flow/openFlowZod.gen.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts index 570c83f52d..88cb8fe875 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n").optional() }).describe("Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of 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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of 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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.enum(["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n").optional() }).describe("Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of 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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Array of 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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce<z.ZodError[]>( (errors, schema) => @@ -37,7 +37,7 @@ export const flowModuleSchema = z.object({ "id": z.string().describe("Unique ide message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"), "questions": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"), "questions": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.").optional(), "error_include_result": z.boolean().describe("When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") export const flowModulesSchema = z.array(flowModuleSchema) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 23a80c4026..c23161c215 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1,6 +1,7 @@ import { randomUUID } from '$lib/utils/uuid' import { AppService, + AssetService, AzureTriggerService, EmailTriggerService, FlowService, @@ -25,7 +26,7 @@ import { import { createTwoFilesPatch } from 'diff' import { deepEqual } from 'fast-equals' import { promptSafe, type ArtifactVersionTarget } from '$lib/components/sessions/previewRouter' -import { canWrite } from '$lib/utils' +import { canDraftItem, roleCanDraft } from '$lib/editRights' import { $ScriptLang } from '$lib/gen/schemas.gen' import type { AppWithLastVersion, @@ -54,6 +55,7 @@ import { evalValue } from '$lib/components/flows/utils.svelte' import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { + BUILDER_FRAMEWORK_TEMPLATES, FRAMEWORK_TEMPLATES, STARTER_RUNNABLES, type FrameworkKey @@ -67,12 +69,15 @@ import { stripFileArgs } from '$lib/components/job_args' import { processSecretArgs } from '$lib/components/secretArgUtils' +import { parsePipelineAnnotations } from '$lib/components/assets/AssetGraph/parsePipelineAnnotations' import { PLAN_MODE_MESSAGES } from '../planModeMessages' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA, formatDataTableRef } from '$lib/components/raw_apps/dataTableRefUtils' import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue' +import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy' +import { operatorBuilderAppsIn } from '$lib/operatorWriteRights' import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom' import { dataUrlToImagePart, normalizeImageDataUrl, type AttachedImage } from '../imageUtils' import { sanitizeAttachmentName, textLineCount, type AttachedTextFile } from '../textFileUtils' @@ -117,6 +122,7 @@ import { } from '../skills/skillResources' import { MAX_SKILL_DESCRIPTION_LENGTH, MAX_SKILL_INSTRUCTIONS_LENGTH } from '../skills/skillMd' import { + getDatatablePrompt, getDatatableSdkReference, getFlowPrompt, getPipelinePrompt, @@ -157,6 +163,7 @@ import { searchDocsTool, readDocsPageTool } from '../docs/core' import { createDbSchemaTool } from '../script/core' import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' +import { getDatatableMigrationTools } from '../datatableMigrationTools' import { getDucklakeTools } from '../ducklakeTools' import { fileTools } from '../files/fileTools' import type { AttachedFilesStore } from '../files/attachedFiles.svelte' @@ -210,14 +217,20 @@ import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import type { UserDraftItemKind } from '$lib/gen' import { bundleRawAppDraft } from './rawAppBundlerBridge' import { + canOpenEditor, DEPLOY, NONE, + RUN_FLOW_PREVIEW, RUN_PREVIEW, + WRITE_APP_DRAFT, WRITE_DRAFT, + WRITE_FLOW_DRAFT, type SessionAccess, type SessionTool, type SessionToolPolicy } from '../sessionCapabilities' +import { OPERATOR_SETTINGS_LOCATION } from '../assistantPermissions' +import { capitalize } from '$lib/utils/capitalize' import { buildRunsUrl, buildSchedulesUrl, @@ -239,7 +252,6 @@ import { parseItemsMaskParam } from '$lib/components/sessions/modifiedItemsMask' import { - pageHref, TRIGGER_PAGES, type TriggerKind as PageTriggerKind } from '$lib/components/sessions/previewRouter' @@ -329,7 +341,7 @@ export type GlobalActiveEditorContext = { export type GlobalActivePreviewContext = { /** Page name as the tab strip shows it, e.g. "Schedules". */ label: string - /** Base-stripped page path plus the request params the page declares — values kept + /** Page path plus the request params the page declares — values kept * only for the ones addressing a workspace object, and percent-encoded. Never a raw * location: a tab can host a legacy app whose hash is app state, and a filter value * can be free text the user typed. Build it with `previewLocationContext`. */ @@ -357,7 +369,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum) const getInstructionsSchema = z.object({ subject: instructionSubjectSchema.describe( - 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' + 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' ), language: scriptLangSchema .optional() @@ -423,6 +435,8 @@ const updateUserInstructionsSchema = z.object({ ) }) +const listPipelinesSchema = z.object({}) + const listWorkspaceItemsSchema = z.object({ types: z .array(itemTypeSchema) @@ -759,7 +773,7 @@ const listRunsSchema = z.object({ // superadmin caller; every other caller gets the hedge even where nothing is hidden. const NO_WORKERS_VISIBLE_MESSAGE = 'No workers came back. This does NOT establish that no workers are running: an instance can hide workers from callers without the devops role, and it does so by returning an empty list rather than an error. ' + - 'Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves. Never state that no workers are online or that the instance has none.' + 'Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves if they can open it, or ask a workspace admin otherwise. Never state that no workers are online or that the instance has none.' const NO_WORKERS_CONNECTED_MESSAGE = 'No workers are connected to this instance (none pinged in the last 5 minutes). Queued runs will stay queued until a worker starts.' const WORKER_PAGE_SIZE = 100 @@ -1040,6 +1054,12 @@ const backendRunnableSchema = z .optional() .describe( 'Required when type is "script", "flow", or "hubscript". Workspace path of the runnable.' + ), + viewerDataAccess: z + .boolean() + .optional() + .describe( + "Inline only. When true, the runnable reads data as the person viewing the app instead of as the app's identity: data table connections that name no role hold the viewer's roles, and object storage permission rules are checked against the viewer, so row-level security and per-user storage paths answer per viewer. Resources and variables stay the app's. Anonymous viewers are refused. Omit to keep the current setting." ) }) .describe( @@ -1180,7 +1200,14 @@ const openPreviewSchema = z.object({ * it can open, so it is not offered. Per-PATH write permission is unknowable here — * a workspace capability says nothing about `f/finance/*` — and is resolved in the * handler instead. Mirrors `open_page` re-narrowing its page enum per user. */ +const openPreviewNoPipelineKind = z + .enum(['script', 'flow', 'raw_app']) + .describe( + 'Item kind to preview. Use "raw_app" for code-based apps. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' + ) + const openPreviewViewOnlySchema = openPreviewSchema.extend({ + kind: openPreviewNoPipelineKind, mode: z .literal('view') .optional() @@ -1189,6 +1216,21 @@ const openPreviewViewOnlySchema = openPreviewSchema.extend({ ) }) +/** A builder operator: the editors of the kinds they compose, and the deployed page for + * everything else. */ +const openPreviewBuilderEditorSchema = (flows: boolean, apps: boolean) => + openPreviewSchema.extend({ + kind: openPreviewNoPipelineKind, + mode: z + .enum(['edit', 'view']) + .optional() + .describe( + flows && apps + ? 'Which side to show. "edit" opens the flow or app editor and works for flows and raw apps only: the only editors you can open in this workspace. Pass "view" for a script, the deployed page with its run form, triggers and past runs.' + : `Which side to show. "edit" opens the ${flows ? 'flow' : 'app'} editor and works for ${flows ? 'flows' : 'raw apps'} only: the only editor you can open in this workspace. Pass "view" for a script or ${flows ? 'app' : 'flow'}, the deployed page with its run form, triggers and past runs.` + ) + }) + const OPEN_PREVIEW_DESCRIPTION = 'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.' @@ -1280,12 +1322,7 @@ const readDomSchema = z.object({ const takeScreenshotSchema = z.object({}) -const FRAMEWORK_KEYS = [ - 'react19', - 'react18', - 'svelte5', - 'vue' -] as const satisfies readonly FrameworkKey[] +const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5'] as const satisfies readonly FrameworkKey[] const initAppSchema = z.object({ path: z @@ -1316,6 +1353,8 @@ const initAppSchema = z.object({ ) }) +const INIT_APP_DESCRIPTION = 'Initialize a draft raw app from a framework template.' + // Mirrors the backend VALID_FOLDER_NAME check so an invalid name fails before the // network round-trip (the server enforces the same rule). const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/ @@ -1392,6 +1431,9 @@ function buildFolderGuidance( return lines.join('\n') } +const listJoin = (items: (string | false)[]) => + new Intl.ListFormat('en', { type: 'conjunction' }).format(items.filter((i): i is string => !!i)) + const buildGlobalSystemPrompt = ( username: string, previewTools: boolean, @@ -1403,7 +1445,20 @@ const buildGlobalSystemPrompt = ( // Each `can*` mirrors the capability the matching tools declare in `requires`, so a // rule cannot outlive the tool it describes. An unresolved profile keeps every block. const canWriteDraft = !access || access.has('write_draft') + const canWriteFlowDraft = !access || access.has('write_flow_draft') + const canWriteAppDraft = !access || access.has('write_app_draft') + // A builder operator: drafts of the kinds the workspace lets them compose, and nothing else. + // Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the + // other kinds keep `canWriteDraft`. + const buildsFlows = canWriteFlowDraft && !canWriteDraft + const buildsApps = canWriteAppDraft && !canWriteDraft + const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft || canWriteAppDraft + const refusedCode = ['scripts', !buildsFlows && 'flows', !buildsApps && 'apps'].filter( + (k): k is string => !!k + ) const canRunPreview = !access || access.has('run_preview') + const canManageSchedules = !access || access.has('manage_schedules') + const canManageTriggers = !access || access.has('manage_triggers') // The deploy tools take their kind as an argument, so `deploy` gates only the folder text. const canCreateFolder = !access || access.has('deploy') // Each gated block carries its own leading newline, so dropping one leaves no blank @@ -1424,7 +1479,17 @@ const buildGlobalSystemPrompt = ( const activePreviewRule = previewTools ? '\n- If the user message includes an ACTIVE PREVIEW section, that is the page the side panel is showing — resolve "this page", "here" and "it" against it, and against `open` (the item the user has open in its editor) when there is one. It already tells you what get_preview_status would, so do not call that tool to learn what is on screen; call it only to check the panel\'s *other* tabs.' : '' - const pipelineBullet = `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on <ref>\` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow.${pipelineAlphaNote}` + const pipelineBullet = `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on <ref>\` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow. list_pipelines lists the existing ones by folder.${pipelineAlphaNote}` + // Roles need a self-hosted Enterprise Edition. The edition is knowable only in the browser, + // so elsewhere the guidance stays and nothing is claimed about the instance. + const datatableRolesAvailable = !isCloudHosted() && (!BROWSER || !!get(enterpriseLicense)) + const datatableRolesBullet = datatableRolesAvailable + ? ` +- A datatable can be under roles; list_datatables then reports its \`usable_roles\` and \`default_role\`. When working on a raw app that pins a role (\`data.roles\` in its raw_app.yaml), pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can. For anything else about roles, grants or a \`permission denied\`, call get_instructions with subject "datatable".` + : BROWSER + ? ` +- Datatable roles and permissions are not available on this instance (they need a self-hosted Enterprise Edition): everyone who can use a datatable reaches all of it, so do not promise per-user, per-schema or per-row access on one.` + : '' // Hosting and edition come from the hostname and a store the app populates at init, so // they are knowable only in the browser: a non-browser caller reads false for both and // would be told "self-hosted Community Edition" whatever it targets. No base URL here @@ -1444,9 +1509,18 @@ The current user's workspace username is "${username}".${instanceLine} ${ canWriteDraft ? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.' - : "Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit scripts, flows, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused scripts, flows and apps outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor." + : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${buildsFlows ? '' : 'flows, '}scripts, ${buildsApps ? '' : 'apps, '}schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor. ${capitalize(listJoin(['resources', 'variables', canManageSchedules && 'schedules', canManageTriggers && 'triggers']))} they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( + canManageSchedules, + ' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.' + )} When you decline something, give the reason stated here and keep to it.${when( + buildsFlows, + '\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.' + )}${when( + buildsApps, + `\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.` + )}` }${when( - canWriteDraft, + canWriteAnyDraft, ` Path conventions: @@ -1459,25 +1533,36 @@ Path conventions: '; create one with `create_folder` only when the user explicitly asks for a new folder' )}.${folderGuidanceBlock}` )}${when( - canCreateFolder && !canWriteDraft, + canCreateFolder && !canWriteAnyDraft, '\n- You can create a shared folder with `create_folder` when the user explicitly asks for one. You cannot create anything inside it here, so do not offer to.' )} Rules:${when( - canWriteDraft, + canWriteAnyDraft, ` - Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` )} - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. +- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed. - If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when( - canWriteDraft, + canWriteAnyDraft, ` - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. - To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.` )}${when( - !canWriteDraft, + !canWriteAnyDraft, ` -- Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace. You cannot create or edit one.` +- Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace — each only for the kinds its type lists. You cannot create or edit one.` + )}${when( + !canManageSchedules || !canManageTriggers, + ` +- This workspace does not let this user change ${ + !canManageSchedules && !canManageTriggers + ? 'schedules or triggers' + : !canManageSchedules + ? 'schedules' + : 'triggers' + }, here or in the editor, so never ask them to create or edit one: suggest asking a workspace admin.` )} - Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.${when( canWriteDraft, @@ -1495,6 +1580,10 @@ ${pipelineBullet}` ` - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. - Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.` + )}${when( + buildsFlows && (!access || access.has('run_flow_preview')), + ` +- After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.` )} - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run. - get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars). @@ -1519,12 +1608,18 @@ ${pipelineBullet}` ? `${when( !canWriteDraft, ` -- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. You cannot open item editors in this workspace, so it only ever opens the deployed page.` +- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${ + buildsFlows && buildsApps + ? 'The flow and app editors are the only editors you can open here: after writing or editing a flow or app draft, show it with open_preview(kind="flow" or "raw_app", path, mode="edit").' + : buildsFlows || buildsApps + ? `The ${buildsFlows ? 'flow' : 'app'} editor is the only editor you can open here: after writing or editing ${buildsFlows ? 'a flow' : 'an app'} draft, show it with open_preview(kind="${buildsFlows ? 'flow' : 'raw_app'}", path, mode="edit").` + : 'You cannot open item editors in this workspace, so it only ever opens the deployed page.' + }` )}${when( canWriteDraft, ` - After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited. -- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.` +- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it adds build_pipeline_node / edit_pipeline_node to your tools from the next step on — they are not in your tool list before open_preview returns, so call open_preview on its own, wait for its result, then build. They then stay available for the rest of the session even while another tab (a page, another preview) is shown: a call brings the pipeline editor back by itself, so do not re-open it first. Use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.` )} - When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app"). - Writing an app file does not compile it: the open preview rebuilds it afterwards. After editing a raw app's frontend files with its preview open, call get_app_runtime_logs to check the build — when it failed, it returns the build errors (e.g. syntax or import errors) and bundler logs to fix. @@ -1551,7 +1646,7 @@ Documentation: Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>". - Use read_flow_module_code${when(canWriteDraft, ' and set_flow_module_code')} for inline script bodies.${when( - canWriteDraft, + canWriteFlowDraft, ` - Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.` )} @@ -1559,12 +1654,16 @@ Flows: Raw apps: - The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app. - read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when( - canWriteDraft, + canWriteAppDraft, ` - A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings. - Use write_app_file, patch_app_file, and delete_app_file for frontend files. - Use write_app_runnable and delete_app_runnable for backend runnables. -- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app's data. +- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${ + buildsApps + ? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.' + : 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.' + } - Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.` )} @@ -1574,13 +1673,9 @@ Data Tables: - Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.${when( canRunPreview, ` -- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.` - )}${ - isCloudHosted() - ? '' - : ` -- A raw app may use a datatable through a role (\`data.roles\` in its raw_app.yaml). When working on such an app, pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can.` - }${when( +- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step. +- A datatable that list_datatables reports with \`migrations_enabled\` keeps its schema as versioned migrations: change its schema with create_datatable_migration, never exec_datatable_sql. list_datatable_migrations reads them, run_datatable_migrations applies what is pending, rollback_datatable_migration undoes one. A migration is applied for real, not drafted, so call get_instructions with subject "datatable" before your first one.` + )}${datatableRolesBullet}${when( canWriteDraft, ` - When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.` @@ -1765,6 +1860,7 @@ function convertPersistedToBackendRunnable( content: persisted.inlineScript.content ?? '' } } + if (persisted.viewer_data_access === true) out.viewerDataAccess = true } else if (persisted.type === 'path' || persisted.type === 'runnableByPath') { if (persisted.runType === 'flow') out.type = 'flow' else if (persisted.runType === 'hubscript') out.type = 'hubscript' @@ -1802,7 +1898,14 @@ function buildPersistedRunnable( // `type` (isRunnableByName / isRunnableByPath), so a leftover does not change which code // runs — it contradicts it: a runnable reported back as inline still names a flow in // `path`, and that is what the model reads on its next turn. - const { runType: _runType, path: _path, inlineScript: _inlineScript, ...carried } = existing ?? {} + const { + runType: _runType, + path: _path, + inlineScript: _inlineScript, + viewer_data_access: carriedAsViewer, + ...carried + }: Partial<PersistedRunnable> = existing ?? {} + const viewerDataAccess = input.viewerDataAccess ?? carriedAsViewer === true // `schema` describes the item a path runnable points at, so it only survives while that // target is unchanged. Carried across a retarget it types `backend.<key>(args)` from the @@ -1824,6 +1927,7 @@ function buildPersistedRunnable( content: input.inlineScript.content, language: input.inlineScript.language }, + ...(viewerDataAccess && { viewer_data_access: true }), fields } } @@ -2016,6 +2120,11 @@ async function saveAppDraft( path: string, value: AppDraftValue ): Promise<DraftPersistResult> { + if (operatorBuilderAppsIn(workspace)) { + const role = await roleForWorkspace(workspace) + value.policy ??= {} + conformBuilderAppPolicy(value.policy, role.kind === 'resolved' ? role.user : undefined) + } return saveGlobalAppDraft(workspace, path, value) } @@ -2379,7 +2488,10 @@ function getScriptInstructions(language: ScriptLang | undefined): string { ${getScriptPrompt(selected)}` } -async function getFlowInstructions(workspace: string | undefined): Promise<string> { +async function getFlowInstructions( + workspace: string | undefined, + composeOnly: boolean +): Promise<string> { const aiAgentProviders = formatAiAgentProvidersPrompt( await getAiAgentProviderCatalog(workspace), { @@ -2395,15 +2507,22 @@ async function getFlowInstructions(workspace: string | undefined): Promise<strin - Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with \`patch_flow_json\`: ${FLOW_VALUE_SETTINGS_KEYS.join(', ')}. For example, \`chat_input_enabled: true\` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow. - \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`. - Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it. -- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed. -- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first. +${ + composeOnly + ? '- Compose only path modules (`script` / `flow`) that reference items already deployed in the workspace. Rawscript modules and hub scripts are refused for this user.' + : `- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed. +- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first.` +} ## Groups and notes - Add \`groups\` to any non-trivial flow without being asked; "Organizing Flows: Groups and Notes" in the reference below gives the fields and the rules the editor enforces. Pass \`null\` to \`write_flow\` to clear them, and the same for \`notes\`. - With \`patch_flow_json\`, edit \`groups\` and \`notes\` the same way as any other field — they appear as top-level keys in the compact flow value. -## Compact view: how rawscript bodies surface in tool I/O +${ + composeOnly + ? '' + : `## Compact view: how rawscript bodies surface in tool I/O - \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script.<moduleId>"\` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view. - Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools: @@ -2414,15 +2533,16 @@ async function getFlowInstructions(workspace: string | undefined): Promise<strin - \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments are **non-compact** flow modules: inline each rawscript body directly in \`content\` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a \`write_flow\` call comes back with a JSON parse error — create that module with **empty content** (\`"content": ""\`) and fill it with \`set_flow_module_code(path, module_id, code)\`, whose \`code\` is a plain argument with no nested escaping. \`write_flow\` reports which modules still have empty bodies so you know what to fill. - When overwriting an **existing** flow, set \`"content": "inline_script.<moduleId>"\` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected. -${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''} +` +}${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''} # Windmill flow authoring reference -${getFlowPrompt()}` +${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline code (rawscript steps, inline AI agent tools) and hub scripts: the server refuses them. Compose only path steps that reference deployed scripts and flows.\n\n' : ''}${getFlowPrompt()}` } type InstructionSubject = (typeof ALL_INSTRUCTION_SUBJECTS)[number] -function getAppInstructions(language?: ScriptLang): string { +function getAppInstructions(language: ScriptLang | undefined, composeOnly: boolean): string { // getRawAppPrompt swaps in the Python SDK for python3, so the pointer has to swap with // it: telling the model to re-fetch a reference it is already holding wastes a turn, and // naming the wrong SDK contradicts the text right below the sentence. @@ -2434,21 +2554,40 @@ function getAppInstructions(language?: ScriptLang): string { - Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy. - App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate. -- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing. -- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables. +- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`)${ + composeOnly + ? '. "Starting a new app" below decides the framework. No data setup: the app reads and stores data only through the deployed runnables it calls.' + : ' and, for an app that stores data, its `data` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with `askUserQuestion`, one question per call with the suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool.' + } \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing. +${ + composeOnly + ? '- `init_app` starts from a static page with no backend runnables.' + : `- \`init_app\` seeds the starter inline runnables ${Object.keys(STARTER_RUNNABLES) + .map((k) => `\`${k}\``) + .join( + ', ' + )} so the demo tour works on first render. Replace or remove them once you start building real backend runnables.` +} - Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`. -- Backend inline runnables are addressed as \`backend/<key>/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. +${ + composeOnly + ? '- Every backend runnable is a path runnable (`script` / `flow`) referencing an item already deployed in the workspace, created with `write_app_runnable` and removed with `delete_app_runnable`. Inline and hub runnables are refused for this user.' + : `- Backend inline runnables are addressed as \`backend/<key>/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.${ + composeOnly + ? '' + : ` +- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.` + } - 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. +${composeOnly ? '' : `${sdkLine}\n`}- 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\`). # Windmill raw app authoring reference -${getRawAppPrompt(language)}` +${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline runnables, hub scripts and the data setup: the server refuses inline and hub runnables. Every runnable is a path runnable referencing a deployed script or flow.\n\n' : ''}${getRawAppPrompt(language)}` } function getResourceInstructions(): string { @@ -2471,7 +2610,9 @@ ${getResourcePrompt()}` function getDatatableInstructions(language?: ScriptLang): string { // Default to the TypeScript SDK so we return only what's needed, not both. const lang = language ?? 'bun' - return `# Datatable SQL SDK reference + return `${getDatatablePrompt()} + +# Datatable SQL SDK reference Datatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime. @@ -2488,17 +2629,18 @@ function getPipelineInstructions(): string { function getInstructions( subject: InstructionSubject, language: ScriptLang | undefined, - workspace: string | undefined + workspace: string | undefined, + composeOnly: boolean ): string | Promise<string> { switch (subject) { case 'script': return getScriptInstructions(language) case 'flow': - return getFlowInstructions(workspace) + return getFlowInstructions(workspace, composeOnly) case 'resource': return getResourceInstructions() case 'app': - return getAppInstructions(language) + return getAppInstructions(language, composeOnly) case 'datatable': return getDatatableInstructions(language) case 'pipeline': @@ -2543,8 +2685,17 @@ export function getSessionContextPromptSection( // Concatenated onto an already capability-gated prompt, so it has to honour the same // profile rather than assume the gating happened upstream. const canDeploy = !access || access.has('deploy') - const canWriteDraft = !access || access.has('write_draft') - const canRunPreview = !access || access.has('run_preview') + const canWriteDraft = + !access || + access.has('write_draft') || + access.has('write_flow_draft') || + access.has('write_app_draft') + const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview') + // The kinds no deploy rule reaches, unless the workspace withdrew them from this user. + const unruledKinds = [ + (!access || access.has('manage_schedules')) && 'schedules', + (!access || access.has('manage_triggers')) && 'triggers' + ].filter((k): k is string => !!k) const targets = ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys'] .filter(Boolean) .join(', ') @@ -2583,9 +2734,20 @@ export function getSessionContextPromptSection( // promotes the rest instead. The rules gate deletes too, so it is owed to every such profile. if (!canDeploy) { lines.push( - "- This workspace refuses direct deployment for this user, except for schedules and triggers — those are the only kinds deploy_workspace_item and delete_workspace_item can still act on. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly." + `- This workspace refuses direct deployment for this user${ + unruledKinds.length === 2 + ? ', except for schedules and triggers — those are the only kinds deploy_workspace_item and delete_workspace_item can still act on' + : unruledKinds.length === 1 + ? `, except for ${unruledKinds[0]} — the only kind deploy_workspace_item and delete_workspace_item can still act on` + : '' + }. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly.` ) } + lines.push( + '- Sessions are saved: the sidebar lists them under AI Sessions, and the user can reopen any of them.', + '- The composer has a permission mode picker: Ask permission, Auto-accept edits, Yolo (bypass permissions: tool confirmations are accepted without asking) and Plan (read-only). Some modes are hidden where they do not apply.', + "- The sliders button next to the model picker opens Assistant settings. Its Permissions section shows what the user's role lets them change and what you can change for them; point them there when they ask what they are allowed to do." + ) return lines.join('\n') } @@ -2719,7 +2881,7 @@ function allowedTriggerKinds(): PageTriggerKind[] { } // Pages an operator may reach: exactly the ones enabled in the workspace's -// operator_settings, the same source OperatorMenu gates on. Operators are never admins, so +// operator_settings, the same source the sidebar gates on. Operators are never admins, so // workspace_settings is excluded whatever the settings say. function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] { const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings @@ -3390,7 +3552,7 @@ export const openPageTool: SessionTool<{}> = { // is no active session, in which case we offer a link chip instead. const previewResult = openPagePreview({ sessionId: sessionIdFromCtx(ctx), - href: pageHref(url), + href: url, label: pageLabel, newTab: parsed.new_tab ?? false }) @@ -3418,7 +3580,7 @@ export const globalTools: SessionTool<{}>[] = [ def: createToolDef( getInstructionsSchema, 'get_instructions', - 'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or the datatable SQL SDK (wmill.datatable()) used inside runnables.' + 'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.' ), planModeSafe: true, fn: async (ctx) => { @@ -3427,9 +3589,19 @@ export const globalTools: SessionTool<{}>[] = [ // Every subject is authoring guidance written around the draft tools by name, and a // tool result is the one place the toolset filter cannot reach. const access = (ctx.helpers as GlobalToolHelpers | undefined)?.access - if (access && !access.has('write_draft')) { - const message = - 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' + const canAuthor = + !access || + access.has('write_draft') || + (parsed.subject === 'flow' && access.has('write_flow_draft')) || + (parsed.subject === 'app' && access.has('write_app_draft')) + if (!canAuthor) { + const composed = [ + access?.has('write_flow_draft') && 'flow', + access?.has('write_app_draft') && 'app' + ].filter((k): k is string => !!k) + const message = composed.length + ? `This session can only compose ${composed.map((k) => `${k}s`).join(' and ')}, so ${composed.join(' and ')} ${composed.length > 1 ? 'are the subjects' : 'is the one subject'} with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.` + : 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' }) return message } @@ -3438,7 +3610,10 @@ export const globalTools: SessionTool<{}>[] = [ ? `${parsed.subject} (${parsed.language})` : parsed.subject toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` }) - return getInstructions(parsed.subject, parsed.language, ctx.workspace) + // Past the gate without `write_draft` means a builder: guidance for inline code would + // steer the model at writes the server refuses and tools it was not given. + const composeOnly = !!access && !access.has('write_draft') + return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly) } }, createSearchHubScriptsTool(false), @@ -3654,6 +3829,23 @@ export const globalTools: SessionTool<{}>[] = [ return JSON.stringify(results, null, 2) } }, + { + requires: NONE, + def: createToolDef( + listPipelinesSchema, + 'list_pipelines', + 'List the data pipelines of the workspace: each folder holding at least one pipeline script, with its number of scripts. A pipeline is named by its folder, not by an item path.' + ), + planModeSafe: true, + fn: async ({ workspace, toolId, toolCallbacks }) => { + toolCallbacks.setToolStatus(toolId, { content: 'Listing data pipelines...' }) + const pipelines = await AssetService.listPipelineFolders({ workspace }) + toolCallbacks.setToolStatus(toolId, { + content: `Listed ${pipelines.length} data pipeline(s)` + }) + return JSON.stringify(pipelines) + } + }, { requires: NONE, def: createToolDef( @@ -3760,7 +3952,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_FLOW_DRAFT, def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a draft flow.'), showDetails: true, streamArguments: true, @@ -3894,7 +4086,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_FLOW_DRAFT, def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', @@ -3944,7 +4136,7 @@ export const globalTools: SessionTool<{}>[] = [ autoCollapseDetails: false }, { - requires: RUN_PREVIEW, + requires: RUN_FLOW_PREVIEW, def: testRunFlowToolDef, fn: async (ctx) => { const parsed = testRunFlowSchema.parse(ctx.args) @@ -4124,16 +4316,17 @@ export const globalTools: SessionTool<{}>[] = [ // turns on the kind. Each kind's handler, reached through `deployDraft`'s switch: requires: NONE, kindRequires: { - // scripts.rs `create_script_internal`, flows.rs `create_flow`/`update_flow` and - // apps.rs `create_app_raw`/`update_app_raw` refuse operators; all run the rules. + // scripts.rs `create_script_internal` refuses operators, flows.rs `create_flow`/ + // `update_flow` and apps.rs `create_app_raw`/`update_app_raw` those without the + // matching builder right; all run the rules. script: ['deploy', 'manage_code'], - flow: ['deploy', 'manage_code'], - app: ['deploy', 'manage_code'], + flow: ['deploy', 'manage_flows'], + app: ['deploy', 'manage_apps'], resource: DEPLOY, variable: DEPLOY, - // The schedule and trigger handlers check neither. - schedule: NONE, - trigger: NONE + // No deploy rules here, but `gate_operator_writes` on their routers. + schedule: ['manage_schedules'], + trigger: ['manage_triggers'] } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deployWorkspaceItemSchema, @@ -4151,8 +4344,17 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - // Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies. - requires: WRITE_DRAFT, + // Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies per kind. + requires: NONE, + kindRequires: { + script: WRITE_DRAFT, + flow: WRITE_FLOW_DRAFT, + app: WRITE_APP_DRAFT, + resource: WRITE_DRAFT, + variable: WRITE_DRAFT, + schedule: WRITE_DRAFT, + trigger: WRITE_DRAFT + } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( rebaseDraftSchema, 'rebase_draft', @@ -4197,13 +4399,14 @@ export const globalTools: SessionTool<{}>[] = [ kindRequires: { // scripts.rs `delete_script_by_path` calls `require_admin`; a non-admin archives. script: ['admin'], - // flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators. - flow: ['deploy', 'manage_code'], - app: ['deploy', 'manage_code'], + // flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators without + // the matching builder right. + flow: ['deploy', 'manage_flows'], + app: ['deploy', 'manage_apps'], resource: DEPLOY, variable: DEPLOY, - schedule: NONE, - trigger: NONE + schedule: ['manage_schedules'], + trigger: ['manage_triggers'] } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deleteWorkspaceItemSchema, @@ -4291,7 +4494,7 @@ export const globalTools: SessionTool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: `Found ${results.length} resource type(s) for "${parsed.query}"` }) - return JSON.stringify( + const listing = JSON.stringify( results.map((rt) => ({ name: rt.name, schema: rt.schema @@ -4299,6 +4502,15 @@ export const globalTools: SessionTool<{}>[] = [ null, 2 ) + // The search is semantic and always returns its closest types, so a miss reads + // like a hit unless the absence is spelled out. + const words = (parsed.query.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter( + (w) => w.length >= 3 + ) + const named = results.some((rt) => words.some((w) => rt.name.toLowerCase().includes(w))) + return named || words.length === 0 + ? listing + : `${listing}\n\nNo resource type name contains "${parsed.query}": these are only the nearest matches, so no dedicated type exists. Searching again with synonyms will not find one — use a fitting generic type above, or create a custom c_<name> type.` } }, createDbSchemaTool<{}>({ @@ -4335,13 +4547,15 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, - def: createToolDef( - initAppSchema, - 'init_app', - 'Initialize a draft raw app from a framework template.', - { strict: false } - ), + requires: WRITE_APP_DRAFT, + def: createToolDef(initAppSchema, 'init_app', INIT_APP_DESCRIPTION, { strict: false }), + // A builder has no data setup: it needs SQL and inline runnables. + schemaFor: async (helpers) => { + const access = (helpers as GlobalToolHelpers | undefined)?.access + const schema = + access && !access.has('write_draft') ? initAppSchema.omit({ data: true }) : initAppSchema + return createToolDef(schema, 'init_app', INIT_APP_DESCRIPTION, { strict: false }) + }, showDetails: true, showFade: true, fn: async (ctx) => { @@ -4376,7 +4590,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( writeAppFileSchema, 'write_app_file', @@ -4391,7 +4605,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( deleteAppFileSchema, 'delete_app_file', @@ -4403,7 +4617,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( patchAppFileSchema, 'patch_app_file', @@ -4418,7 +4632,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( writeAppRunnableSchema, 'write_app_runnable', @@ -4434,7 +4648,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( deleteAppRunnableSchema, 'delete_app_runnable', @@ -4465,17 +4679,26 @@ export const globalTools: SessionTool<{}>[] = [ { requires: NONE, def: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION), - // Withhold the editor side from a session that cannot write drafts, so the model - // never offers the user a panel it would not get. + // Withhold the editors a session cannot save, so the model never offers the user a + // panel it would not get. schemaFor: async (helpers) => { const access = (helpers as GlobalToolHelpers | undefined)?.access - return access && !access.has('write_draft') + if (canOpenEditor(access, 'script')) { + return createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION) + } + const flows = canOpenEditor(access, 'flow') + const apps = canOpenEditor(access, 'raw_app') + return flows || apps ? createToolDef( + openPreviewBuilderEditorSchema(flows, apps), + 'open_preview', + OPEN_PREVIEW_DESCRIPTION + ) + : createToolDef( openPreviewViewOnlySchema, 'open_preview', OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION ) - : createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION) }, fn: async (ctx) => { const parsed = openPreviewSchema.parse(ctx.args) @@ -4663,6 +4886,7 @@ export const globalTools: SessionTool<{}>[] = [ }, // Workspace-scoped datatable tools (unrestricted: no whitelist, no creation policy) ...getDatatableTools(), + ...getDatatableMigrationTools(), // Workspace DuckLake: pipeline storage prerequisite, and declared measures ...getDucklakeTools(), // Read-only tools over files the user attached to the conversation @@ -4811,22 +5035,25 @@ async function openSessionPreview( if (!openPreviewHandler) { return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.' } - // A pipeline has only an editor, and its own capability gates already cover it. + // A pipeline has only an editor, so a session that cannot save its nodes has nothing to open. if (args.kind === 'pipeline') { + if (!canOpenEditor(access, 'pipeline')) { + return 'Error: you cannot open the pipeline editor in this workspace: this user cannot save drafts. Show its scripts with open_preview mode "view" instead.' + } return await openPreviewHandler({ ...args, mode: undefined, sessionId }) } // Resolved before the gates, not after: the handler reads a missing mode as the editor, so // gating on `args.mode === 'edit'` would wave through every call that simply omitted it — // which is what a model whose schema only offers 'view' does. const mode: 'edit' | 'view' = args.mode ?? 'edit' - // The advertised schema already withholds 'edit' from a session that cannot write - // drafts; re-check so a model asking outside the enum can't act outside it either. - if (mode === 'edit' && access && !access.has('write_draft')) { + // The advertised schema already narrows 'edit' to what this session can save; re-check so + // a model asking outside the enum can't act outside it either. + if (mode === 'edit' && !canOpenEditor(access, args.kind)) { const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId }) - return `${opened}\nOpened the deployed page instead: you cannot open item editors in this workspace.` + return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'raw_app' ? 'app' : args.kind} editors in this workspace.` } - // `write_draft` is a workspace capability and says nothing about this path, so an - // edit that survived it is still checked against the item's own permissions. + // The draft capability is workspace-wide and says nothing about this path, so an edit + // that survived it is still checked against the item's own permissions. if (mode === 'edit' && workspace) { const verdict = await canEditItemPath(workspace, args.kind, args.path) if (verdict !== 'allowed') { @@ -4854,10 +5081,12 @@ async function canEditItemPath( const role = await roleForWorkspace(workspace) if (role.kind === 'not_a_member') return 'denied' if (role.kind !== 'resolved' || !role.user) return 'unverified' - if (role.user.operator) return 'denied' + const user = role.user + const settings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings + if (!roleCanDraft(kind, user, settings)) return 'denied' // Folder and ownership rules answer most calls without a request. An item's own // `extra_perms` can only widen them, so the item is fetched only to overturn a denial. - if (canWrite(path, {}, role.user)) return 'allowed' + if (canDraftItem(kind, path, {}, user, settings)) return 'allowed' try { const extraPerms = kind === 'script' @@ -4865,7 +5094,7 @@ async function canEditItemPath( : kind === 'flow' ? (await FlowService.getFlowByPath({ workspace, path })).extra_perms : (await AppService.getAppByPath({ workspace, path })).extra_perms - return canWrite(path, extraPerms ?? {}, role.user) ? 'allowed' : 'denied' + return canDraftItem(kind, path, extraPerms, user, settings) ? 'allowed' : 'denied' } catch (e) { // Nothing deployed means no sharing to widen the folder rules with. return (e as { status?: number } | null | undefined)?.status === 404 ? 'denied' : 'unverified' @@ -5471,6 +5700,17 @@ function writeScriptDraft( ctx: WriteDraftCtx, codeDiff?: ToolCodeDiff ): Promise<string> { + // A session builds pipeline nodes into the folder's pipeline draft; a script draft + // written here never reaches the pipeline graph, and saving the pipeline drops it. + if ( + (ctx.sessionId ?? sessionIdFromCtx(ctx)) && + parsePipelineAnnotations(args.content).inPipeline + ) { + const folder = args.path.match(/^f\/([^/]+)\//)?.[1] ?? '<folder>' + throw new Error( + `"${args.path}" is a data pipeline node (its source starts with the pipeline annotation). In a session, pipeline nodes are not script drafts: call open_preview(kind="pipeline", path="${folder}") if that folder's pipeline editor is not open yet, then use build_pipeline_node (new node) or edit_pipeline_node (existing node). Nothing was written.` + ) + } return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override, codeDiff: @@ -6570,11 +6810,12 @@ async function initApp( content: `Saving app "${path}" draft (${framework} template)…` }) - const template = FRAMEWORK_TEMPLATES[framework] + // A builder can save no inline runnable, so they start from a static page instead of the tour. + const builder = operatorBuilderAppsIn(workspace) const value: AppDraftValue = { summary, - files: { ...template }, - runnables: structuredClone(STARTER_RUNNABLES), + files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] }, + runnables: builder ? {} : structuredClone(STARTER_RUNNABLES), ...(data && { data: { tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)), @@ -6587,7 +6828,7 @@ async function initApp( const result = await saveAppDraft(workspace, path, value) return finishAppDraftWrite(result, ctx, () => ({ content: `Saved app "${path}" draft (${framework})`, - message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}` + message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). ${builder ? 'The template is a static page with no runnables: replace its UI, and add only deployed scripts and flows, by path.' : `The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable).`} Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}` })) } diff --git a/frontend/src/lib/components/copilot/chat/global/gate.ts b/frontend/src/lib/components/copilot/chat/global/gate.ts index d1dafe29ed..506f230027 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -25,14 +25,35 @@ export function isGlobalAiEnabled(): boolean { } } +const OPERATOR_SESSIONS_KEY = 'wm_operator_ai_sessions' + +/** + * Operators get AI sessions only in a browser that opted in, until the operator experience + * has been tested; everywhere else they keep the docked chat and `/sessions` refuses them. + * Set with `localStorage.setItem('wm_operator_ai_sessions', '1')` and reload. To ship it, + * delete this and `sessionsAllowedFor`, and drop the `isOperator` argument from every caller. + */ +function operatorSessionsEnabled(): boolean { + if (typeof localStorage === 'undefined') return false + try { + return localStorage.getItem(OPERATOR_SESSIONS_KEY) === '1' + } catch { + return false + } +} + +/** Whether this user's role may use AI sessions at all. */ +export function sessionsAllowedFor(isOperator: boolean | undefined): boolean { + return !isOperator || operatorSessionsEnabled() +} + /** * Whether an AI entry point hands off to a session instead of driving the docked * chat. Deliberately the same condition as the root layout's `disableAi`, so a * caller falling back on `false` always has a mounted pane to fall back to. - * Operators keep that pane (`/sessions` refuses them) until the operator chat ships. */ export function prefersSessionHandoff(isOperator: boolean | undefined): boolean { - return isGlobalAiEnabled() && !isOperator + return isGlobalAiEnabled() && sessionsAllowedFor(isOperator) } /** Clear the opt-out, then hard-reload so every gated site re-reads it. */ diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts index feffda425e..dcb46ca148 100644 --- a/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts @@ -15,22 +15,17 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: vi.mock('$lib/components/vscode', () => ({})) import { getSessionContextPromptSection, type SessionPromptContext } from './core' -import { getPipelinePromptSection, type PipelineContext } from '../pipeline/core' +import { getPipelinePromptSection } from '../pipeline/core' import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly' const sessionContext: SessionPromptContext = { workspaceId: 'ws', parentWorkspaceId: 'parent' } -const pipelineContext: PipelineContext = { - folder: 'myfolder', - mode: 'edit', - nodes: [], - assets: [] -} +const pipelineFolders = ['myfolder'] describe('assembleGlobalTools', () => { it('includes the pipeline tools only when a pipeline editor is active', () => { const names = (opts: Parameters<typeof assembleGlobalTools>[0]) => assembleGlobalTools(opts).map((t) => t.def.function.name) - expect(names({ pipelineContext })).toContain('get_pipeline_graph') + expect(names({ pipelineFolders })).toContain('get_pipeline_graph') expect(names({})).not.toContain('get_pipeline_graph') }) }) @@ -41,13 +36,13 @@ describe('assembleGlobalSystemMessage', () => { it('carries both optional sections when both contexts are given', () => { const content = assembleGlobalSystemMessage(undefined, { sessionContext, - pipelineContext + pipelineFolders }).content as string expect(content).toContain(getSessionContextPromptSection(sessionContext)) - expect(content).toContain(getPipelinePromptSection(pipelineContext)) + expect(content).toContain(getPipelinePromptSection(pipelineFolders)) // Order is part of the assembled output, so it is pinned too. expect(content.indexOf(getSessionContextPromptSection(sessionContext))).toBeLessThan( - content.indexOf(getPipelinePromptSection(pipelineContext)) + content.indexOf(getPipelinePromptSection(pipelineFolders)) ) }) @@ -58,6 +53,6 @@ describe('assembleGlobalSystemMessage', () => { // shares, so a stray section built from any other context still trips them. const heading = (s: string) => s.trim().split('\n')[0] expect(content).not.toContain(heading(getSessionContextPromptSection(sessionContext))) - expect(content).not.toContain(heading(getPipelinePromptSection(pipelineContext))) + expect(content).not.toContain(heading(getPipelinePromptSection(pipelineFolders))) }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts index a291e93ec7..d1fe80f13d 100644 --- a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts @@ -12,7 +12,7 @@ import { type SessionPromptContext } from './core' import { createMcpTools } from './mcpTools' -import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '../pipeline/core' +import { getPipelinePromptSection, pipelineTools } from '../pipeline/core' import { getFolderInstructionsPromptSection, type FolderInstruction } from '../folderInstructions' // Derived, not retyped: an option added to prepareGlobalSystemMessage is reachable @@ -21,7 +21,8 @@ import { getFolderInstructionsPromptSection, type FolderInstruction } from '../f // directly — would get it. export type GlobalAssemblyOpts = NonNullable<Parameters<typeof prepareGlobalSystemMessage>[1]> & { sessionContext?: SessionPromptContext - pipelineContext?: PipelineContext + /** Folders of the open pipeline editors; the pipeline tools and prompt come with any. */ + pipelineFolders?: readonly string[] folderInstructions?: readonly FolderInstruction[] } @@ -34,8 +35,8 @@ export function assembleGlobalSystemMessage( if (opts.sessionContext) { systemMessage.content += getSessionContextPromptSection(opts.sessionContext, opts.access) } - if (opts.pipelineContext) { - systemMessage.content += getPipelinePromptSection(opts.pipelineContext, opts.access) + if (opts.pipelineFolders?.length) { + systemMessage.content += getPipelinePromptSection(opts.pipelineFolders, opts.access) } return systemMessage } @@ -43,7 +44,7 @@ export function assembleGlobalSystemMessage( export function assembleGlobalTools(opts: GlobalAssemblyOpts): SessionTool<any>[] { return [ ...globalToolsFor({ sessionPreview: opts.previewTools ?? false }), - ...(opts.pipelineContext ? pipelineTools : []), + ...(opts.pipelineFolders?.length ? pipelineTools : []), ...createMcpTools(opts.mcpServers ?? []) ] } diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts index 82ad8162d3..eca9f96bf9 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -8,7 +8,7 @@ const { whoami, deployRules } = vi.hoisted(() => ({ vi.mock('$lib/gen', () => ({ UserService: { whoami } })) vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules })) -import { resolveSessionAccess } from './sessionAccess' +import { capabilitiesForRole, resolveSessionAccess } from './sessionAccess' import { clearWorkspaceRoleCache } from '$lib/user' type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean } @@ -45,20 +45,32 @@ describe('resolveSessionAccess', () => { it('gives a developer every capability but admin', async () => { const caps = await capabilitiesFor({}) - expect([...caps].sort()).toEqual(['deploy', 'manage_code', 'run_preview', 'write_draft']) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_apps', + 'manage_code', + 'manage_flows', + 'manage_schedules', + 'manage_triggers', + 'run_flow_preview', + 'run_preview', + 'write_app_draft', + 'write_draft', + 'write_flow_draft' + ]) }) // The folder, resource and variable handlers run the rules but have no operator check, // so withholding `deploy` from an operator would be stricter than the server. it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => { const caps = await capabilitiesFor({ operator: true }) - expect([...caps]).toEqual(['deploy']) + expect([...caps].sort()).toEqual(['deploy', 'manage_schedules', 'manage_triggers']) }) it('takes deploy from the rules alone, so a rule blocks an operator too', async () => { deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' }) const caps = await capabilitiesFor({ operator: true }) - expect([...caps]).toEqual([]) + expect([...caps].sort()).toEqual(['manage_schedules', 'manage_triggers']) }) // Both spellings of `authed.is_admin`, which the draft path honours and the handlers @@ -67,7 +79,15 @@ describe('resolveSessionAccess', () => { 'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)', async (role) => { const caps = await capabilitiesFor({ ...role, operator: true }) - expect([...caps].sort()).toEqual(['admin', 'deploy', 'write_draft']) + expect([...caps].sort()).toEqual([ + 'admin', + 'deploy', + 'manage_schedules', + 'manage_triggers', + 'write_app_draft', + 'write_draft', + 'write_flow_draft' + ]) } ) @@ -82,6 +102,54 @@ describe('resolveSessionAccess', () => { expect(caps.has('write_draft')).toBe(true) }) + // flows.rs, drafts.rs and run_preview_flow all let a builder through for flows, and + // nothing else moves: scripts, apps and their previews stay refused. + it("gives a builder operator the flow capabilities and nothing a script's handler refuses", () => { + const caps = capabilitiesForRole({ + isAdmin: false, + operator: true, + deployRulesPass: true, + operatorSettings: { builder_flows: true } + }) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_flows', + 'manage_schedules', + 'manage_triggers', + 'run_flow_preview', + 'write_flow_draft' + ]) + }) + + // apps.rs and drafts.rs let an app builder through; nothing runs an app preview server-side. + it('gives an app builder operator the app capabilities alone', () => { + const caps = capabilitiesForRole({ + isAdmin: false, + operator: true, + deployRulesPass: true, + operatorSettings: { builder_apps: true } + }) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_apps', + 'manage_schedules', + 'manage_triggers', + 'write_app_draft' + ]) + }) + + // A withdrawable right: only `false` withdraws it, as `gate_operator_writes` reads it. + it('withholds schedule and trigger writes only where the workspace set them to false', () => { + const caps = capabilitiesForRole({ + isAdmin: false, + operator: true, + deployRulesPass: true, + operatorSettings: { manage_schedules: false } + }) + expect(caps.has('manage_schedules')).toBe(false) + expect(caps.has('manage_triggers')).toBe(true) + }) + it('grants everything when the role cannot be resolved', async () => { whoami.mockRejectedValueOnce(new Error('network')) const access = await resolveSessionAccess('ws') diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 1ac13136a4..6e65d833d5 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -1,12 +1,28 @@ +import { get } from 'svelte/store' +import { userWorkspaces } from '$lib/stores' +import type { OperatorSettings } from '$lib/gen' import { getWorkspaceRole } from '$lib/user' +import { roleCanAuthor, roleCanDraft } from '$lib/editRights' import { checkDeployRules } from '$lib/utils_workspace_deploy' import type { SessionAccess, SessionCapability } from '../sessionCapabilities' +type OperatorRights = Pick< + NonNullable<OperatorSettings>, + 'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers' +> + const ALL_CAPABILITIES: SessionCapability[] = [ 'write_draft', + 'write_flow_draft', + 'write_app_draft', 'run_preview', + 'run_flow_preview', 'deploy', 'manage_code', + 'manage_flows', + 'manage_apps', + 'manage_schedules', + 'manage_triggers', 'admin' ] @@ -25,19 +41,41 @@ export function capabilitiesForRole(role: { isAdmin: boolean operator: boolean deployRulesPass: boolean + /** The workspace's `operator_settings`, null for a non-operator. */ + operatorSettings?: OperatorRights | null }): SessionAccess { const capabilities = new Set<SessionCapability>() - // Per-capability precedence, NOT a role ladder: drafts.rs `require_can_write_path` - // returns Ok on `authed.is_admin` BEFORE its operator branch, while jobs.rs - // `run_preview_*` and the script/flow/app handlers refuse `authed.is_operator` with no - // admin escape — and on the session path that flag is never cleared for an admin. - if (role.isAdmin || !role.operator) { + const user = { operator: role.operator, is_admin: role.isAdmin, is_super_admin: false } + const authorsCode = roleCanAuthor('script', user, role.operatorSettings) + const authorsFlows = roleCanAuthor('flow', user, role.operatorSettings) + // Drafts take the admin precedence `roleCanDraft` encodes; previews and the item handlers + // do not — and on the session path the operator flag is never cleared for an admin. + if (roleCanDraft('script', user, role.operatorSettings)) { capabilities.add('write_draft') } - if (!role.operator) { + if (roleCanDraft('flow', user, role.operatorSettings)) { + capabilities.add('write_flow_draft') + } + if (roleCanDraft('raw_app', user, role.operatorSettings)) { + capabilities.add('write_app_draft') + } + if (authorsCode) { capabilities.add('run_preview') capabilities.add('manage_code') } + if (authorsFlows) { + capabilities.add('run_flow_preview') + capabilities.add('manage_flows') + } + if (roleCanAuthor('raw_app', user, role.operatorSettings)) { + capabilities.add('manage_apps') + } + if (roleCanAuthor('schedule', user, role.operatorSettings)) { + capabilities.add('manage_schedules') + } + if (roleCanAuthor('trigger', user, role.operatorSettings)) { + capabilities.add('manage_triggers') + } // No operator term: the rules are their own gate, and the handlers that also refuse // operators say so through `manage_code`. Admins bypass the rules inside the check. if (role.deployRulesPass) { @@ -63,6 +101,7 @@ export async function resolveSessionAccess(workspace: string): Promise<SessionAc return capabilitiesForRole({ isAdmin: !!me.is_admin || !!me.is_super_admin, operator: !!me.operator, - deployRulesPass: (await checkDeployRules(workspace, me)).ok + deployRulesPass: (await checkDeployRules(workspace, me)).ok, + operatorSettings: get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings }) } diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index 42136112c4..ceb179779f 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -32,7 +32,7 @@ import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCa const ASSEMBLY_OPTS = { previewTools: true, - pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }, + pipelineFolders: ['my_pipeline'], mcpServers: [{ path: 'f/test/server' } as any] } @@ -75,10 +75,24 @@ const REACHABLE_PROFILES = [ [false, true].flatMap((isAdmin) => [false, true].flatMap((operator) => // `checkDeployRules` bypasses on admin, so an admin has only the one outcome. - (isAdmin ? [true] : [false, true]).map((deployRulesPass) => { - const access = capabilitiesForRole({ isAdmin, operator, deployRulesPass }) - return [[...access].sort().join(',') || 'none', access] as const - }) + (isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) => + // The operator rights a workspace can set: none, the builder right, both locks. + [ + undefined, + { builder_flows: true }, + { builder_apps: true }, + { builder_flows: true, builder_apps: true }, + { manage_schedules: false, manage_triggers: false } + ].map((operatorSettings) => { + const access = capabilitiesForRole({ + isAdmin, + operator, + deployRulesPass, + operatorSettings + }) + return [[...access].sort().join(',') || 'none', access] as const + }) + ) ) ) ).entries() @@ -122,13 +136,26 @@ describe('session tool policies', () => { const kindsOf = (name: string, access: SessionAccess) => (shipped(name, access).def.function.parameters as any).properties.type.enum + const developerCaps: SessionCapability[] = [ + 'write_draft', + 'write_flow_draft', + 'write_app_draft', + 'run_preview', + 'run_flow_preview', + 'manage_code', + 'manage_flows', + 'manage_apps', + 'manage_schedules', + 'manage_triggers' + ] + // A developer a protection rule refuses keeps the two kinds no rule reaches. - const refused = accessWith(['write_draft', 'run_preview', 'manage_code']) + const refused = accessWith(developerCaps) expect(kindsOf('deploy_workspace_item', refused)).toEqual(['schedule', 'trigger']) expect(kindsOf('delete_workspace_item', refused)).toEqual(['schedule', 'trigger']) // An operator where no rule applies: only the code handlers refuse them. - const operator = accessWith(['deploy']) + const operator = accessWith(['deploy', 'manage_schedules', 'manage_triggers']) expect(kindsOf('deploy_workspace_item', operator)).toEqual([ 'schedule', 'trigger', @@ -136,8 +163,28 @@ describe('session tool policies', () => { 'variable' ]) + // A workspace that withdrew schedule writes from operators: `gate_operator_writes`. + const locked = accessWith(['deploy', 'manage_triggers']) + expect(kindsOf('delete_workspace_item', locked)).toEqual(['trigger', 'resource', 'variable']) + + // A builder operator deploys flows too, and still no script or app. + const builder = accessWith(['deploy', 'manage_flows', 'manage_schedules', 'manage_triggers']) + expect(kindsOf('deploy_workspace_item', builder)).toContain('flow') + expect(kindsOf('deploy_workspace_item', builder)).not.toContain('script') + expect(kindsOf('deploy_workspace_item', builder)).not.toContain('app') + + // An app builder gets the app kind alone, and rebases its own stale app drafts. + const appBuilder = accessWith(['deploy', 'manage_apps', 'write_app_draft']) + expect(kindsOf('deploy_workspace_item', appBuilder)).toContain('app') + expect(kindsOf('deploy_workspace_item', appBuilder)).not.toContain('flow') + expect(kindsOf('rebase_draft', appBuilder)).toEqual(['app']) + // A session left with no kind must not be offered an empty enum. + expect(shippedSessionTools(operator).map((t) => t.def.function.name)).not.toContain( + 'rebase_draft' + ) + // Deleting a script is admin-only, where deploying one is not. - const developer = accessWith(['write_draft', 'run_preview', 'manage_code', 'deploy']) + const developer = accessWith([...developerCaps, 'deploy']) expect(kindsOf('deploy_workspace_item', developer)).toContain('script') expect(kindsOf('delete_workspace_item', developer)).not.toContain('script') @@ -168,7 +215,7 @@ describe('session tool policies', () => { user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] }, access, sessionContext: ctx, - pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] } + pipelineFolders: ['my_pipeline'] }) // Both decoration variants: the escalation one adds its own tool mentions. for (const blocks of [0, 9]) { @@ -184,9 +231,9 @@ describe('session tool policies', () => { // ships to every profile with guidance written around the draft tools by name. it('does not hand authoring guidance naming withheld tools to a session that cannot draft', async () => { const tool = globalTools.find((t) => t.def.function.name === 'get_instructions')! - const call = (access: SessionAccess) => + const call = (access: SessionAccess, subject = 'script') => tool.fn({ - args: { subject: 'script', language: 'bun' }, + args: { subject, language: 'bun' }, workspace: 'ws', helpers: { access }, toolId: 't1', @@ -199,6 +246,30 @@ describe('session tool policies', () => { expect(withheld.filter((n) => restricted.includes(n))).toEqual([]) // The gate is the whole test, so pin that it is not simply refusing everyone. expect(await call(fullSessionAccess())).toContain('write_script') + + // A builder gets the guide for their kind, rewritten without the inline-code tools. + for (const [capability, subject, used] of [ + ['write_flow_draft', 'flow', 'patch_flow_json'], + ['write_app_draft', 'app', 'write_app_runnable'] + ] as const) { + const builder = accessWith(['deploy', capability]) + const guide = await call(builder, subject) + expect(guide).toContain(used) + // The shared reference below still teaches inline code, so the guide must say to skip it. + expect(guide).toContain('skip what it says') + expect(withheldNames(builder).filter((n) => guide.includes(n))).toEqual([]) + } + }) + + // A data setup needs SQL and inline runnables, neither of which a builder has. + it('offers a builder no init_app data setup', async () => { + const tool = globalTools.find((t) => t.def.function.name === 'init_app')! + const properties = async (access: SessionAccess) => + ((await tool.schemaFor!({ access })).function.parameters as any).properties + const builder = await properties(accessWith(['deploy', 'write_app_draft'])) + const full = await properties(fullSessionAccess()) + expect(builder.data).toBeUndefined() + expect(full.data).toBeDefined() }) // A full-access profile must gate nothing at all: the text has to match the ungated @@ -216,4 +287,25 @@ describe('session tool policies', () => { expect(full).toBe(ungated) } }) + + // The operator's own rights outrun the chat's tools: what they schedule in the UI must not + // be sent to an admin. + it("sends an operator to the UI for what their role allows and the chat's tools do not", () => { + const prompt = (operatorSettings: { manage_schedules?: boolean }): string => + prepareGlobalSystemMessage(undefined, { + user: { username: 'op' }, + access: capabilitiesForRole({ + isAdmin: false, + operator: true, + deployRulesPass: true, + operatorSettings + }) + }).content as string + + expect(prompt({})).toContain( + 'Resources, variables, schedules, and triggers they can still edit' + ) + expect(prompt({})).toContain('schedule a deployed script or flow themselves') + expect(prompt({ manage_schedules: false })).not.toContain('schedule a deployed script') + }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts index 4789967a6d..0d8f8753a2 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts @@ -24,13 +24,16 @@ export function filterSessionTools<T extends FilterableTool>( access: SessionAccess | undefined ): T[] { if (!access) return tools - return tools.filter((t) => sessionToolAllowed(t, access)).map((t) => narrowKinds(t, access)) + return tools + .filter((t) => sessionToolAllowed(t, access)) + .flatMap((t) => narrowKinds(t, access) ?? []) } /** Returns the tool itself when every kind is allowed, so full access ships the same bytes: * tool defs are part of every iteration's cached prefix. Builds new objects otherwise — the - * defs are module singletons shared by every session. */ -function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T { + * defs are module singletons shared by every session. `undefined` when no kind is left: an + * empty enum would offer a tool no call can satisfy. */ +function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T | undefined { const kindRequires = tool.kindRequires if (!kindRequires) return tool const parameters = tool.def.function.parameters as { @@ -40,6 +43,7 @@ function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): // A kind with no entry is withheld, as a tool with no `requires` is. const allowed = kinds.filter((k) => kindRequires[k] && satisfies(kindRequires[k], access)) if (allowed.length === kinds.length) return tool + if (allowed.length === 0) return undefined const def = structuredClone(tool.def) ;(def.function.parameters as typeof parameters).properties.type.enum = allowed return { ...tool, def } diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 3a839f4bad..13143f6bce 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -18,6 +18,7 @@ import { applyReasoningToConfig } from '../reasoningRegistry' import { appendPendingToolImages, processToolCall, + type LiveToolSet, queuedToolStatus, type Tool, type ToolCallbacks, @@ -413,7 +414,11 @@ export async function parseOpenAIResponsesCompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool<any>[], helpers: any, - options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void } + options?: { + workspace?: string + onTokenUsage?: (usage: ChatTokenUsage) => void + live?: LiveToolSet + } ): Promise<ParsedCompletionResult> { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error: OpenAIError | ResponseErrorEvent | null = null @@ -620,7 +625,8 @@ export async function parseOpenAIResponsesCompletion( helpers, toolCallbacks: callbacks, workspace: options?.workspace, - messages + messages, + live: options?.live }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts index c0d9b7ac8b..6c4539ef85 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts @@ -39,7 +39,7 @@ const sampleContext: PipelineContext = { } function makeHelpers(overrides: Partial<PipelineAIChatHelpers> = {}): { - helpers: { pipeline: PipelineAIChatHelpers } + helpers: { pipelines: () => PipelineAIChatHelpers[] } calls: Record<string, any[]> } { const calls: Record<string, any[]> = {} @@ -50,6 +50,8 @@ function makeHelpers(overrides: Partial<PipelineAIChatHelpers> = {}): { return ret } const pipeline: PipelineAIChatHelpers = { + getFolder: () => sampleContext.folder, + hasDrafts: () => false, getPipelineContext: () => sampleContext, getNodeBody: async (path: string) => { calls.getNodeBody = [...(calls.getNodeBody ?? []), [path]] @@ -59,15 +61,20 @@ function makeHelpers(overrides: Partial<PipelineAIChatHelpers> = {}): { calls.proposeNode = [...(calls.proposeNode ?? []), [input]] return { path: input.path, detectedReads: [], detectedWrites: [] } }, - editNode: async (path, content) => { - calls.editNode = [...(calls.editNode ?? []), [path, content]] - return { detectedReads: [], detectedWrites: [] } + editNode: async (path, content, summary) => { + calls.editNode = [...(calls.editNode ?? []), [path, content, summary]] + return { summary: summary ?? 'Existing summary', detectedReads: [], detectedWrites: [] } }, removeProposedNode: record('removeProposedNode'), + unconfiguredTriggers: async () => [], + setNodeTrigger: async (path, kind, config) => { + calls.setNodeTrigger = [...(calls.setNodeTrigger ?? []), [path, kind, config]] + return { path: `${path}_${kind}`, replaced: false } + }, testNode: async () => 'job-123', ...overrides } - return { helpers: { pipeline }, calls } + return { helpers: { pipelines: () => [pipeline] }, calls } } describe('pipeline tools', () => { @@ -78,6 +85,7 @@ describe('pipeline tools', () => { 'get_pipeline_graph', 'read_pipeline_node', 'remove_pipeline_node', + 'set_pipeline_trigger', 'test_pipeline_node' ]) }) @@ -96,18 +104,25 @@ describe('pipeline tools', () => { it('build_pipeline_node forwards to proposeNode and does not deploy', async () => { const { helpers, calls } = makeHelpers() + const modified: string[] = [] const out = await toolByName('build_pipeline_node').fn({ args: { path: 'f/analytics/clean', language: 'bun', + summary: 'Test node', content: '// pipeline\nexport async function main() {}', output_kind: 'ducklake' }, workspace: 'w', helpers, - toolCallbacks: noopCallbacks(), + toolCallbacks: { + ...noopCallbacks(), + onItemModified: (kind, path) => modified.push(`${kind}:${path}`) + }, toolId: 't' }) + // The session tracks the folder's draft bundle, the unit it deploys. + expect(modified).toEqual(['data_pipeline:f/analytics/data_pipeline']) expect(calls.proposeNode?.[0]?.[0]).toMatchObject({ path: 'f/analytics/clean', language: 'bun', @@ -116,6 +131,99 @@ describe('pipeline tools', () => { expect(out).toContain('not deployed') }) + it('build_pipeline_node attaches declared triggers, and flags one left unset', async () => { + const { helpers, calls } = makeHelpers({ unconfiguredTriggers: async () => ['kafka'] }) + const out = await toolByName('build_pipeline_node').fn({ + args: { + path: 'f/analytics/ingest', + language: 'python3', + summary: 'Test node', + content: '# pipeline\n# on schedule\n# on kafka\ndef main():\n return 1', + triggers: [{ kind: 'schedule', config: { schedule: '0 0 6 * * *', timezone: 'UTC' } }] + }, + workspace: 'w', + helpers, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + expect(calls.setNodeTrigger?.[0]).toEqual([ + 'f/analytics/ingest', + 'schedule', + { args: {}, enabled: true, schedule: '0 0 6 * * *', timezone: 'UTC' } + ]) + expect(out).toContain("schedule 'f/analytics/ingest_schedule'") + expect(out).toContain('ACTION REQUIRED') + expect(out).toContain('set_pipeline_trigger') + }) + + it('build_pipeline_node rejects an invalid trigger config before staging the node', async () => { + const { helpers, calls } = makeHelpers() + await expect( + toolByName('build_pipeline_node').fn({ + args: { + path: 'f/analytics/ingest', + language: 'python3', + summary: 'Test node', + content: '# pipeline\n# on schedule\ndef main():\n return 1', + triggers: [{ kind: 'schedule', config: { timezone: 'UTC' } }] + }, + workspace: 'w', + helpers, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + ).rejects.toThrow(/schedule/) + expect(calls.proposeNode).toBeUndefined() + }) + + it('build_pipeline_node keeps and records the node when a trigger fails to attach', async () => { + const { helpers, calls } = makeHelpers({ + setNodeTrigger: async () => { + throw new Error('path taken') + } + }) + const modified: string[] = [] + const out = await toolByName('build_pipeline_node').fn({ + args: { + path: 'f/analytics/ingest', + language: 'python3', + content: '# pipeline\n# on schedule\ndef main():\n return 1', + summary: 'Ingest', + triggers: [{ kind: 'schedule', config: { schedule: '0 0 6 * * *', timezone: 'UTC' } }] + }, + workspace: 'w', + helpers, + toolCallbacks: { + ...noopCallbacks(), + onItemModified: (kind, path) => modified.push(`${kind}:${path}`) + }, + toolId: 't' + }) + expect(calls.proposeNode).toHaveLength(1) + expect(modified).toEqual(['data_pipeline:f/analytics/data_pipeline']) + expect(out).toContain('path taken') + }) + + it('build_pipeline_node refuses a trigger the content does not declare, before staging', async () => { + const { helpers, calls } = makeHelpers() + await expect( + toolByName('build_pipeline_node').fn({ + args: { + path: 'f/analytics/ingest', + language: 'python3', + content: '# pipeline\ndef main():\n return 1', + summary: 'Ingest', + triggers: [{ kind: 'schedule', config: { schedule: '0 0 6 * * *', timezone: 'UTC' } }] + }, + workspace: 'w', + helpers, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + ).rejects.toThrow(/on schedule/) + expect(calls.proposeNode).toBeUndefined() + }) + it('build_pipeline_node reports the inferred asset lineage', async () => { const { helpers } = makeHelpers({ proposeNode: async (input) => ({ @@ -125,7 +233,12 @@ describe('pipeline tools', () => { }) }) const out = await toolByName('build_pipeline_node').fn({ - args: { path: 'f/analytics/clean', language: 'duckdb', content: '-- pipeline' }, + args: { + path: 'f/analytics/clean', + language: 'duckdb', + content: '-- pipeline', + summary: 'Test node' + }, workspace: 'w', helpers, toolCallbacks: noopCallbacks(), @@ -140,7 +253,12 @@ describe('pipeline tools', () => { proposeNode: async (input) => ({ path: input.path, detectedReads: [], detectedWrites: [] }) }) const out = await toolByName('build_pipeline_node').fn({ - args: { path: 'f/analytics/clean', language: 'duckdb', content: '-- pipeline' }, + args: { + path: 'f/analytics/clean', + language: 'duckdb', + content: '-- pipeline', + summary: 'Test node' + }, workspace: 'w', helpers, toolCallbacks: noopCallbacks(), @@ -164,6 +282,34 @@ describe('pipeline tools', () => { expect(calls.editNode?.[0]?.[1]).toContain('const x = 42') }) + it('edit_pipeline_node renames a node by summary alone, and asks for one when missing', async () => { + const body = { language: 'bun' as const, content: 'const x = 1\n' } + const { helpers, calls } = makeHelpers({ getNodeBody: async () => body }) + const run = (args: Record<string, unknown>) => + toolByName('edit_pipeline_node').fn({ + args: { path: 'f/analytics/orders', ...args }, + workspace: 'w', + helpers, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + await run({ summary: 'Clean orders' }) + expect(calls.editNode?.[0]).toEqual(['f/analytics/orders', body.content, 'Clean orders']) + + const bare = makeHelpers({ + getNodeBody: async () => body, + editNode: async () => ({ summary: '', detectedReads: [], detectedWrites: [] }) + }) + const out = await toolByName('edit_pipeline_node').fn({ + args: { path: 'f/analytics/orders', old_string: 'x = 1', new_string: 'x = 2' }, + workspace: 'w', + helpers: bare.helpers, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + expect(out).toContain('has no summary') + }) + it('edit_pipeline_node surfaces a clear error when old_string is absent', async () => { const { helpers } = makeHelpers({ getNodeBody: async () => ({ language: 'bun', content: 'const x = 1\n' }) @@ -182,7 +328,7 @@ describe('pipeline tools', () => { it('mutation tools fail clearly when no pipeline editor is registered', async () => { await expect( toolByName('build_pipeline_node').fn({ - args: { path: 'f/a/b', language: 'bun', content: 'x' }, + args: { path: 'f/a/b', language: 'bun', content: 'x', summary: 'Test node' }, workspace: 'w', helpers: {}, toolCallbacks: noopCallbacks(), @@ -191,14 +337,114 @@ describe('pipeline tools', () => { ).rejects.toThrow(/No pipeline editor is open/) }) + it('remove_pipeline_node drops the pipeline from the changes once its last draft goes', async () => { + const run = async (hasDrafts: boolean) => { + const { helpers } = makeHelpers({ hasDrafts: () => hasDrafts }) + const log: string[] = [] + await toolByName('remove_pipeline_node').fn({ + args: { path: 'f/analytics/clean' }, + workspace: 'w', + helpers, + toolCallbacks: { + ...noopCallbacks(), + onItemModified: (kind, path) => log.push(`modified ${kind}:${path}`), + onItemDiscarded: (kind, path) => log.push(`discarded ${kind}:${path}`) + }, + toolId: 't' + }) + return log + } + expect(await run(false)).toEqual(['discarded data_pipeline:f/analytics/data_pipeline']) + expect(await run(true)).toEqual(['modified data_pipeline:f/analytics/data_pipeline']) + }) + it('test_pipeline_node requires confirmation', () => { expect(toolByName('test_pipeline_node').requiresConfirmation).toBe(true) }) }) +describe('pipeline tools with several editors open', () => { + function editorFor(folder: string, built: string[]): PipelineAIChatHelpers { + return { + ...makeHelpers().helpers.pipelines()[0], + getFolder: () => folder, + getPipelineContext: () => ({ ...sampleContext, folder }), + proposeNode: async (input) => { + built.push(`${folder}:${input.path}`) + return { path: input.path, detectedReads: [], detectedWrites: [] } + } + } + } + const call = (name: string, args: any, editors: PipelineAIChatHelpers[]) => + toolByName(name).fn({ + args, + workspace: 'w', + helpers: { pipelines: () => editors }, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + + it('remounts an opened folder whose editor another tab replaced', async () => { + const built: string[] = [] + const mounted: PipelineAIChatHelpers[] = [] + const ensured: string[] = [] + await toolByName('build_pipeline_node').fn({ + args: { + path: 'f/sales/clean', + language: 'bun', + content: '// pipeline', + summary: 'Test node' + }, + workspace: 'w', + helpers: { + pipelines: () => mounted, + pipelineFolders: () => ['sales'], + ensurePipeline: async (folder: string) => { + ensured.push(folder) + mounted.push(editorFor(folder, built)) + return mounted[0] + } + }, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + expect(ensured).toEqual(['sales']) + expect(built).toEqual(['sales:f/sales/clean']) + }) + + it('routes a node to the editor of its folder', async () => { + const built: string[] = [] + const editors = [editorFor('crm', built), editorFor('sales', built)] + await call( + 'build_pipeline_node', + { path: 'f/sales/clean', language: 'bun', content: '// pipeline', summary: 'Test node' }, + editors + ) + expect(built).toEqual(['sales:f/sales/clean']) + await expect( + call( + 'build_pipeline_node', + { path: 'f/other/clean', language: 'bun', content: '// pipeline', summary: 'Test node' }, + editors + ) + ).rejects.toThrow(/open_preview\(kind="pipeline", path="other"\)/) + }) + + it('get_pipeline_graph needs the folder only when it is ambiguous', async () => { + const editors = [editorFor('crm', []), editorFor('sales', [])] + await expect(call('get_pipeline_graph', {}, editors)).rejects.toThrow(/pass the folder/) + expect( + JSON.parse(await call('get_pipeline_graph', { folder: 'f/sales' }, editors)) + ).toMatchObject({ folder: 'sales' }) + expect(JSON.parse(await call('get_pipeline_graph', {}, [editors[0]]))).toMatchObject({ + folder: 'crm' + }) + }) +}) + describe('getPipelinePromptSection', () => { it('names the active folder and the direct-draft workflow', () => { - const section = getPipelinePromptSection(sampleContext) + const section = getPipelinePromptSection([sampleContext.folder]) expect(section).toContain('/pipeline/analytics') expect(section).toContain('build_pipeline_node') expect(section).toContain('directly as unsaved drafts') diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.ts index a1a8f1ad6b..8d71b00279 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.ts @@ -1,7 +1,8 @@ import { z } from 'zod' import { $ScriptLang } from '$lib/gen/schemas.gen' import type { ScriptLang } from '$lib/gen' -import { createToolDef, executeTestRun, findAndReplace } from '../shared' +import { createToolDef, executeTestRun, findAndReplace, type ToolCallbacks } from '../shared' +import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' import { NONE, RUN_PREVIEW, @@ -10,6 +11,11 @@ import { type SessionTool } from '../sessionCapabilities' import type { PipelineOutputKind } from '$lib/components/assets/AssetGraph/pipelineTemplates' +import type { PipelineTriggerDraftKind } from '$lib/components/assets/AssetGraph/types' +import { DRAFTABLE_TRIGGER_KINDS } from '$lib/components/assets/AssetGraph/pipelineTriggerDrafts' +import { scheduleRequestSchema, triggerRequestSchemas } from '../workspaceToolsZod.gen' +import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' +import { parsePipelineAnnotations } from '$lib/components/assets/AssetGraph/parsePipelineAnnotations' // ============================================================================ // Pipeline AI chat tools. @@ -57,6 +63,10 @@ export type PipelineContext = { * small — the page owns the draft Map and canvas rendering. */ export interface PipelineAIChatHelpers { + /** Folder name (no `f/` prefix) of the pipeline this editor shows. */ + getFolder: () => string + /** Whether the folder has any script or trigger draft left. */ + hasDrafts: () => boolean getPipelineContext: () => PipelineContext /** Read a node's source (the in-flight draft body if one exists, else deployed). */ getNodeBody: (path: string) => Promise<{ language: ScriptLang; content: string } | undefined> @@ -68,29 +78,108 @@ export interface PipelineAIChatHelpers { language: ScriptLang content: string outputKind?: PipelineOutputKind + summary?: string }) => Promise<{ path: string; detectedReads: string[]; detectedWrites: string[] }> /** Replace an existing node's body, applied as an unsaved draft. Returns the * re-inferred asset lineage (URIs) so the caller sees the effect of the edit. */ editNode: ( path: string, - content: string - ) => Promise<{ detectedReads: string[]; detectedWrites: string[] }> + content: string, + summary?: string + ) => Promise<{ summary: string; detectedReads: string[]; detectedWrites: string[] }> /** Discard the unsaved draft at a path (undo a build_pipeline_node). */ removeProposedNode: (path: string) => Promise<void> + /** Trigger kinds the node declares (`on <kind>`) that no trigger row or draft fills. */ + unconfiguredTriggers: (path: string) => Promise<PipelineTriggerDraftKind[]> + /** Attach (or replace) the node's trigger draft of that kind, deployed with the + * pipeline. `config` is the kind's trigger config; path/script_path are filled in. */ + setNodeTrigger: ( + path: string, + kind: PipelineTriggerDraftKind, + config: Record<string, any> + ) => Promise<{ path: string; replaced: boolean }> /** Preview-run a node (draft body preferred). Returns the started job id. */ testNode: (path: string, args?: Record<string, any>) => Promise<string | undefined> } -/** Helper bag the pipeline tools receive from the manager in global mode. */ -export type PipelineToolHelpers = { pipeline?: PipelineAIChatHelpers } +/** Helper bag the pipeline tools receive from the manager in global mode: every + * pipeline editor currently open, one per folder. */ +export type PipelineToolHelpers = { + pipelines?: () => readonly PipelineAIChatHelpers[] + /** Folders the chat may work on, including ones whose editor is not mounted now. */ + pipelineFolders?: () => readonly string[] + /** Remounts the folder's editor (a session reopens its preview tab) and resolves + * to it, or `undefined` when it cannot be brought back. */ + ensurePipeline?: (folder: string) => Promise<PipelineAIChatHelpers | undefined> +} -function requirePipeline(helpers: PipelineToolHelpers): PipelineAIChatHelpers { - if (!helpers?.pipeline) { +/** The folder's editor, remounted when the chat opened it before and something else + * has taken its tab since. */ +async function remount( + helpers: PipelineToolHelpers, + folder: string | undefined +): Promise<PipelineAIChatHelpers | undefined> { + if (!folder || !helpers?.ensurePipeline) return undefined + if (!(helpers.pipelineFolders?.() ?? []).includes(folder)) return undefined + return helpers.ensurePipeline(folder) +} + +function openPipelines(helpers: PipelineToolHelpers): readonly PipelineAIChatHelpers[] { + const open = helpers?.pipelines?.() ?? [] + if (open.length === 0) { throw new Error( 'No pipeline editor is open. Pipeline tools only work on a /pipeline/<folder> page in edit mode.' ) } - return helpers.pipeline + return open +} + +function notOpenError(folder: string, open: readonly PipelineAIChatHelpers[]): Error { + const names = open.map((p) => `'${p.getFolder()}'`).join(', ') + return new Error( + `No pipeline editor is open for folder '${folder}' (open: ${names}). Open it with open_preview(kind="pipeline", path="${folder}") first.` + ) +} + +/** The editor of the pipeline a node path belongs to (`f/<folder>/<node>`). With a + * single editor open, a path outside it still goes to that editor, whose own check + * names the corrected path. */ +async function pipelineForPath( + helpers: PipelineToolHelpers, + path: string +): Promise<PipelineAIChatHelpers> { + const folder = path.match(/^f\/([^/]+)\//)?.[1] + const live = folder ? helpers?.pipelines?.().find((p) => p.getFolder() === folder) : undefined + const match = live ?? (await remount(helpers, folder)) + if (match) return match + const open = openPipelines(helpers) + if (open.length === 1) return open[0] + throw folder + ? notOpenError(folder, open) + : new Error( + `'${path}' is not a pipeline node path — nodes live at f/<folder>/<node_name>, with <folder> one of: ${open.map((p) => `'${p.getFolder()}'`).join(', ')}.` + ) +} + +async function pipelineForFolder( + helpers: PipelineToolHelpers, + folder: string | undefined +): Promise<PipelineAIChatHelpers> { + const known = helpers?.pipelineFolders?.() ?? [] + const name = folder ? normalizePipelineFolder(folder) : known.length === 1 ? known[0] : undefined + const live = name ? helpers?.pipelines?.().find((p) => p.getFolder() === name) : undefined + const remounted = live ?? (await remount(helpers, name)) + if (remounted) return remounted + const open = openPipelines(helpers) + if (folder) { + const match = open.find((p) => p.getFolder() === name) + if (!match) throw notOpenError(normalizePipelineFolder(folder), open) + return match + } + if (open.length === 1) return open[0] + throw new Error( + `Several pipeline editors are open (${open.map((p) => `'${p.getFolder()}'`).join(', ')}) — pass the folder to read.` + ) } const scriptLangSchema = z.enum($ScriptLang.enum) @@ -105,12 +194,20 @@ const outputKindSchema = z // Read tools // ---------------------------------------------------------------------------- -const getPipelineGraphSchema = z.object({}) +const getPipelineGraphSchema = z.object({ + folder: z + .string() + .optional() + .describe( + 'Folder of the pipeline to read (e.g. `analytics`). Required when several pipeline editors are open; may be omitted when only one is.' + ) +}) const getPipelineGraphToolDef = createToolDef( getPipelineGraphSchema, 'get_pipeline_graph', - "Read the live pipeline graph for the open /pipeline/<folder> editor: its nodes (scripts), each node's language, asset reads/writes, declared triggers, and whether it has an unsaved draft edit. Call this before building or editing nodes so you reuse existing assets/paths and understand the current DAG." + "Read the live pipeline graph of an open /pipeline/<folder> editor: its nodes (scripts), each node's language, asset reads/writes, declared triggers, and whether it has an unsaved draft edit. Call this before building or editing nodes so you reuse existing assets/paths and understand the current DAG.", + { strict: false } ) const readPipelineNodeSchema = z.object({ @@ -127,11 +224,75 @@ const readPipelineNodeToolDef = createToolDef( // Mutation tools (apply directly as unsaved drafts; never deploy) // ---------------------------------------------------------------------------- +const triggerKindEnum = z.enum(DRAFTABLE_TRIGGER_KINDS as [PipelineTriggerDraftKind, ...PipelineTriggerDraftKind[]]) + +const nodeTriggerSchema = z.object({ + kind: triggerKindEnum.describe('Trigger kind; the node must declare `on <kind>`.'), + config: z + .record(z.string(), z.any()) + .describe( + 'The trigger configuration. schedule: { schedule (6-field cron, e.g. "0 0 6 * * *"), timezone (IANA, e.g. "UTC"), args? (the node\'s inputs), summary? }. Any other kind: the fields get_trigger_schema returns for it. path, script_path and is_flow are filled in; pass path only to name the trigger yourself.' + ) +}) + +const setPipelineTriggerSchema = nodeTriggerSchema.extend({ + path: z.string().describe('Workspace path of the node the trigger runs.') +}) + +const setPipelineTriggerToolDef = createToolDef( + setPipelineTriggerSchema, + 'set_pipeline_trigger', + 'Attach the trigger a node declares with `on <schedule|email|kafka|mqtt|amqp|nats|postgres|sqs|gcp>` (its cron, topic, queue…) as an unsaved trigger draft, deployed together with the pipeline. Calling it again for the same node and kind replaces that draft. Webhook and data_upload need no trigger.', + { strict: false } +) + +/** Validates a node trigger's config against the kind's request schema, with the + * fields the pipeline fills in present so they are not reported missing. */ +function validateTriggerConfig( + kind: PipelineTriggerDraftKind, + config: Record<string, any>, + nodePath: string +): Record<string, any> { + const filled = { path: `${nodePath}_${kind}`, script_path: nodePath, is_flow: false, ...config } + if (kind === 'schedule') { + const parsed = scheduleRequestSchema.safeParse({ args: {}, enabled: true, ...filled }) + if (!parsed.success) { + throw new Error( + `Invalid schedule config: ${parsed.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`).join('; ')}` + ) + } + return { args: {}, enabled: true, ...config } + } + const parsed = triggerRequestSchemas[kind].safeParse(filled) + if (!parsed.success) { + throw new Error( + `Invalid config for a "${kind}" trigger. Call get_trigger_schema with kind "${kind}" for its exact fields. Issues: ${parsed.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`).join('; ')}` + ) + } + return config +} + +/** The note every node write ends with while a declared trigger is still unset: the + * node would deploy with nothing to start it. */ +function unconfiguredTriggersNote(path: string, kinds: readonly string[]): string { + if (kinds.length === 0) return '' + return ` ACTION REQUIRED: '${path}' declares \`on ${kinds.join('`, `on ')}\` but has no ${kinds.join('/')} trigger yet, so nothing would start it once deployed. Call set_pipeline_trigger for it now (a schedule needs its cron and timezone; ask the user only if the cadence cannot be inferred).` +} + +const summaryField = z + .string() + .trim() + .min(1) + .max(80) + .describe( + 'Succinct summary of what the node does, a few words in sentence case with no trailing period (e.g. "Ingest Stripe charges", "Daily revenue by region"). It is the title the node shows in the workspace.' + ) + const buildPipelineNodeSchema = z.object({ path: z .string() .describe( - "Workspace path for the new node, e.g. f/<folder>/<name>. Use the open pipeline's folder. Must not collide with an existing node." + 'Workspace path for the new node, e.g. f/<folder>/<name>, in the folder of an open pipeline editor. Must not collide with an existing node.' ), language: scriptLangSchema.describe( 'Script language. SQL-shaped data work uses duckdb (DuckLake/S3) or postgresql (data tables); bun/python3 for general transforms.' @@ -141,7 +302,14 @@ const buildPipelineNodeSchema = z.object({ .describe( "Full script source. Start it with the `pipeline` annotation as a top-of-file comment in the LANGUAGE'S comment syntax — `-- pipeline` for SQL (duckdb/postgresql), `# pipeline` for python3/bash, `// pipeline` for bun/TS — to mark it a pipeline member; declare inputs the same way (e.g. `-- on <asset-uri|schedule|webhook|...>`), and write outputs via the wmill SDK / SQL so the lineage edges are inferred. A `// pipeline` line in a SQL node is a syntax error. Read existing node bodies first to match conventions." ), - output_kind: outputKindSchema.optional() + summary: summaryField, + output_kind: outputKindSchema.optional(), + triggers: z + .array(nodeTriggerSchema) + .optional() + .describe( + 'Triggers for the `on <kind>` lines the content declares (schedule, email, kafka, …), attached as trigger drafts in the same step. Every such declaration needs one; webhook and data_upload need none.' + ) }) const buildPipelineNodeToolDef = createToolDef( @@ -153,8 +321,17 @@ const buildPipelineNodeToolDef = createToolDef( const editPipelineNodeSchema = z.object({ path: z.string().describe('Workspace path of the node to edit.'), - old_string: z.string().min(1).describe("Exact text to find in the node's current source."), - new_string: z.string().describe('Replacement text.'), + old_string: z + .string() + .min(1) + .optional() + .describe("Exact text to find in the node's current source. Omit to change only the summary."), + new_string: z.string().optional().describe('Replacement text.'), + summary: summaryField + .optional() + .describe( + 'New succinct summary (a few words, no trailing period). Pass it whenever the node has none or the edit changes what the node does.' + ), replace_all: z .boolean() .optional() @@ -197,6 +374,12 @@ const testPipelineNodeToolDef = createToolDef( { strict: false } ) +// A pipeline's drafts are one bundle for the whole folder, so that bundle is the +// item a node edit marks as modified — it is what the session deploys as a unit. +function recordPipelineModified(toolCallbacks: ToolCallbacks, pipeline: PipelineAIChatHelpers) { + toolCallbacks.onItemModified?.(PIPELINE_DRAFT_KIND, pipelineBundlePath(pipeline.getFolder())) +} + // Summarize the asset lineage the parser inferred from a just-applied node so the // model gets same-turn feedback on whether its intended edges formed. An empty // result is the useful signal: a write/read expressed via a variable or dynamic @@ -217,8 +400,9 @@ export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [ requires: NONE, def: getPipelineGraphToolDef, planModeSafe: true, - fn: async ({ helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + const { folder } = getPipelineGraphSchema.parse(args ?? {}) + const pipeline = await pipelineForFolder(helpers, folder) toolCallbacks.setToolStatus(toolId, { content: 'Reading pipeline graph...' }) const ctx = pipeline.getPipelineContext() toolCallbacks.setToolStatus(toolId, { @@ -233,8 +417,8 @@ export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [ def: readPipelineNodeToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path } = readPipelineNodeSchema.parse(args) + const pipeline = await pipelineForPath(helpers, path) toolCallbacks.setToolStatus(toolId, { content: `Reading node '${path}'...` }) const node = await pipeline.getNodeBody(path) if (!node) { @@ -251,20 +435,51 @@ export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [ showDetails: true, showFade: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) - const { path, language, content, output_kind } = buildPipelineNodeSchema.parse(args) + const { path, language, content, summary, output_kind, triggers } = + buildPipelineNodeSchema.parse(args) + const pipeline = await pipelineForPath(helpers, path) + // Checked before the node is staged, so a bad trigger leaves nothing half-built. + const declared = new Set(parsePipelineAnnotations(content).nativeTriggers.map((n) => n.kind)) + const configs = (triggers ?? []).map((t) => { + if (!declared.has(t.kind)) { + throw new Error( + `The content declares no \`on ${t.kind}\`, so a ${t.kind} trigger has nothing to bind to. Add the annotation line or drop that trigger. Nothing was built.` + ) + } + return { kind: t.kind, config: validateTriggerConfig(t.kind, t.config, path) } + }) toolCallbacks.setToolStatus(toolId, { content: `Building node '${path}'...` }) const { detectedReads, detectedWrites } = await pipeline.proposeNode({ path, language: language as ScriptLang, content, + summary, outputKind: output_kind as PipelineOutputKind | undefined }) + // Recorded as soon as it is staged: a trigger that fails below leaves the node. + recordPipelineModified(toolCallbacks, pipeline) + const attached: string[] = [] + const failed: string[] = [] + for (const t of configs) { + try { + const { path: triggerPath } = await pipeline.setNodeTrigger(path, t.kind, t.config) + attached.push(`${t.kind} '${triggerPath}'`) + } catch (e) { + failed.push(`${t.kind}: ${e instanceof Error ? e.message : String(e)}`) + } + } toolCallbacks.setToolStatus(toolId, { content: `Added draft node '${path}'`, result: 'Success' }) - return `Pipeline node '${path}' added as an unsaved draft on the canvas. It is not deployed — the user deploys it.${inferredLineageNote(detectedReads, detectedWrites)}` + const attachedNote = + (attached.length + ? ` Attached trigger draft${attached.length > 1 ? 's' : ''}: ${attached.join(', ')}.` + : '') + + (failed.length + ? ` The node was built, but these triggers were not attached — fix them with set_pipeline_trigger: ${failed.join('; ')}.` + : '') + return `Pipeline node '${path}' added as an unsaved draft on the canvas. It is not deployed — the user deploys it.${attachedNote}${inferredLineageNote(detectedReads, detectedWrites)}${unconfiguredTriggersNote(path, await pipeline.unconfiguredTriggers(path))}` } }, { @@ -274,36 +489,74 @@ export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [ showDetails: true, showFade: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) - const { path, old_string, new_string, replace_all } = editPipelineNodeSchema.parse(args) + const { path, old_string, new_string, replace_all, summary } = + editPipelineNodeSchema.parse(args) + if (old_string === undefined && summary === undefined) { + throw new Error('Pass old_string/new_string to change the source, summary to rename it, or both.') + } + const pipeline = await pipelineForPath(helpers, path) const node = await pipeline.getNodeBody(path) if (!node) { return `No pipeline node found at '${path}'. Call get_pipeline_graph to list the available nodes.` } toolCallbacks.setToolStatus(toolId, { content: `Editing node '${path}'...` }) - const updated = findAndReplace( - node.content, - old_string, - new_string, - replace_all ?? false, - 'node source' - ) - const { detectedReads, detectedWrites } = await pipeline.editNode(path, updated) + const updated = + old_string === undefined + ? node.content + : findAndReplace( + node.content, + old_string, + new_string ?? '', + replace_all ?? false, + 'node source' + ) + const edited = await pipeline.editNode(path, updated, summary) + const { detectedReads, detectedWrites } = edited + const summaryNote = edited.summary.trim() + ? '' + : ` '${path}' has no summary: call edit_pipeline_node with just \`summary\` (a few words saying what it does).` + recordPipelineModified(toolCallbacks, pipeline) toolCallbacks.setToolStatus(toolId, { content: `Edited draft '${path}'`, result: 'Success' }) - return `Pipeline node '${path}' updated as an unsaved draft on the canvas (not deployed).${inferredLineageNote(detectedReads, detectedWrites)}` + return `Pipeline node '${path}' updated as an unsaved draft on the canvas (not deployed).${inferredLineageNote(detectedReads, detectedWrites)}${summaryNote}${unconfiguredTriggersNote(path, await pipeline.unconfiguredTriggers(path))}` + } + }, + { + requires: WRITE_DRAFT, + def: setPipelineTriggerToolDef, + showDetails: true, + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + const { path, kind, config } = setPipelineTriggerSchema.parse(args) + const pipeline = await pipelineForPath(helpers, path) + toolCallbacks.setToolStatus(toolId, { content: `Setting the ${kind} trigger of '${path}'...` }) + const valid = validateTriggerConfig(kind, config, path) + const { path: triggerPath, replaced } = await pipeline.setNodeTrigger(path, kind, valid) + recordPipelineModified(toolCallbacks, pipeline) + toolCallbacks.setToolStatus(toolId, { + content: `${replaced ? 'Updated' : 'Attached'} ${kind} trigger '${triggerPath}'`, + result: 'Success' + }) + return `${replaced ? 'Replaced' : 'Attached'} the ${kind} trigger draft '${triggerPath}' on '${path}'. It deploys with the pipeline.${unconfiguredTriggersNote(path, await pipeline.unconfiguredTriggers(path))}` } }, { requires: WRITE_DRAFT, def: removePipelineNodeToolDef, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path } = removePipelineNodeSchema.parse(args) + const pipeline = await pipelineForPath(helpers, path) toolCallbacks.setToolStatus(toolId, { content: `Discarding draft '${path}'...` }) await pipeline.removeProposedNode(path) + // With the last draft gone there is nothing left to deploy: the pipeline + // is no longer this chat's change, and listing it would show it deployed. + if (pipeline.hasDrafts()) recordPipelineModified(toolCallbacks, pipeline) + else + toolCallbacks.onItemDiscarded?.( + PIPELINE_DRAFT_KIND, + pipelineBundlePath(pipeline.getFolder()) + ) toolCallbacks.setToolStatus(toolId, { content: `Discarded draft '${path}'`, result: 'Success' @@ -319,8 +572,8 @@ export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [ showDetails: true, autoCollapseDetails: false, fn: async ({ args, workspace, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path, args: runArgs } = testPipelineNodeSchema.parse(args) + const pipeline = await pipelineForPath(helpers, path) return executeTestRun({ jobStarter: async () => { const jobId = await pipeline.testNode(path, runArgs ?? undefined) @@ -350,22 +603,35 @@ export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [ * since the tools it names are withheld from that session; the annotation model stays, * as it is what lets the model read and explain an existing pipeline. */ -export function getPipelinePromptSection(ctx: PipelineContext, access?: SessionAccess): string { +export function getPipelinePromptSection( + folders: readonly string[], + access?: SessionAccess +): string { const canWriteDraft = !access || access.has('write_draft') + const [first] = folders + const openLine = + folders.length === 1 + ? `The user has the /pipeline/${first} editor open.` + : `The user has ${folders.length} pipeline editors open: ${folders.map((f) => `/pipeline/${f}`).join(', ')}. Each tool acts on the pipeline its node path's folder names; pass \`folder\` to get_pipeline_graph.` + const pathLine = + folders.length === 1 + ? `Every node of this pipeline lives at \`f/${first}/<node_name>\` — \`${first}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`<node_name>\`).` + : `Every node of a pipeline lives at \`f/<folder>/<node_name>\` (e.g. \`f/${first}/<node_name>\`) — \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`<node_name>\`). A node can only be built in the folder of an open pipeline editor.` return ` Data Pipeline editor (ACTIVE): -- The user has the /pipeline/${ctx.folder} editor open. A pipeline is a DAG of scripts (nodes) connected by storage assets (DuckLake tables, data tables, S3 objects, volumes, resources) and execution triggers. +- ${openLine} A pipeline is a DAG of scripts (nodes) connected by storage assets (DuckLake tables, data tables, S3 objects, volumes, resources) and execution triggers. - Annotations are top-of-file comments in the NODE'S OWN comment syntax: \`--\` for SQL (duckdb/postgresql), \`#\` for python3/bash, \`//\` for bun/TS. The \`//\` shown below is the TS form — translate it (a \`// pipeline\` line in a SQL node is a syntax error that won't deploy). - A script becomes a pipeline node when its source starts with the \`// pipeline\` annotation. Declare execution-DAG inputs with \`// on <asset-uri | schedule | webhook | email | kafka | mqtt | amqp | nats | postgres | sqs | gcp | data_upload>\` (e.g. \`// on ducklake://main/orders\`). Outputs are inferred from what the body writes (wmill SDK calls / SQL CREATE TABLE / writeS3File); declare a managed output with \`// materialize <asset-uri>\`. Optional badges: \`// partitioned <daily|hourly|weekly|monthly|dynamic>\`, \`// freshness <duration>\`, \`// tag <name>\`, \`// retry <count> [delay]\`, \`// data_test <kind> ...\` (managed DuckLake targets only — deploy rejects it beside a \`dbt://\` target), \`// measure <name> = <agg> [where <pred>]\`, \`// dimension <name> = <expr>\`. -- \`materialize\` (the managed output): a managed \`// materialize ducklake://<name>/<table>\` means the runtime writes the node's output table FOR you — write the body as a single SELECT and the runtime wraps it in the create/replace, so do NOT also write your own CREATE TABLE / INSERT. The \`dbt://\` target below is the opposite: the node writes its own DDL and none of the write strategies apply to it. IMPORTANT: a MANAGED \`// materialize\` is **DuckDB-only** and its target MUST be a DuckLake table (\`ducklake://<name>/<table>\`) — deploy rejects a \`ducklake://\` target on any other language. For a \`python3\`/\`bun\`/\`postgresql\` node writing the lake, do NOT use \`// materialize\`; write the output via the SDK instead (e.g. \`wmill.writeS3File(...)\`, a \`CREATE TABLE\` in postgresql, or \`wmill.databaseUrlFromResource\`/ducklake helpers) and let the output be inferred. Reach for \`duckdb\` when a node should materialize a DuckLake table. The one target any language BUT DBT'S OWN may declare (a dbt project's writes come from its manifest, so \`// materialize\` on a dbt script is rejected at deploy) is a WAREHOUSE RELATION: \`// materialize manual dbt://<warehouse>/<schema>/<name>\`, with \`<warehouse>\` a warehouse the workspace configures under Settings → dbt. \`manual\` is its only mode — nothing generates warehouse DDL, so the node issues its own write and the annotation records the outcome. Use it on an ingestion node a dbt project reads as a \`source\`: the declared relation and the dbt model become ONE graph node, and a downstream \`// on dbt://<warehouse>/<schema>/<name>\` fires when that node completes. Write strategy: with no option it REPLACES the whole table each run (full refresh; the only mode whose output columns may change); \`// materialize <uri> append\` INSERT-appends rows (incremental); \`// materialize <uri> key=<col>\` merges/upserts on \`<col>\`. \`// materialize manual <uri>\` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned <daily|hourly|weekly|monthly|dynamic>\` node runs once per partition (append/merge into a fixed-schema table), and the \`{partition}\` token — usable in any asset URI AND in the body SQL — is substituted with the current partition's IDENTITY string at run time. To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition(<ts_col>) = {partition}\`. \`wm_partition(ts)\` buckets a timestamp with the exact identity format the runtime used (daily/hourly/weekly/monthly), so it always matches and you 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 runtime. For \`dynamic\` partitioning the identity is your caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE <your_key_col> = {partition}\`. \`materialize\` is an output DECLARATION on the node — it is not a command; there is no "materialize run". +- \`materialize\` (the managed output): a managed \`// materialize ducklake://<name>/<table>\` means the runtime writes the node's output table FOR you — write the body as a single SELECT and the runtime wraps it in the create/replace, so do NOT also write your own CREATE TABLE / INSERT. The \`dbt://\` target below is the opposite: the node writes its own DDL and none of the write strategies apply to it. IMPORTANT: a MANAGED \`// materialize\` is **DuckDB-only** and its target MUST be a DuckLake table (\`ducklake://<name>/<table>\`) — deploy rejects a \`ducklake://\` target on any other language. For a \`python3\`/\`bun\`/\`postgresql\` node writing the lake, do NOT use \`// materialize\`; write the table straight into DuckLake with \`wmill.ducklake()\` (or a data table with \`wmill.datatable()\`), or a \`CREATE TABLE\` in postgresql and let the output be inferred. Reach for \`duckdb\` when a node should materialize a DuckLake table. The one target any language BUT DBT'S OWN may declare (a dbt project's writes come from its manifest, so \`// materialize\` on a dbt script is rejected at deploy) is a WAREHOUSE RELATION: \`// materialize manual dbt://<warehouse>/<schema>/<name>\`, with \`<warehouse>\` a warehouse the workspace configures under Settings → dbt. \`manual\` is its only mode — nothing generates warehouse DDL, so the node issues its own write and the annotation records the outcome. Use it on an ingestion node a dbt project reads as a \`source\`: the declared relation and the dbt model become ONE graph node, and a downstream \`// on dbt://<warehouse>/<schema>/<name>\` fires when that node completes. Write strategy: with no option it REPLACES the whole table each run (full refresh; the only mode whose output columns may change); \`// materialize <uri> append\` INSERT-appends rows (incremental); \`// materialize <uri> key=<col>\` merges/upserts on \`<col>\`. \`// materialize manual <uri>\` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned <daily|hourly|weekly|monthly|dynamic>\` node runs once per partition (append/merge into a fixed-schema table), and the \`{partition}\` token — usable in any asset URI AND in the body SQL — is substituted with the current partition's IDENTITY string at run time. To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition(<ts_col>) = {partition}\`. \`wm_partition(ts)\` buckets a timestamp with the exact identity format the runtime used (daily/hourly/weekly/monthly), so it always matches and you 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 runtime. For \`dynamic\` partitioning the identity is your caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE <your_key_col> = {partition}\`. \`materialize\` is an output DECLARATION on the node — it is not a command; there is no "materialize run". - \`measure\` / \`dimension\` (declared metrics): on a node that materializes a DuckLake table, \`// measure <name> = <aggregate> [where <predicate>]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension <name> = <expr>\` 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 can 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 \`<agg> FILTER (WHERE <pred>)\`, which is what lets two measures with different predicates sit under 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 with read_pipeline_node and reuse its exact expression rather than guessing it. - Use get_pipeline_graph to see the current nodes/assets/triggers, and read_pipeline_node before editing one. -- Every node of this pipeline lives at \`f/${ctx.folder}/<node_name>\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`<node_name>\`).${ +- ${pathLine}${ canWriteDraft ? ` -- Build new nodes with build_pipeline_node and edit existing ones with edit_pipeline_node. These apply directly as unsaved drafts on the canvas (like the flow/script editor applies AI edits) — they DO NOT deploy. There is no separate Accept/Reject step. Prefer these over the generic write_script/edit_script draft tools while a pipeline is open. +- Build new nodes with build_pipeline_node and edit existing ones with edit_pipeline_node. Give every node a succinct \`summary\` (a few words saying what it does, e.g. "Ingest Stripe charges"), and update it when an edit changes what the node does. These apply directly as unsaved drafts on the canvas (like the flow/script editor applies AI edits) — they DO NOT deploy. There is no separate Accept/Reject step. Prefer these over the generic write_script/edit_script draft tools while a pipeline is open. - Reuse existing asset paths from the graph when wiring a downstream node to an upstream one (read the upstream's write asset, then \`// on\` that same URI). +- TRIGGERS: an ingestion node (one with no upstream asset to react to) must declare how it starts, e.g. \`// on schedule\`, and every \`on <schedule|email|kafka|mqtt|amqp|nats|postgres|sqs|gcp>\` line needs its trigger: pass it in build_pipeline_node's \`triggers\` (or call set_pipeline_trigger) in the same step you build the node. A schedule needs a 6-field cron and an IANA timezone — pick a sensible cadence from the request (e.g. daily at 06:00 UTC for a daily sync) rather than leaving it empty; for the other kinds call get_trigger_schema(kind) for the config fields and ask the user for any connection/resource you cannot find. Never leave a declared trigger without one: the node would deploy with nothing to start it. \`webhook\` and \`data_upload\` need no trigger. - Only deploy when the user explicitly asks; the user deploys drafts from the canvas.` : '' }` diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts index fc77b3e1c4..ba5f4facff 100644 --- a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -12,14 +12,31 @@ export type SessionCapability = /** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and * triggers included, though an operator may create those directly. */ | 'write_draft' + /** Passes `require_can_write_path` for a flow draft, which an operator also passes where the + * workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */ + | 'write_flow_draft' + /** The same for a raw app draft, under `builder_apps`. */ + | 'write_app_draft' /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ | 'run_preview' + /** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an + * operator with `builder_flows`. */ + | 'run_flow_preview' /** Passes `check_deploy_rules`. */ | 'deploy' - /** Passes the operator refusal the script, flow and app handlers make before creating or + /** Passes the operator refusal the script handlers make before creating or * deleting one. The same condition as `run_preview` today, but another handler family's * check, so each follows its own gate if the two ever diverge. */ | 'manage_code' + /** Passes the flow handlers' `check_operator_can_build_flows`. */ + | 'manage_flows' + /** Passes the raw app handlers' `check_operator_can_build`, read from `builder_apps`. */ + | 'manage_apps' + /** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose + * workspace set `operator_settings.manage_schedules` to false. */ + | 'manage_schedules' + /** The same gate on the trigger routers, read from `manage_triggers`. */ + | 'manage_triggers' /** Passes `require_admin`. */ | 'admin' @@ -36,9 +53,25 @@ export type SessionToolPolicy = readonly SessionCapability[] export const NONE: SessionToolPolicy = [] export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] +export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft'] +export const WRITE_APP_DRAFT: SessionToolPolicy = ['write_app_draft'] export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] +export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview'] export const DEPLOY: SessionToolPolicy = ['deploy'] +/** Editors a session can open. Every editor autosaves a draft, so it needs the draft + * capability of its kind; a pipeline's nodes are script drafts. */ +export type EditorKind = 'script' | 'flow' | 'raw_app' | 'pipeline' + +/** Whether `access` can save what the `kind` editor writes. `undefined` access (not + * resolved yet, or not a session) answers true, as the toolset filter does. */ +export function canOpenEditor(access: SessionAccess | undefined, kind: EditorKind): boolean { + if (!access) return true + return access.has( + kind === 'flow' ? 'write_flow_draft' : kind === 'raw_app' ? 'write_app_draft' : 'write_draft' + ) +} + /** * A tool that can reach an AI session's toolset. `requires` is mandatory, and declared * beside the `fn` whose call is what proves it right. diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index ff7c59d6cb..5a3427006e 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -395,6 +395,30 @@ describe('processToolCall', () => { expect(result.content).toBe(`Error while calling tool: ${expectedError}`) }) + it('resolves a tool registered earlier in the same batch from the live set', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const fn = vi.fn().mockResolvedValue('built') + const registered = { + def: createToolDef(z.object({}), 'build_pipeline_node', 'Build node'), + fn + } + const result = await processToolCall({ + tools: [], + live: () => ({ tools: [registered], helpers: { pipelines: true } }), + toolCall: { + id: 'call_live', + type: 'function', + function: { name: 'build_pipeline_node', arguments: '{}' } + }, + helpers: {}, + workspace: 'test-workspace', + toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() } + }) + + expect(result.content).toBe('built') + expect(fn).toHaveBeenCalledWith(expect.objectContaining({ helpers: { pipelines: true } })) + }) + it('continues to confirmation when pre-confirmation validation passes', async () => { const { createToolDef, processToolCall } = await import('./shared') const fn = vi.fn().mockResolvedValue('ok') diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 6897a22444..f346b4297d 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -834,6 +834,13 @@ async function callTool<T>({ }): Promise<string> { const tool = tools.find((t) => t.def.function.name === functionName) if (!tool) { + // The pipeline tools exist only while a pipeline editor is mounted, and change_mode + // cannot mount one, so the generic hint would send the model probing. + if (/_pipeline_node$|^get_pipeline_graph$/.test(functionName)) { + throw new Error( + `${functionName} is not available yet: no pipeline editor is open for this chat. Call open_preview(kind="pipeline", path="<folder>") first and wait for its result; once it reports the editor open, the pipeline tools are callable. Do not fall back to write_script.` + ) + } throw new Error( `Unknown tool call: ${functionName}. Probably not in the correct mode, use the change_mode tool to switch to the correct mode.` ) @@ -924,22 +931,33 @@ type ToolCallStatus = | 'blocked_plan_mode' | 'held_for_instructions' +/** Re-reads the chat loop's current tools and helpers. A call earlier in the same + * response can register tools (open_preview mounting the pipeline editor, change_mode), + * so a later call in that batch must resolve against the live set, not the snapshot the + * response was requested with. */ +export type LiveToolSet = () => { tools: Tool<any>[]; helpers: any } + export async function processToolCall<T>({ - tools, + tools: snapshotTools, toolCall, - helpers, + helpers: snapshotHelpers, toolCallbacks, workspace, - messages = [] + messages = [], + live }: { tools: Tool<T>[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T + live?: LiveToolSet toolCallbacks: ToolCallbacks workspace?: string /** The conversation so far, for which folder instructions it already carries. */ messages?: readonly ChatCompletionMessageParam[] }): Promise<ChatCompletionMessageParam> { + const current = live?.() + const tools: Tool<T>[] = current?.tools ?? snapshotTools + const helpers: T = current ? current.helpers : snapshotHelpers const tool = tools.find((t) => t.def.function.name === toolCall.function.name) const workspaceId = workspace ?? get(workspaceStore) ?? '' diff --git a/frontend/src/lib/components/copilot/chat/skills/skillResources.ts b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts index 7a80705d4e..d28a4d32e8 100644 --- a/frontend/src/lib/components/copilot/chat/skills/skillResources.ts +++ b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts @@ -1,6 +1,7 @@ import { ResourceService } from '$lib/gen' -import { canWrite } from '$lib/utils' -import type { UserExt } from '$lib/stores' +import { canEditItem } from '$lib/editRights' +import { userWorkspaces, type UserExt } from '$lib/stores' +import { get } from 'svelte/store' /** * Skills are resources of this type: a file resource (`format_extension = 'md'`) @@ -53,9 +54,10 @@ export type SkillListing = { skills: SkillResource[]; truncated: boolean } * `user` decides which rows the drawer offers to edit rather than only view; pass * the account the workspace is being browsed as. Ownership is mostly implicit in * the path (`u/<me>/…`, a folder the user owns), which is why this goes through - * the shared `canWrite` rather than reading `extra_perms` alone. */ + * the shared `canEditItem` rather than reading `extra_perms` alone. */ export async function listSkillResources(workspace: string, user?: UserExt): Promise<SkillListing> { if (!workspace) return { skills: [], truncated: false } + const operatorSettings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings const rows: SkillResource[] = [] for (let page = 1; page <= MAX_SKILLS_PAGES; page++) { const resources = await ResourceService.listResource({ @@ -70,7 +72,7 @@ export async function listSkillResources(workspace: string, user?: UserExt): Pro name: skillNameFromPath(r.path), description: r.description ?? '', editedAt: r.edited_at, - canWrite: canWrite(r.path, r.extra_perms ?? {}, user) + canWrite: canEditItem('resource', r.path, r.extra_perms, user, operatorSettings) })) ) if (resources.length < SKILLS_PAGE_SIZE) return { skills: rows, truncated: false } diff --git a/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts b/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts index dd480c5e97..75d996ebef 100644 --- a/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts @@ -1,4 +1,4 @@ -import { BookOpen, List, Plus } from 'lucide-svelte' +import { BookOpen, List, Loader2, Plus } from 'lucide-svelte' import { get } from 'svelte/store' import { userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' @@ -14,6 +14,14 @@ type Row = SkillResource & { enabled: boolean } // scannable, so the rest are reached through the settings modal rather than dropped. const MAX_MENU_SKILLS = 8 +// Not `disabled`: melt stamps that on a row at mount and never clears it, and the +// unkeyed submenu reuses this row for the first skill once the load lands. +const loadingItem: Item = { + displayName: 'Loading…', + icon: Loader2, + iconProps: { class: 'animate-spin shrink-0' } +} + /** * The chat "+" menu's Skills submenu: one row per skill, checked when it is on, * then the way to manage them. Everything a skill is beyond turning it on and off @@ -25,7 +33,7 @@ export class SkillsMenu { #seq = 0 /** Rows for the workspace named by `#rowsWorkspace`, and meaningless for any other. */ #rows = $state<Row[]>([]) - #rowsWorkspace: string | undefined = undefined + #rowsWorkspace = $state<string | undefined>(undefined) constructor(manager: AIChatManager, onManage: () => void) { this.#manager = manager @@ -89,19 +97,21 @@ export class SkillsMenu { await this.#manager.refreshGlobalSkills(ws) } - /** Loaded on open so the checks are current. */ - async items(closeMenu?: () => void): Promise<Item[]> { + /** Called on open so the checks are current; never awaited, see `items`. */ + refresh() { + const ws = this.#ws + if (ws) void this.#load(ws) + } + + /** + * Built from the live rows, so call it from a getter: the submenu then fills in + * when a load lands instead of the whole "+" menu waiting on the fetch. Rows for + * another workspace are not shown — same path, different skill. + */ + items(closeMenu?: () => void): Item[] { const ws = this.#ws if (!ws) return [] - // The menu opens on what is already known and refreshes behind it: awaited - // inline it would stall the whole "+" menu, attachments included. Rows for - // another workspace are not "already known" — same path, different skill. - if (this.#rowsWorkspace !== ws) { - this.#rows = [] - await this.#load(ws) - } else { - void this.#load(ws) - } + if (this.#rowsWorkspace !== ws) return [loadingItem] const ambiguous = ambiguousSkillNames(this.#rows) // By path. Ordering the ones that are on first would put every row in the same // bucket now that skills start that way, and drop the one row it did move — a diff --git a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts index fc019ee983..0b393e6969 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts @@ -28,9 +28,9 @@ export const scheduleRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -80,9 +80,9 @@ export const httpTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -139,9 +139,9 @@ export const websocketTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -185,9 +185,9 @@ export const kafkaTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -217,9 +217,9 @@ export const natsTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -258,9 +258,9 @@ export const postgresTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -300,9 +300,9 @@ export const mqttTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -337,9 +337,9 @@ export const amqpTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -368,9 +368,9 @@ export const sqsTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -408,9 +408,9 @@ export const gcpTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -442,9 +442,9 @@ export const azureTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() @@ -470,9 +470,9 @@ export const emailTriggerRequestSchema = z.object({ "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), - "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), + "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() - }).describe("Retry with exponential backoff (delay doubles each time)").optional(), + }).describe("Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index c53028031d..a9a33e9deb 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -32,6 +32,7 @@ import { formatResourceTypes } from './utils' import { appendPendingToolImages, processToolCall, + type LiveToolSet, queuedToolStatus, type Tool, type ToolCallbacks @@ -71,6 +72,7 @@ interface AIProviderDetails { // the frontier model. The gpt-5 family is deprecated (retires 2026-12-11) but // still served, so it stays in the list below the 5.6 models. const OPENAI_MODELS = [ + 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-astra', 'gpt-6-luna', @@ -1371,6 +1373,7 @@ export async function parseOpenAICompletion( workspace?: string provider?: string onTokenUsage?: (usage: ChatTokenUsage) => void + live?: LiveToolSet } ): Promise<{ shouldContinue: boolean; tokenUsage: ChatTokenUsage }> { const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {} @@ -1582,7 +1585,8 @@ export async function parseOpenAICompletion( helpers, toolCallbacks: callbacks, workspace: options?.workspace, - messages + messages, + live: options?.live }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/reasoningParity.json b/frontend/src/lib/components/copilot/reasoningParity.json index 93b8ac7eb2..1f20def9be 100644 --- a/frontend/src/lib/components/copilot/reasoningParity.json +++ b/frontend/src/lib/components/copilot/reasoningParity.json @@ -47,6 +47,12 @@ "canDisable": false, "completionsToolsNeedOff": true }, + { + "provider": "openai", + "model": "gpt-6.1-sol", + "canDisable": false, + "completionsToolsNeedOff": true + }, { "provider": "openai", "model": "gpt-6-sol", diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 68934c22f3..0cbf935832 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -131,9 +131,10 @@ const BEDROCK_RULES: ReasoningRule[] = [ // Azure names gpt-3.5 `gpt-35-turbo`. `minimal` exists on gpt-5 only, `xhigh` from // gpt-5.5, `max` from gpt-5.6. const OPENAI_RULES: ReasoningRule[] = [ - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. { - match: /(?:^|\/)gpt-6-astra/, + match: /(?:^|\/)gpt-6(?:-astra|\.1-sol)/, levels: LOW_TO_MAX, canDisable: false, completionsToolsNeedOff: true diff --git a/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte index a3a765014a..f4b7f1df60 100644 --- a/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte +++ b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte @@ -1,36 +1,48 @@ <script lang="ts"> import { WorkspaceService, type AclChange, type AclTarget, type DatatableAclInfo } from '$lib/gen' import { resource } from 'runed' - import { Trash2 } from 'lucide-svelte' + import type { Snippet } from 'svelte' + import { Tooltip } from '../meltComponents' + import { KeyRound, Trash2 } from 'lucide-svelte' import { sendUserToast } from '$lib/toast' import { Alert, Button } from '../common' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import Select from '../select/Select.svelte' + import PgGrantBuilder from './PgGrantBuilder.svelte' import DataTable from '../table/DataTable.svelte' import Head from '../table/Head.svelte' import Row from '../table/Row.svelte' import Cell from '../table/Cell.svelte' - import PgGrantBuilder from './PgGrantBuilder.svelte' + import Badge from '../common/badge/Badge.svelte' import { ADMIN_ROLE, blockingSources, grantKey, - grantScopeLabel, + grantCoverage, + scopesOf, groupGrants, revocablePrivileges, revokeScopeOf, - uncoveredCreators + uncoveredCreators, + type AclScope, + type GroupedGrant } from './aclScopes' let { workspace, datatable, - target + target, + disabledReason, + manageRoles }: { workspace: string datatable: string /** What owner and grants are read and written for. */ target: AclTarget + /** Shows owner and grants without letting them change, with this reason on hover. */ + disabledReason?: string + /** Offered at the bottom of every role picker. `blocker` keeps the entry, disabled, with why. */ + manageRoles?: { open: () => void; blocker?: string } } = $props() const acl = resource( @@ -45,6 +57,11 @@ }) ) + /** Re-read owner, grants and the roles they can name, e.g. after the data table's roles changed. */ + export function refresh() { + acl.refetch() + } + // Nothing is written before its SQL has been shown, and the apply runs exactly that SQL: the // server plans again and refuses if the result differs. let pending = $state< @@ -54,7 +71,32 @@ let applying = $state(false) const info: DatatableAclInfo | undefined = $derived(acl.current) + const controlsDisabled = $derived(planning || applying || !!disabledReason) const grantRows = $derived(groupGrants(info?.grants ?? [])) + // Several tags of one kind widen the list; tags of both kinds narrow it to grants matching each. + let roleFilters = $state<string[]>([]) + let privilegeFilters = $state<string[]>([]) + // A selected tag stays offered after its last grant goes, so it can still be cleared. + const roleChips = $derived( + [...new Set([...grantRows.map((g) => g.grantee), ...roleFilters])].sort() + ) + const privilegeChips = $derived( + [...new Set([...grantRows.flatMap((g) => g.privileges), ...privilegeFilters])].sort() + ) + const shownGrantRows = $derived( + grantRows.filter( + (g) => + (roleFilters.length === 0 || roleFilters.includes(g.grantee)) && + (privilegeFilters.length === 0 || g.privileges.some((p) => privilegeFilters.includes(p))) + ) + ) + /** What a grant on the object itself reads as, in the scope picker's words. */ + const targetLabel = $derived(scopesOf(target.kind)[0].label) + // Most grants are on the object itself, which the column would only repeat. + const showCoverage = $derived(grantRows.some((g) => grantCoverage(g, target) !== undefined)) + const columns = $derived(showCoverage ? 4 : 3) + const toggled = (list: string[], value: string) => + list.includes(value) ? list.filter((v) => v !== value) : [...list, value] const ownerItems = $derived( info ? (info.roles.includes(info.owner) ? info.roles : [info.owner, ...info.roles]).map((r) => ({ @@ -68,6 +110,28 @@ pending?.change.type === 'revoke' && (pending.change.objects?.length ?? 0) > 1 ) + /** Why a row's delete button is disabled, or undefined when it can revoke. */ + function unrevocableReason( + grant: GroupedGrant, + revokeScope: AclScope | undefined, + revocable: string[], + blocked: string[] + ): string | undefined { + // What `admin` holds is what every role here connects through. + if (grant.grantee === ADMIN_ROLE) + return 'admin is the connection every role reaches this data table through, so its grants are not changed here' + if (!info?.roles.includes(grant.grantee)) + return `Only grants to this data table's roles are revoked here, and ${grant.grantee} is not one` + if (!revokeScope) return 'Grants on types are read only here' + if (revocable.length === 0) + return grant.future + ? 'Default privileges set database-wide are not revoked from here' + : "CONNECT follows the data table's roles and TEMPORARY is left as Postgres sets it, so nothing here can be revoked" + if (blocked.length > 0) + return `Only ${blocked.join(', ')} can revoke this: Postgres takes a grant back through the role that made it` + return undefined + } + function errorText(e: any): string { return e?.body ?? e?.message ?? String(e) } @@ -108,6 +172,60 @@ } </script> +<!-- A disabled control receives no hover, so the tooltip's trigger is a wrapper the hover falls + through to. --> +{#snippet withDisabledReason(content: Snippet, widthClass: string)} + {#if disabledReason} + <Tooltip class="block {widthClass}" anchor="cursor"> + <div class="pointer-events-none">{@render content()}</div> + {#snippet text()}{disabledReason}{/snippet} + </Tooltip> + {:else} + {@render content()} + {/if} +{/snippet} + +<!-- The home page's filter chips, any number of them selected at once. --> +{#snippet filterChip(label: string, selected: boolean, toggle: () => void)} + <Badge color="transparent" clickable {selected} onclick={toggle}> + {label} + {#if selected}✗{/if} + </Badge> +{/snippet} + +<!-- Styled like the picker's tags in the row that creates a grant. --> +{#snippet tag(text: string)} + <span class="inline-flex items-center min-h-6 px-2 border bg-surface rounded-full text-emphasis"> + {text} + </span> +{/snippet} + +{#snippet manageRolesEntry({ close }: { close: () => void })} + <div class="flex items-center gap-1 border-t p-1"> + <Button + unifiedSize="sm" + variant="subtle" + startIcon={{ icon: KeyRound }} + disabled={!!manageRoles?.blocker} + btnClasses="w-full justify-start" + wrapperClasses="grow" + onClick={() => { + close() + manageRoles?.open() + }} + > + Manage roles + </Button> + {#if manageRoles?.blocker} + <span class="pr-2 flex"> + <Tooltip> + {#snippet text()}{manageRoles?.blocker}{/snippet} + </Tooltip> + </span> + {/if} + </div> +{/snippet} + {#if acl.error} <Alert type="error" title="Could not read access" size="xs">{errorText(acl.error)}</Alert> {:else if !info} @@ -137,22 +255,26 @@ </span> </div> {#if info.editable} - <Select - items={ownerItems} - disabled={planning || applying} - size="sm" - class="w-64" - bind:value={ - () => info.owner, - (role) => { - // The select shows what the database says; a pick is a request, and only the - // applied change moves it. - if (role && role !== info.owner) { - confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`) + {#snippet ownerSelect()} + <Select + items={ownerItems} + disabled={controlsDisabled} + size="sm" + class="w-64" + bind:value={ + () => info.owner, + (role) => { + // The select shows what the database says; a pick is a request, and only the + // applied change moves it. + if (role && role !== info.owner) { + confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`) + } } } - } - /> + bottomSnippet={manageRoles ? manageRolesEntry : undefined} + /> + {/snippet} + {@render withDisabledReason(ownerSelect, 'w-64')} {:else} <span class="font-mono text-xs">{info.owner}</span> {/if} @@ -168,75 +290,76 @@ : 'What each role may do here, beyond what it owns.'} </span> </div> - {#if info.editable} - <PgGrantBuilder - {target} - roles={info.roles} - disabled={planning || applying} - supportsMaintain={info.supports_maintain} - dbname={info.dbname} - onAdd={({ role, privileges, scope }) => - confirm( - { type: 'grant', role, privileges, scope }, - `Granted ${privileges.join(', ')} to ${role}` - )} - /> + <!-- A set filter keeps its chips, or a revoke that leaves one grant would strand it. --> + {#if grantRows.length > 1 || roleFilters.length > 0 || privilegeFilters.length > 0} + <div class="flex flex-wrap items-center gap-2"> + {#each roleChips as role (`role:${role}`)} + {@render filterChip(role, roleFilters.includes(role), () => { + roleFilters = toggled(roleFilters, role) + })} + {/each} + {#each privilegeChips as privilege (`privilege:${privilege}`)} + {@render filterChip(privilege, privilegeFilters.includes(privilege), () => { + privilegeFilters = toggled(privilegeFilters, privilege) + })} + {/each} + </div> {/if} - {#if grantRows.length === 0} - <span class="text-xs text-secondary">No grants yet.</span> - {:else} - <DataTable size="xs"> - <Head> - <tr> - <Cell head first>Role</Cell> - <Cell head>Privileges</Cell> + <DataTable size="xs"> + <Head> + <tr> + <Cell head first>Role</Cell> + <Cell head>Privileges</Cell> + {#if showCoverage} <Cell head>On</Cell> - <Cell head last></Cell> - </tr> - </Head> - <tbody class="divide-y"> - {#each grantRows as grant (grantKey(grant))} - {@const revokeScope = revokeScopeOf(grant)} - {@const revocable = revocablePrivileges(grant, target)} - {@const blocked = blockingSources(grant, revocable)} - {@const uncovered = uncoveredCreators(grant, info.roles)} - <Row> - <Cell first>{grant.grantee}</Cell> - <Cell wrap - ><span class="font-mono text-2xs">{grant.privileges.join(', ')}</span></Cell - > - <Cell wrap> - {grantScopeLabel(grant)} - {#if blocked.length > 0} - <span - class="text-2xs text-secondary" - title="Only this role can take the grant back: Postgres revokes a grant through the role that made it" - > - from {blocked.join(', ')} - </span> - {/if} - {#if uncovered.length > 0} - <span - class="text-2xs text-secondary" - title="A default privilege covers only the roles it was granted for: grant it again to cover these" - > - · not for what {uncovered.join(', ')} - {uncovered.length === 1 ? 'creates' : 'create'} - </span> - {/if} + {/if} + <Cell head last /> + </tr> + </Head> + <tbody class="divide-y"> + {#each shownGrantRows as grant (grantKey(grant))} + {@const revokeScope = revokeScopeOf(grant)} + {@const revocable = revocablePrivileges(grant, target)} + {@const blocked = blockingSources(grant, revocable)} + {@const uncovered = uncoveredCreators(grant, info.roles)} + {@const unrevocable = unrevocableReason(grant, revokeScope, revocable, blocked)} + <Row> + <Cell first class="font-medium text-emphasis">{grant.grantee}</Cell> + <Cell wrap> + <div class="flex flex-wrap items-center gap-1"> + {#each grant.privileges as privilege (privilege)} + {@render tag(privilege)} + {/each} + </div> + {#if blocked.length > 0 || uncovered.length > 0} + <div class="mt-0.5 text-2xs text-secondary"> + {[ + blocked.length > 0 && `granted by ${blocked.join(', ')}`, + uncovered.length > 0 && + `not for what ${uncovered.join(', ')} ${uncovered.length === 1 ? 'creates' : 'create'}: a default privilege only covers the roles it was granted for` + ] + .filter(Boolean) + .join(' · ')} + </div> + {/if} + </Cell> + {#if showCoverage} + <Cell wrap class="text-secondary"> + {grantCoverage(grant, target) ?? targetLabel} </Cell> - <Cell last> - <!-- What `admin` holds is what every role here connects through, so it is not - this editor's to take away. --> - {#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE} + {/if} + <Cell last class="w-10"> + {#if info.editable} + {#snippet revokeButton()} <Button unifiedSize="xs" variant="subtle" iconOnly startIcon={{ icon: Trash2 }} - title="Revoke {revocable.join(', ')}" - disabled={planning || applying} - onClick={() => + title={unrevocable ?? `Revoke ${revocable.join(', ')}`} + disabled={controlsDisabled || !!unrevocable} + onClick={() => { + if (!revokeScope) return confirm( { type: 'revoke', @@ -246,15 +369,52 @@ objects: grant.objects }, `Revoked ${revocable.join(', ')} from ${grant.grantee}` - )} + ) + }} /> + {/snippet} + {#if unrevocable && !disabledReason} + <Tooltip> + <div class="pointer-events-none">{@render revokeButton()}</div> + {#snippet text()}{unrevocable}{/snippet} + </Tooltip> + {:else} + {@render withDisabledReason(revokeButton, 'w-fit')} {/if} - </Cell> - </Row> - {/each} - </tbody> - </DataTable> - {/if} + {/if} + </Cell> + </Row> + {:else} + <Row> + <Cell first last colspan={columns} class="text-secondary"> + {grantRows.length === 0 ? 'No grants yet.' : 'No grant matches the filters.'} + </Cell> + </Row> + {/each} + {#if info.editable} + <!-- The grant to create: a row of the table, set apart until it exists. --> + <Row class="bg-surface-secondary"> + <Cell first last colspan={columns}> + {#snippet grantBuilder()} + <PgGrantBuilder + {target} + roles={info.roles} + disabled={controlsDisabled} + manageRoles={manageRoles ? manageRolesEntry : undefined} + supportsMaintain={info.supports_maintain} + onAdd={({ role, privileges, scope }) => + confirm( + { type: 'grant', role, privileges, scope }, + `Granted ${privileges.join(', ')} to ${role}` + )} + /> + {/snippet} + {@render withDisabledReason(grantBuilder, 'w-full')} + </Cell> + </Row> + {/if} + </tbody> + </DataTable> </section> </div> {/if} diff --git a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte index 5e5119abb6..745c1a1db5 100644 --- a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte +++ b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte @@ -1,17 +1,18 @@ <script lang="ts"> import type { AclTarget } from '$lib/gen' + import type { Snippet } from 'svelte' import { Plus } from 'lucide-svelte' import { Button } from '../common' import Select from '../select/Select.svelte' import MultiSelect from '../select/MultiSelect.svelte' - import { privilegesOf, scopeSql, scopesOf, type AclScope } from './aclScopes' + import { privilegesOf, scopesOf, type AclScope } from './aclScopes' let { target, roles, supportsMaintain = false, - dbname, disabled = false, + manageRoles, onAdd }: { target: AclTarget @@ -19,9 +20,9 @@ roles: string[] /** Postgres 17+, which has one more table privilege to offer. */ supportsMaintain?: boolean - /** Names the database in the statement a database target builds. */ - dbname?: string disabled?: boolean + /** Rendered at the bottom of the role picker, to reach the data table's roles. */ + manageRoles?: Snippet<[{ close: () => void }]> onAdd: (grant: { role: string; privileges: string[]; scope: AclScope }) => void } = $props() @@ -29,26 +30,24 @@ let scope = $state<AclScope>('target') let privileges = $state<string[]>([]) + const scopes = $derived(scopesOf(target.kind)) const available = $derived(privilegesOf(scope, target.kind, supportsMaintain)) - const statement = $derived( - privileges.length && role - ? `GRANT ${privileges.join(', ')} ON ${scopeSql(scope, target, dbname)} TO ${role}` - : undefined - ) </script> -<div class="flex flex-col gap-2 border rounded-md p-3"> - <div class="flex flex-wrap items-center gap-2 text-xs text-secondary"> - <span class="font-mono text-primary">GRANT</span> - <MultiSelect - bind:value={privileges} - items={available.map((p) => ({ value: p, label: p }))} - placeholder="privileges" - {disabled} - size="sm" - class="min-w-48" - /> - <span class="font-mono text-primary">ON</span> +<!-- Laid out like the grants above it, so it reads as the statement it will run. --> +<div class="flex items-center gap-2 text-xs text-secondary"> + <span class="font-mono shrink-0">GRANT</span> + <MultiSelect + bind:value={privileges} + items={available.map((p) => ({ value: p, label: p }))} + placeholder="privileges" + {disabled} + size="md" + class="min-w-0 flex-[4]" + /> + <!-- A database or a table has only itself to grant on. --> + {#if scopes.length > 1} + <span class="font-mono shrink-0">ON</span> <Select bind:value={ () => scope, @@ -61,35 +60,34 @@ privileges = privileges.filter((p) => allowed.includes(p)) } } - items={scopesOf(target.kind)} + items={scopes} {disabled} - size="sm" - class="w-52" + size="md" + class="min-w-0 flex-[3]" /> - <span class="font-mono text-primary">TO</span> - <Select - bind:value={role} - items={roles.map((r) => ({ value: r, label: r }))} - placeholder="role" - {disabled} - size="sm" - class="w-40" - /> - <Button - unifiedSize="sm" - variant="default" - startIcon={{ icon: Plus }} - disabled={disabled || !role || privileges.length === 0} - onClick={() => { - if (!role) return - onAdd({ role, privileges: [...privileges], scope }) - privileges = [] - }} - > - Add - </Button> - </div> - {#if statement} - <pre class="text-2xs text-tertiary overflow-x-auto">{statement}</pre> {/if} + <span class="font-mono shrink-0">TO</span> + <Select + bind:value={role} + items={roles.map((r) => ({ value: r, label: r }))} + placeholder="role" + {disabled} + size="md" + class="min-w-0 flex-[2]" + bottomSnippet={manageRoles} + /> + <Button + unifiedSize="md" + variant="default" + wrapperClasses="shrink-0" + startIcon={{ icon: Plus }} + disabled={disabled || !role || privileges.length === 0} + onClick={() => { + if (!role) return + onAdd({ role, privileges: [...privileges], scope }) + privileges = [] + }} + > + Create + </Button> </div> diff --git a/frontend/src/lib/components/datatableAcl/aclScopes.test.ts b/frontend/src/lib/components/datatableAcl/aclScopes.test.ts index 7c01836e89..aa385ea5af 100644 --- a/frontend/src/lib/components/datatableAcl/aclScopes.test.ts +++ b/frontend/src/lib/components/datatableAcl/aclScopes.test.ts @@ -3,10 +3,12 @@ import type { AclGrant } from '$lib/gen' import { blockingSources, grantKey, + grantCoverage, groupGrants, revocablePrivileges, revokeScopeOf, - uncoveredCreators + uncoveredCreators, + type GroupedGrant } from './aclScopes' const table = (name: string) => ({ name, kind: 'TABLE' }) @@ -176,3 +178,29 @@ describe('uncoveredCreators', () => { ).toEqual([]) }) }) + +describe('grantCoverage', () => { + const schema = { kind: 'schema' as const, schema: 'sales' } + const row = (grant: Partial<GroupedGrant>): GroupedGrant => ({ + grantee: 'analytics', + privileges: ['SELECT'], + objects: [], + sources: [], + ...grant + }) + + it('says nothing for the object itself, and names what is created later', () => { + expect(grantCoverage(row({}), schema)).toBeUndefined() + expect(grantCoverage(row({ future: 'TABLES' }), schema)).toBe('tables created later') + expect(grantCoverage(row({ future: 'TABLES' }), { kind: 'database' })).toBe( + 'tables created later, in every schema' + ) + }) + + it('names a routine with its arguments, and summarizes past three objects', () => { + const routine = row({ objects: [{ name: 'refresh', kind: 'FUNCTION', args: 'integer' }] }) + expect(grantCoverage(routine, schema)).toBe('function refresh(integer)') + const many = row({ objects: ['a', 'b', 'c', 'd', 'e'].map(table) }) + expect(grantCoverage(many, schema)).toBe('tables a, b, c and 2 more') + }) +}) diff --git a/frontend/src/lib/components/datatableAcl/aclScopes.ts b/frontend/src/lib/components/datatableAcl/aclScopes.ts index d5b8ad9d20..4127920c1d 100644 --- a/frontend/src/lib/components/datatableAcl/aclScopes.ts +++ b/frontend/src/lib/components/datatableAcl/aclScopes.ts @@ -73,28 +73,6 @@ export function privilegesOf( } } -/** What a statement built at this scope reads as, for the builder's own preview. */ -export function scopeSql(scope: AclScope, target: AclTarget, dbname?: string): string { - if (target.kind === 'database') return `DATABASE ${dbname ?? ''}`.trim() - const schema = target.schema - switch (scope) { - case 'target': - return target.kind === 'schema' ? `SCHEMA ${schema}` : `TABLE ${schema}.${target.table}` - case 'all_tables': - return `ALL TABLES IN SCHEMA ${schema}` - case 'all_sequences': - return `ALL SEQUENCES IN SCHEMA ${schema}` - case 'all_functions': - return `ALL FUNCTIONS IN SCHEMA ${schema}` - case 'future_tables': - return `TABLES (default privileges in ${schema})` - case 'future_sequences': - return `SEQUENCES (default privileges in ${schema})` - case 'future_functions': - return `FUNCTIONS (default privileges in ${schema})` - } -} - /** One row of the grants table: the same privileges on several objects read as one line, since * granting them per object is what `ON ALL TABLES` does. */ export type GroupedGrant = { @@ -195,17 +173,21 @@ export function revocablePrivileges(grant: GroupedGrant, target: AclTarget): str return grant.privileges } -/** How a row reads back: what it covers, in one phrase. */ -export function grantScopeLabel(grant: GroupedGrant): string { - if (grant.future) return `${grant.future.toLowerCase()} created later` - if (grant.objects.length === 1) { - const object = grant.objects[0] - // A routine's arguments are part of what it is, so two of the same name would otherwise - // read as one row twice. - const args = object.args !== undefined ? `(${object.args})` : '' - return `${object.kind.toLowerCase()} ${object.name}${args}` +/** How many objects a folded row names before it summarizes the rest. */ +const LISTED_OBJECTS = 3 + +/** What a row covers, when that is not the editor's own object: the objects inside it, or what is + * created later. Undefined for a grant on the object itself, which needs no saying. */ +export function grantCoverage(grant: GroupedGrant, target: AclTarget): string | undefined { + if (grant.future) { + const created = `${grant.future.toLowerCase()} created later` + return target.kind === 'database' ? `${created}, in every schema` : created } - if (grant.objects.length > 1) - return `${grant.objects.length} ${grant.objects[0].kind.toLowerCase()}s` - return 'itself' + if (grant.objects.length === 0) return undefined + // A routine's arguments are part of what it is: two of the same name are different objects. + const names = grant.objects.map((o) => (o.args !== undefined ? `${o.name}(${o.args})` : o.name)) + const listed = names.slice(0, LISTED_OBJECTS).join(', ') + const rest = names.length - LISTED_OBJECTS + const kind = grant.objects[0].kind.toLowerCase() + return `${names.length > 1 ? `${kind}s` : kind} ${listed}${rest > 0 ? ` and ${rest} more` : ''}` } diff --git a/frontend/src/lib/components/dbTableFilters.ts b/frontend/src/lib/components/dbTableFilters.ts index 9d9c83e6b0..c3266b3ac7 100644 --- a/frontend/src/lib/components/dbTableFilters.ts +++ b/frontend/src/lib/components/dbTableFilters.ts @@ -52,7 +52,7 @@ export function buildDbTableFilterSchema(columns: ColumnDef[]): DbTableFilterSch const columnOfKey: Record<string, string> = {} const keyOfColumn: Record<string, string> = {} for (const col of columns) { - if (!col.field) continue + if (!col.field || col.filterable === false) continue let base = col.field.replace(/\W+/g, '_') || 'column' if (base === FREE_TEXT_KEY) base = `${base}col` let key = base diff --git a/frontend/src/lib/components/dbTypes.ts b/frontend/src/lib/components/dbTypes.ts index e6d3af12e8..2bb2a92e14 100644 --- a/frontend/src/lib/components/dbTypes.ts +++ b/frontend/src/lib/components/dbTypes.ts @@ -32,6 +32,9 @@ export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includ /** The role every data table has: the one it connects as when it is not under roles. */ export const ADMIN_DATATABLE_ROLE = 'admin' +/** What `default_role` holds for a data table with no default role. Mirrors the backend's + * `NO_DEFAULT_DATATABLE_ROLE`. */ +export const NO_DEFAULT_DATATABLE_ROLE = '$none' /** What the server accepts in `-- role <name>` and `?role=<name>`. */ export function isDatatableRoleName(name: string): boolean { @@ -52,7 +55,13 @@ export function defaultMigrationRole( permissioned: boolean | undefined, defaultRole: string | undefined ): string | undefined { - return permissioned && !datatableNameTakesRole(name) ? defaultRole : undefined + return permissioned && !datatableNameTakesRole(name) ? namedDefaultRole(defaultRole) : undefined +} + +/** A data table's default role, or `undefined` when it has none: the server reports that as + * `NO_DEFAULT_DATATABLE_ROLE`, which is not a role anything can connect as. */ +export function namedDefaultRole(defaultRole: string | undefined): string | undefined { + return defaultRole === NO_DEFAULT_DATATABLE_ROLE ? undefined : defaultRole } /** `datatable://<name>`, with `?role=<role>` when a role is named. Throws rather than build a diff --git a/frontend/src/lib/components/dbt/DbtModelGraph.svelte b/frontend/src/lib/components/dbt/DbtModelGraph.svelte index bd7f42a9e7..12b008812d 100644 --- a/frontend/src/lib/components/dbt/DbtModelGraph.svelte +++ b/frontend/src/lib/components/dbt/DbtModelGraph.svelte @@ -16,7 +16,6 @@ import { Button } from '$lib/components/common' import { Loader2, RefreshCw } from 'lucide-svelte' import { displayDate } from '$lib/utils' - import { base } from '$lib/base' import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte' import type { DbtPreviewBuffer } from './previewRows' import type { @@ -477,7 +476,7 @@ worker tag nothing serves waits here indefinitely. <a class="text-blue-500 hover:underline" - href="{base}/run/{refreshPending}?workspace={workspace}" + href="/run/{refreshPending}?workspace={workspace}" target="_blank" rel="noreferrer">Open the parse job</a > @@ -491,7 +490,7 @@ {#if refreshError.job} <a class="text-blue-500 hover:underline" - href="{base}/run/{refreshError.job}?workspace={workspace}" + href="/run/{refreshError.job}?workspace={workspace}" target="_blank" rel="noreferrer">Open the parse job</a > diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 72c39ba512..30b0d5dab7 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -3,10 +3,10 @@ import DropdownV2 from '$lib/components/DropdownV2.svelte' import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte' - import { twMerge } from 'tailwind-merge' - import { userStore } from '$lib/stores' - import { createEventDispatcher, getContext, tick } from 'svelte' + import { createEventDispatcher, getContext, tick, type Snippet } from 'svelte' import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import type { TriggerContext } from '../triggers' import type { Item } from '$lib/utils' import { Bell, BellOff, Calendar } from 'lucide-svelte' @@ -17,14 +17,16 @@ type MainButton = { label: string - /** Shown under the label once the button has collapsed into a menu. */ + /** Shown under the label once the button is a row of a menu. */ description?: string buttonProps: ButtonProps - /** Where the button lands below the `lg` breakpoint, where the bar cannot hold every - * button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button - * labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every - * width. */ - narrow?: 'menu' | { dropdownOf: string } + /** A secondary action: a bar button while the bar has room for every one, then, from the + * first fold, in the ellipsis menu or in the dropdown of the enabled bar button labelled + * `dropdownOf` (the menu when there is none). Unset makes it a main button, which goes to + * the menu only on the folds after that. */ + placement?: 'menu' | { dropdownOf: string } + /** In its `placement` at every width, never a bar button of its own. */ + alwaysPlaced?: boolean } type ButtonProps = any @@ -37,9 +39,10 @@ } // An agent's page has no triggers, and so no context for them. - const { triggersCount, triggersState } = $state( - getContext<TriggerContext | undefined>('TriggerContext') ?? ({} as Partial<TriggerContext>) - ) + const triggerContext = getContext<TriggerContext | undefined>('TriggerContext') + const { triggersCount, triggersState } = $state(triggerContext ?? ({} as Partial<TriggerContext>)) + // The buttons below render in the page header, away from this page's tree. + const headerContexts = new Map<any, any>([['TriggerContext', triggerContext]]) interface Props { mainButtons?: MainButton[] @@ -49,6 +52,9 @@ tag?: string | undefined /** Unset for what has no workspace error handler to mute, such as an agent. */ errorHandlerKind?: 'flow' | 'script' + /** What the rename popover is renaming, when that is not the error handler's kind — an + * agent has no error handler but is still renamed from here. */ + itemKind?: 'flow' | 'script' | 'agent' scriptOrFlowPath?: string errorHandlerMuted?: boolean | undefined labels?: string[] | undefined @@ -56,12 +62,19 @@ onSaved?: (newPath: string) => void children?: import('svelte').Snippet trigger_badges?: import('svelte').Snippet + /** True for the route's own page: its header becomes the page header. A host that renders + * this inside something else — a session's preview panel — leaves it false and gets the + * row below. */ + ownsPageHeader?: boolean /** Whether the bar is wide enough to hold every button beside the summary, decided by * the layout from its own width. Not a viewport media query: the same page also renders * inside an AI session's preview panel, where the window is wide and the pane is not. */ wide?: boolean /** Controls ahead of the menu, such as the way an agent's page runs it. */ leading_actions?: import('svelte').Snippet + /** Marks that belong to the item's name, such as its favorite star: drawn right after the + * summary rather than among the badges and buttons at the far end. */ + name_marks?: import('svelte').Snippet } let { @@ -71,6 +84,7 @@ path, tag, errorHandlerKind, + itemKind, scriptOrFlowPath, errorHandlerMuted = $bindable(), labels = $bindable(), @@ -78,20 +92,62 @@ onSaved, children, trigger_badges, + ownsPageHeader = false, wide = true, - leading_actions + leading_actions, + name_marks }: Props = $props() const dispatch = createEventDispatcher() - const barButtons = $derived(wide ? mainButtons : mainButtons.filter((b) => !b.narrow)) + // A flow and a script are renamed as what their error handler already names them; an agent has + // no error handler and says so itself. + const renameKind = $derived(itemKind ?? errorHandlerKind) + + // The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below): + // first the secondary ones go to their `placement`, then every bar button but the last, which + // is what the page is for (Edit), then that one too. Outside the band — a session's preview + // panel — no bar measures the row, but the host knows when its own box is narrow while the + // window is not and says so through `wide`: the secondary buttons fold there, as at the first + // fold, and the error handler's toggle goes to the menu. + const fold = $derived(ownsPageHeader ? pageHeader.actionFold : wide ? 0 : 1) + const wideRow = $derived(wide && fold <= 1) + + const rowButtons = $derived(mainButtons.filter((b) => !b.placement)) + const barButtons = $derived( + fold === 0 + ? mainButtons.filter((b) => !b.alwaysPlaced) + : fold === 1 + ? rowButtons + : fold === 2 + ? rowButtons.slice(-1) + : [] + ) function dropdownHost(btn: MainButton): MainButton | undefined { - if (typeof btn.narrow !== 'object') return undefined - const label = btn.narrow.dropdownOf + if (typeof btn.placement !== 'object' || barButtons.includes(btn)) return undefined + const label = btn.placement.dropdownOf return barButtons.find((b) => b.label === label && !b.buttonProps.disabled) } + /** What the menu holds of the buttons: the folded bar buttons, each followed by what its + * dropdown would have held, then the secondary actions with no bar button to hang from. */ + const menuButtons = $derived.by(() => { + const folded = rowButtons.filter((b) => !barButtons.includes(b)) + const homeless = mainButtons.filter( + (b) => b.placement && !barButtons.includes(b) && !dropdownHost(b) + ) + const childOf = (host: MainButton) => + homeless.filter( + (b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label + ) + const placed = new Set(folded.flatMap(childOf)) + return [ + ...folded.flatMap((host) => [host, ...childOf(host)]), + ...homeless.filter((b) => !placed.has(b)) + ] + }) + async function toggleErrorHandler() { if (!errorHandlerKind || !scriptOrFlowPath) return const next = await toggleWorkspaceErrorHandler( @@ -104,7 +160,7 @@ } const allMenuItems: Item[] = $derived([ - ...(wide ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ + ...menuButtons.map((b) => ({ displayName: b.label, description: b.description, icon: b.buttonProps.startIcon, @@ -113,7 +169,7 @@ disabled: b.buttonProps.disabled, type: 'action' as const })), - ...(wide || !errorHandlerKind + ...(wideRow || !errorHandlerKind ? [] : [ { @@ -129,12 +185,11 @@ action: item.onclick, disabled: item.disabled, type: item.color === 'red' ? ('delete' as const) : ('action' as const), - separatorTop: i === 0 && !wide + separatorTop: i === 0 && (menuButtons.length > 0 || (!wideRow && !!errorHandlerKind)) })) ]) function dropdownItemsOf(host: MainButton) { - if (wide) return undefined const items = mainButtons .filter((b) => dropdownHost(b) === host) .map((b) => ({ @@ -149,74 +204,147 @@ } </script> -<div class="border-b"> - <div class="mx-auto"> - <div - class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0" +{#snippet summaryContent()} + <div class="flex items-center gap-1 min-w-0"> + <SummaryPathDisplay + {summary} + {path} + bind:labels + {inheritedLabels} + {onSaved} + kind={renameKind} + compact + /> + {#if name_marks}<span class="flex items-center shrink-0">{@render name_marks()}</span>{/if} + </div> +{/snippet} + +<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path. + with the cursor in the path field. `bind:` cannot be spread, so `labels` is bound twice; both + instances bind the same slot and only one is ever open. --> +{#snippet pathTrigger(pathLabel: Snippet, triggerClass: string)} + <SummaryPathDisplay + label={pathLabel} + {triggerClass} + focusField="path" + {summary} + {path} + bind:labels + {inheritedLabels} + {onSaved} + kind={renameKind} + /> +{/snippet} + +<!-- The two halves of the row, rendered side by side in the band and in the two groups of the + row below. One copy each: a button added to one placement belongs in both. --> +{#snippet badges(size: 'sm' | 'md')} + {#if tag} + <Badge>tag: {tag}</Badge> + {/if} + {@render children?.()} + {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} + {@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)} + <Button + btnClasses="inline-flex" + startIcon={{ icon: Calendar }} + variant="default" + unifiedSize={size} + on:click={async () => { + dispatch('seeTriggers') + await tick() + triggersState.selectedTriggerIndex = primarySchedule + }} > - <div class="grow px-2 inline-flex items-center gap-4 min-w-0"> - <div class={twMerge('min-w-0', $userStore?.operator ? 'pl-10' : '')}> - <SummaryPathDisplay - {summary} - {path} - bind:labels - {inheritedLabels} - {onSaved} - kind={errorHandlerKind} - /> + {$triggersCount?.primary_schedule?.schedule ?? ''} + </Button> + {/if} + {@render trigger_badges?.()} +{/snippet} + +{#snippet controls(size: 'sm' | 'md')} + {@render leading_actions?.()} + {#if allMenuItems.length > 0} + {#key allMenuItems} + <DropdownV2 items={allMenuItems} placement="bottom-end" {size} /> + {/key} + {/if} + {#if wideRow && errorHandlerKind && scriptOrFlowPath} + <ErrorHandlerToggleButton + kind={errorHandlerKind} + {scriptOrFlowPath} + bind:errorHandlerMuted + unifiedSize={size} + /> + {/if} + {#each barButtons as btn (btn.label)} + {@const dropdownItems = dropdownItemsOf(btn)} + <Button + {...btn.buttonProps} + startIcon={{ icon: btn.buttonProps.startIcon }} + unifiedSize={size} + {dropdownItems} + dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined} + btnClasses="flex items-center gap-1 whitespace-nowrap" + > + {btn.label} + </Button> + {/each} +{/snippet} + +{#snippet actions()} + {@render badges('sm')} + {@render controls('sm')} +{/snippet} + +{#if ownsPageHeader} + <!-- The summary keeps its rename-and-labels popover here rather than becoming plain text in + the breadcrumb: renaming a script or flow is done from this page, not from the trail. --> + <PageHeaderContent + item={{ + kind: errorHandlerKind, + path, + // Read-only, the summary control falls back to the path when there is no summary, so the + // path is what names the item there and the trail's own copy can go. + summary: onSaved ? summary : summary || path, + summaryContent, + // Only where this page can save one: without `onSaved` the editor behind it has nothing + // to write, and the band keeps its own segment, which copies the path. + pathTrigger: onSaved ? pathTrigger : undefined + }} + {actions} + actionFolds={3} + earlyActionFolds={1} + contexts={headerContexts} + separator="always" + /> +{:else} + <!-- Nested in a session's preview panel, which has a band of its own above it naming the + session. A page in there draws its own row instead: registering would rename the band to + whatever the panel happens to be showing. --> + <div class="border-b"> + <div class="mx-auto"> + <div + class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0" + > + <div class="grow px-2 inline-flex items-center gap-4 min-w-0"> + <div class="min-w-0"> + <SummaryPathDisplay + {summary} + {path} + bind:labels + {inheritedLabels} + {onSaved} + kind={renameKind} + /> + </div> + {@render name_marks?.()} + {@render badges('md')} + </div> + <div class="flex gap-1 items-center pr-4"> + {@render controls('md')} </div> - {#if tag} - <Badge>tag: {tag}</Badge> - {/if} - {@render children?.()} - {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} - {@const primarySchedule = triggersState.triggers.findIndex( - (t) => t.isPrimary && !t.isDraft - )} - <Button - btnClasses="inline-flex" - startIcon={{ icon: Calendar }} - variant="contained" - color="light" - size="xs" - on:click={async () => { - dispatch('seeTriggers') - await tick() - triggersState.selectedTriggerIndex = primarySchedule - }} - > - {$triggersCount?.primary_schedule?.schedule ?? ''} - </Button> - {/if} - {@render trigger_badges?.()} - </div> - <div class="flex gap-1 items-center pr-4"> - {@render leading_actions?.()} - {#if allMenuItems.length > 0} - {#key allMenuItems} - <DropdownV2 items={allMenuItems} placement="bottom-end" size="md" /> - {/key} - {/if} - {#if wide && errorHandlerKind && scriptOrFlowPath} - <ErrorHandlerToggleButton - kind={errorHandlerKind} - {scriptOrFlowPath} - bind:errorHandlerMuted - /> - {/if} - {#each barButtons as btn (btn.label)} - {@const dropdownItems = dropdownItemsOf(btn)} - <Button - {...btn.buttonProps} - startIcon={{ icon: btn.buttonProps.startIcon }} - {dropdownItems} - dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined} - btnClasses="flex items-center gap-1 whitespace-nowrap" - > - {btn.label} - </Button> - {/each} </div> </div> </div> -</div> +{/if} diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte index 49622c8af2..48e833e2ff 100644 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte +++ b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte @@ -13,9 +13,16 @@ scriptOrFlowPath: string errorHandlerMuted: boolean | undefined iconOnly?: boolean + unifiedSize?: 'xs' | 'sm' | 'md' | 'lg' } - let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props() + let { + kind, + scriptOrFlowPath, + errorHandlerMuted = $bindable(), + iconOnly = true, + unifiedSize = 'md' + }: Props = $props() async function toggleErrorHandler(): Promise<void> { const next = await toggleWorkspaceErrorHandler( @@ -32,7 +39,7 @@ title={errorHandlerMuted === undefined || !errorHandlerMuted ? 'Disable workspace error handler for this script' : 'Enable workspace error handler for this script'} - unifiedSize="md" + {unifiedSize} on:click={toggleErrorHandler} variant="subtle" startIcon={{ diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 0e9f47816b..7bf8fda492 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -23,13 +23,12 @@ import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte' import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte' import { goto } from '$lib/navigation' - import { base } from '$lib/base' import { Badge as HeaderBadge, Alert } from '$lib/components/common' import MoveDrawer from '$lib/components/MoveDrawer.svelte' import RunForm from '$lib/components/RunForm.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useEditRights } from '$lib/operatorWriteRights' import { sendUserToast } from '$lib/toast' import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte' @@ -131,7 +130,7 @@ // A ⌘-click opens these in a new tab, which only the query names the workspace for. const operatingHref = useOperatingWorkspaceHref() const actingUser = $derived(operatingUser.current) - const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() let flow: Flow | undefined = $state() // Derived, not assigned during the load: in a fork the acting user is looked up @@ -345,9 +344,7 @@ } } - // Operators with the builder right author flows out of deployed runnables; every other - // operator is read-only here. - let canAuthorFlow = $derived(!actingUser?.operator || $operatorBuilderFlows) + let canAuthorFlow = $derived(editRights.roleCanAuthor('flow')) let moveDrawer: MoveDrawer | undefined = $state() let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state() @@ -372,9 +369,9 @@ buttons.push({ label: 'Fork', description: `Start a new flow from a copy of this one`, - narrow: { dropdownOf: 'Edit' }, + placement: { dropdownOf: 'Edit' }, buttonProps: { - href: operatingHref(`${base}/flows/add?template=${flow.path}`), + href: operatingHref(`/flows/add?template=${flow.path}`), onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`), variant: 'subtle', unifiedSize: 'md', @@ -393,7 +390,7 @@ buttons.push({ label: editInForkLabel(workspace, $userWorkspaces), description: editInForkDescription('flow', workspace, $userWorkspaces), - narrow: { dropdownOf: 'Edit' }, + placement: { dropdownOf: 'Edit' }, buttonProps: { href: buildForkEditUrl('flow', flow.path, workspace), onClick: (e: Event | undefined) => editInFork(e, flow.path), @@ -411,7 +408,7 @@ buttons.push({ label: `Runs`, buttonProps: { - href: operatingHref(`${base}/runs/${flow.path}`), + href: operatingHref(`/runs/${flow.path}`), onClick: interceptNav(onNavigate, `/runs/${flow.path}`), unifiedSize: 'md', variant: 'subtle', @@ -421,7 +418,7 @@ buttons.push({ label: `History`, - narrow: 'menu', + placement: 'menu', buttonProps: { onClick: () => flowHistory?.open(), unifiedSize: 'md', @@ -434,11 +431,12 @@ return buttons } - // The builder right covers flows only; building an app is still refused to operators. + // Build app makes a low-code app, which no builder right covers. if (!actingUser?.operator) { buttons.push({ label: 'Build app', - narrow: 'menu', + placement: 'menu', + alwaysPlaced: true, buttonProps: { onClick: async () => { const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema) @@ -457,7 +455,7 @@ buttons.push({ label: 'Edit', buttonProps: { - href: operatingHref(`${base}/flows/edit/${path}`), + href: operatingHref(`/flows/edit/${path}`), onClick: interceptNav(onNavigate, `/flows/edit/${path}`), variant: 'accent', unifiedSize: 'md', @@ -656,6 +654,7 @@ {#snippet header({ wide }: { wide: boolean })} <DetailPageHeader {wide} + ownsPageHeader={!embedded} on:seeTriggers={() => { detailLayout?.showTriggers() }} @@ -684,6 +683,12 @@ } : undefined} > + {#snippet name_marks()} + <!-- Favorites are the sidebar's, which lists the navigation workspace only. --> + {#if workspace && flow && workspace === $workspaceStore} + <Star kind="flow" path={flow.path} summary={flow.summary} /> + {/if} + {/snippet} {#snippet trigger_badges()} <TriggersBadge showOnlyWithCount={true} @@ -700,12 +705,6 @@ small={false} /> {/snippet} - {#if workspace && flow} - <!-- Favorites are the sidebar's, which lists the navigation workspace only. --> - {#if workspace === $workspaceStore} - <Star kind="flow" path={flow.path} summary={flow.summary} /> - {/if} - {/if} <OnBehalfOfBadge onBehalfOf={flow?.on_behalf_of} onBehalfOfEmail={flow?.on_behalf_of_email} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 078ab20a47..5880a4d03a 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { JobService, ScriptService, @@ -104,6 +103,7 @@ useOperatingUser, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' + import { useEditRights } from '$lib/operatorWriteRights' import { buildForkEditUrl, editInForkAllowed, @@ -173,6 +173,9 @@ script.workspace_id == workspace && canWrite(script.path, script.extra_perms!, actingUser) ) + const editRights = useEditRights() + // The edit actions also need a role that authors scripts, which no operator has. + const canEdit = $derived(can_write && editRights.roleCanAuthor('script')) let deploymentInProgress = $state(false) let expandedModuleLocks: Record<string, boolean> = $state({}) let expandedModuleCode: Record<string, boolean> = $state({}) @@ -529,9 +532,9 @@ buttons.push({ label: 'Fork', description: `Start a new script from a copy of this one`, - narrow: { dropdownOf: 'Edit' }, + placement: { dropdownOf: 'Edit' }, buttonProps: { - href: operatingHref(`${base}/scripts/add?template=${script.path}`), + href: operatingHref(`/scripts/add?template=${script.path}`), onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`), unifiedSize: 'md', variant: 'subtle', @@ -550,7 +553,7 @@ buttons.push({ label: editInForkLabel(workspace, $userWorkspaces), description: editInForkDescription('script', workspace, $userWorkspaces), - narrow: { dropdownOf: 'Edit' }, + placement: { dropdownOf: 'Edit' }, buttonProps: { href: buildForkEditUrl('script', script.path, workspace), onClick: (e: Event | undefined) => editInFork(e, script.path), @@ -568,7 +571,7 @@ buttons.push({ label: `Runs`, buttonProps: { - href: operatingHref(`${base}/runs/${script.path}`), + href: operatingHref(`/runs/${script.path}`), onClick: interceptNav(onNavigate, `/runs/${script.path}`), unifiedSize: 'md', variant: 'subtle', @@ -576,14 +579,14 @@ } }) - if (!script || actingUser?.operator || !can_write) { + if (!script || !canEdit) { return buttons } if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) { buttons.push({ label: `History`, - narrow: 'menu', + placement: 'menu', buttonProps: { onClick: () => { versionsDrawerOpen = !versionsDrawerOpen @@ -599,7 +602,8 @@ if (!actingUser?.operator) { buttons.push({ label: 'Build app', - narrow: 'menu', + placement: 'menu', + alwaysPlaced: true, buttonProps: { onClick: async () => { const app = createRawAppFromScript(script.path, script.summary, script.schema) @@ -637,7 +641,7 @@ buttons.push({ label: 'Edit', buttonProps: { - href: operatingHref(`${base}${editUrl}`), + href: operatingHref(editUrl), onClick: interceptNav(onNavigate, editUrl), unifiedSize: 'md', startIcon: Pen, @@ -838,6 +842,7 @@ {#snippet header({ wide }: { wide: boolean })} <DetailPageHeader {wide} + ownsPageHeader={!embedded} {mainButtons} menuItems={getMenuItems(script, deployUiSettings)} bind:errorHandlerMuted={ @@ -866,6 +871,12 @@ } : undefined} > + {#snippet name_marks()} + <!-- Favorites are the sidebar's, which lists the navigation workspace only. --> + {#if workspace && script && workspace === $workspaceStore} + <Star kind="script" path={script.path} summary={script.summary} /> + {/if} + {/snippet} {#snippet trigger_badges()} <TriggersBadge showOnlyWithCount={true} @@ -883,12 +894,6 @@ }} /> {/snippet} - {#if workspace && script} - <!-- Favorites are the sidebar's, which lists the navigation workspace only. --> - {#if workspace === $workspaceStore} - <Star kind="script" path={script.path} summary={script.summary} /> - {/if} - {/if} {#if script?.auto_kind === 'wac'} <Popover notClickable> {#snippet text()} @@ -981,7 +986,7 @@ <Alert type="warning" title="Not HEAD"> This hash is not HEAD (latest non-archived version at this path) : <a - href="{base}/scripts/get/{topHash}?workspace={workspace}" + href="/scripts/get/{topHash}?workspace={workspace}" onclick={interceptNav(onNavigate, `/scripts/get/${topHash}`)} >Go to the HEAD of this path</a > @@ -1078,7 +1083,7 @@ {#if Object.keys(script?.schema?.properties ?? {}).length > 0} <AIFormAssistant instructions={promptForAi} - onEditInstructions={can_write && !actingUser?.operator + onEditInstructions={canEdit ? () => onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) : undefined} runnableType="script" diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index f763e92faa..1a4b7e5c26 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -75,4 +75,7 @@ export type FlowBuilderProps = { // EditorHeader's path/breadcrumb row dropped (summary only). Used by the // session preview to save vertical room. condensedHeader?: boolean + /** The editor's path/summary and actions go to the route's page header instead of a bar of + * its own. Only the full-page editor sets it; embedders keep their own bar. */ + ownsPageHeader?: boolean } diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index d75d1b33d3..9ab0ea407f 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { goto } from '$lib/navigation' - import { base } from '$lib/base' import { Button } from '$lib/components/common' import Drawer from '$lib/components/common/drawer/Drawer.svelte' @@ -39,12 +38,12 @@ function selectLowCode() { appTypeModalOpen = false - goto(`${base}/apps/add`) + goto('/apps/add') } function selectFullCode() { appTypeModalOpen = false - goto(`${base}/apps_raw/add`) + goto('/apps_raw/add') } </script> diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index ae848156f5..9f78585cc0 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import { goto } from '$lib/navigation' - import { base } from '$lib/base' import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' import { Button } from '$lib/components/common' @@ -35,7 +34,7 @@ const parsed = wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') $importScriptStore = parsed - await goto(`${base}/scripts/add?import=true`) + await goto('/scripts/add?import=true') wacDrawer?.closeDrawer?.() } @@ -45,22 +44,22 @@ function selectFlowEditor() { flowModalOpen = false - goto(`${base}/flows/add`) + goto('/flows/add') } function selectWacPython() { flowModalOpen = false - goto(`${base}/scripts/add?wac=python`) + goto('/scripts/add?wac=python') } function selectWacTypescript() { flowModalOpen = false - goto(`${base}/scripts/add?wac=typescript`) + goto('/scripts/add?wac=typescript') } function selectPipeline() { flowModalOpen = false - goto(`${base}/pipeline`) + goto('/pipeline') } </script> diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index b76f9bb471..cb6081ef46 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -3,8 +3,8 @@ import { get } from 'svelte/store' import { deepEqual } from 'fast-equals' import { ResourceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' -import { canWrite } from '$lib/utils' -import { userStore } from '$lib/stores' +import { canEditItem, roleCanAuthor } from '$lib/editRights' +import { userStore, userWorkspaces } from '$lib/stores' import { getUserExt } from '$lib/user' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { UserDraft } from '$lib/userDraft.svelte' @@ -339,10 +339,12 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // Same rule the generic resource editor applies. The backend refuses the write // either way, but without this the editor would invite edits it cannot save and // autosave a draft on every keystroke against a resource the reader cannot deploy. - canWriteResource = canWrite( + canWriteResource = canEditItem( + 'agent', r.path, - r.extra_perms ?? {}, - user ?? get(userStore) ?? undefined + r.extra_perms, + user ?? get(userStore) ?? undefined, + get(userWorkspaces).find((w) => w.id === ws)?.operator_settings ) // An agent that exists only as a draft has no deployed value to compare against or // fall back to, and the response's is a synthetic echo of the draft. Leaving the @@ -382,7 +384,11 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { if (loadedFor !== key) return noDeployed = true deployed = undefined - canWriteResource = true + canWriteResource = roleCanAuthor( + 'agent', + get(userStore) ?? undefined, + get(userWorkspaces).find((w) => w.id === ws)?.operator_settings + ) state = { path: name, description: '', @@ -438,6 +444,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // The counter the step card's write-back used to report, from the surface that now owns the // write: a deploy here reaches every flow linking this agent. logReusableAgentUsage(noDeployed ? 'saved' : 'updated') + // A new agent's editor opens on its draft's storage path, so its first save always lands + // elsewhere: that is naming it, not a rename. + const firstSave = noDeployed deployed = submitted noDeployed = false const renamed = submitted.path !== fromPath @@ -456,7 +465,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { sync.discard(fromPath, submitted) // A rename moves the row, so the next load must not reuse the old key. loadedFor = `${ws}:${submitted.path}` - sendUserToast(renamed ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}`) + sendUserToast( + renamed && !firstSave ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}` + ) return submitted.path } diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index 9c2a57b43f..658a7adb77 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -9,7 +9,8 @@ AGENT_CHAT_SCHEMA, agentChatFlow, agentChatGap, - agentChatPath + agentChatPath, + AGENT_STEP_ID } from '../conversations/agentEditorChat' import { runFlowPreview } from '../utils.svelte' import { goto } from '$lib/navigation' @@ -100,7 +101,7 @@ * schema per tool — the linked branch deliberately loads none. Reserved in `forbiddenIds`: * the root shares a flow-state map with the tools, so a tool of this id would lose its * schema to the root's. */ - const AGENT_ID = '__wm_agent_root' + const AGENT_ID = AGENT_STEP_ID const draft = useAgentDraft({ path: () => path, diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index 618c31dbaf..30aa98b65e 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -1,16 +1,8 @@ <script lang="ts"> - import { - ChevronRight, - FlaskConical, - Settings, - FormInput, - History, - MessageSquare, - Save - } from 'lucide-svelte' + import { FlaskConical, Settings, FormInput, History, MessageSquare, Save } from 'lucide-svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { onDestroy, untrack } from 'svelte' + import { onDestroy, untrack, type Snippet } from 'svelte' import { resource } from 'runed' import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte' import PagedContent from '$lib/components/common/modal/PagedContent.svelte' @@ -39,6 +31,10 @@ import AgentSettings from './AgentSettings.svelte' import AgentEvalsModal from './AgentEvalsModal.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' + import { emptyString } from '$lib/utils' + import Tooltip from '$lib/components/Tooltip.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -93,6 +89,9 @@ let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined) let versionDrawer: Drawer | undefined = $state(undefined) let settingsDrawer: Drawer | undefined = $state(undefined) + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state<string | undefined>(undefined) let evalsModal: AgentEvalsModal | undefined = $state(undefined) let saving = $state(false) @@ -136,9 +135,10 @@ let canEvaluate = $derived( draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused ) - // A never-deployed agent is stored at a minted `draft_<uuid>` path, so it is named by the path - // its first deploy will create, as the home list names it. - let shownPath = $derived((draftOnly && draft?.state?.path) || target?.path) + // The draft's path, so a rename shows where it is typed — including a never-deployed agent, + // stored at a minted `draft_<uuid>` path and named by the path its first deploy will create, + // as the home list names it. Falls back to the target while the draft is still loading. + let shownPath = $derived(draft?.state?.path || target?.path) // Where the evals pane is within itself, so its levels extend this dialog's trail rather than // opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on @@ -312,34 +312,23 @@ {@render body()} </Modal> {:else} + <!-- On its own route the editor's header is the page header: the agent's path is the + breadcrumb, the level it is on reads after that name, and its controls are the band's + actions. --> + <PageHeaderContent + item={{ + path: pathSnapshot ?? shownPath, + summary: draft?.state?.description, + summaryContent: agentSummary, + // Only where the rename behind it can be saved: a reader who cannot write this agent + // keeps the segment that copies the path, rather than an editor with nothing to edit. + pathTrigger: readOnly ? undefined : agentPathTrigger + }} + afterName={agentHint} + actions={settings} + separator="always" + /> <div class="h-full min-h-0 flex flex-col"> - <div class="flex items-center gap-2 px-4 py-2 border-b shrink-0"> - <div class="min-w-0 flex flex-col"> - <div class="flex items-center gap-1 min-w-0"> - {#each trail as segment, i (i)} - {#if i > 0} - <ChevronRight size={14} class="text-tertiary shrink-0" /> - {/if} - {#if segment.onclick} - <Button variant="subtle" unifiedSize="sm" onClick={segment.onclick}> - {segment.label} - </Button> - {:else} - <span class="text-sm font-semibold text-emphasis truncate">{segment.label}</span> - {/if} - {#if i === 0} - {@render titleBadge()} - {/if} - {/each} - {@render levelBadge()} - </div> - {#if description} - <span class="text-2xs text-tertiary truncate">{description}</span> - {/if} - </div> - <div class="grow"></div> - {@render settings()} - </div> <div class="flex-1 min-h-0 px-4 sm:px-6"> {@render body()} </div> @@ -347,6 +336,95 @@ {/if} {/key} + <!-- The summary's editor again, hung off the band's path segment so it opens under the path, + with the cursor in the path field. `bind:` cannot be spread, so the slots are written out + twice; both instances bind the same ones and only one is ever open. --> + {#snippet agentPathTrigger(pathLabel: Snippet, triggerClass: string)} + <!-- Gated like the summary beside it: this renders from the band's tree, where `draft` is + undefined for as long as the modal has not loaded one, and a getter that reads through it + throws there rather than where it was written. The `{:else}` carries the whole load of + that wait: the band gave up its own segment to this snippet, so without it the trail + would show no path until the draft arrives. --> + {#if draft?.state} + <PathEditPopover + label={pathLabel} + {triggerClass} + focusField="path" + bind:summary={ + () => draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + /> + {:else} + <span class={triggerClass}>{@render pathLabel()}</span> + {/if} + {/snippet} + + {#snippet agentSummary()} + <!-- What the agent is, beside what it is called, and clicking it renames both — the same + shape the script, flow and app editors carry. The version rides along, as the + linked-agent card in the step panel has it. The trail's levels are not here: this layout + opens settings in a drawer and evals over the page, so it never stands on one. --> + <div class="flex items-center gap-1 min-w-0"> + {#if draft?.state} + {#snippet summaryText()} + <span + class="min-w-0 truncate text-xs {emptyString(draft.state?.description) + ? 'text-tertiary italic font-normal' + : 'font-medium text-emphasis'}" + title={draft.state?.description} + >{emptyString(draft.state?.description) + ? 'Add a summary...' + : draft.state?.description}</span + > + {/snippet} + <PathEditPopover + label={summaryText} + bind:summary={ + () => draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + penVisibility={emptyString(draft.state.description) ? 'always' : 'hover'} + /> + {/if} + {@render titleBadge()} + </div> + {/snippet} + {#snippet agentHint()} + {#if description} + <Tooltip>{description}</Tooltip> + {/if} + {/snippet} {#snippet titleBadge()} <!-- Against the agent's own name wherever it appears, as the linked-agent card in the step panel has it. --> diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index dc7c0948eb..3359f20080 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -8,8 +8,8 @@ import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import { ResourceService, type InputTransform, type Resource } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { userStore } from '$lib/stores' - import { canWrite } from '$lib/utils' + import { userStore, userWorkspaces } from '$lib/stores' + import { canEditItem } from '$lib/editRights' import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte' import { AGENT_BRAIN_KEYS, @@ -235,8 +235,13 @@ let unavailable = $derived(linkedInfo?.unavailable ?? false) let canEditAgent = $derived( !!agent && - !$userStore?.operator && - canWrite(agent, linkedInfo?.extraPerms ?? {}, $userStore ?? undefined) + canEditItem( + 'agent', + agent, + linkedInfo?.extraPerms, + $userStore ?? undefined, + $userWorkspaces.find((w) => w.id === ws)?.operator_settings + ) ) // The hint flips on the first keystroke in the agent editor, so the badge does not wait for the // debounced autosave and the refetch behind it; the fetched answer covers a draft written diff --git a/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte b/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte index 979717dae1..9002e88adf 100644 --- a/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte +++ b/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte @@ -13,7 +13,6 @@ type ResolvedExpandedSubflowStep } from '../expandedSubflowStep' import { parseExpandedSubflowId } from '$lib/components/restartFromStepPath' - import { base } from '$app/paths' import FlowPanelChrome from '../common/FlowPanelChrome.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -122,7 +121,7 @@ title="Open the subflow in a new tab" startIcon={{ icon: ExternalLink }} iconOnly - href={`${base}/flows/edit/${containingFlowPath}?workspace=${encodeURIComponent( + href={`/flows/edit/${containingFlowPath}?workspace=${encodeURIComponent( opWs ?? '' )}${module ? `&selected=${encodeURIComponent(leafId)}` : ''}`} target="_blank" diff --git a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte index 6b4d73c6a0..c6c1d13b2a 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte @@ -11,7 +11,6 @@ import { initFlow } from '$lib/components/flows/flowStore.svelte' import type { FlowState } from '$lib/components/flows/flowState' import type { FlowEditorContext } from '../types' - import { base } from '$app/paths' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -127,7 +126,7 @@ // Follow the drawer's own selection, which the user may have moved since it opened. const stepId = flowBuilder?.getSelectedId() ?? selectedStepId const selected = stepId ? `?selected=${encodeURIComponent(stepId)}` : '' - window.open(`${base}/flows/edit/${flowPath}${selected}`, '_blank', 'noopener,noreferrer') + window.open(`/flows/edit/${flowPath}${selected}`, '_blank', 'noopener,noreferrer') flowEditorDrawer?.closeDrawer() }} startIcon={{ icon: ExternalLink }} diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index f86d870a1a..41d860530a 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -291,6 +291,18 @@ }) }} /> + <FlowScriptPicker + label="Pi" + lang="pisandbox" + on:click={() => { + dispatch('new', { + language: 'bun', + kind, + subkind: 'pisandbox', + summary + }) + }} + /> </div> {/if} diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index 0b78193c6a..ecb3187c5c 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -267,28 +267,33 @@ ) let aiLength = $derived(showAiRows ? 2 : 0) - // Must match the heading and label rendered for the AI Sandbox section const aiSandboxHeading = 'AI Sandbox' - const aiSandboxLabel = 'Claude Code' - let matchesAiSandbox = $derived.by(() => { + const aiSandboxes = [ + { label: 'Claude Code', subkind: 'claudesandbox' }, + { label: 'Pi', subkind: 'pisandbox' } + ] as const + // A query matching the heading keeps every sandbox, otherwise only the ones it names. + let matchingAiSandboxes = $derived.by(() => { const query = funcDesc?.trim().toLowerCase() ?? '' - if (query.length == 0) { - return true - } - return [aiSandboxHeading, aiSandboxLabel].some((term) => { + const matches = (term: string) => { const lowered = term.toLowerCase() return lowered.startsWith(query) || lowered.split(' ').some((w) => w.startsWith(query)) - }) + } + if (query.length == 0 || matches(aiSandboxHeading)) { + return aiSandboxes + } + return aiSandboxes.filter((s) => matches(s.label)) }) - // Gates the AI Sandbox row and its slot in the index space; both must agree or arrow keys land - // on an index that renders nothing. - let showAiSandbox = $derived( + // Gates the AI Sandbox rows and their slots in the index space; both must agree or arrow keys + // land on an index that renders nothing. + let aiSandboxRows = $derived( selectedKind === 'script' && preFilter === 'all' && !selected && customUi?.aiSandbox != false && - !$operatorBuilderFlows && - matchesAiSandbox + !$operatorBuilderFlows + ? matchingAiSandboxes + : [] ) // Hub runnables carry code the workspace never reviewed, and the backend refuses them in a @@ -301,7 +306,7 @@ let aiOffset = $derived(inlineOffset + (inlineScripts?.length ?? 0)) let workspaceOffset = $derived(aiOffset + aiLength) let aiSandboxOffset = $derived(workspaceOffset + (filteredWorkspaceItems?.length ?? 0)) - let hubOffset = $derived(aiSandboxOffset + (showAiSandbox ? 1 : 0)) + let hubOffset = $derived(aiSandboxOffset + aiSandboxRows.length) </script> <svelte:window onkeydown={onKeyDown} /> @@ -530,27 +535,29 @@ {/await} <div class="pb-1"></div> {/if} - {#if showAiSandbox} + {#if aiSandboxRows.length > 0} <div class="pb-0 text-2xs font-normal text-secondary ml-2">{aiSandboxHeading}</div> - <FlowScriptPickerQuick - eeRestricted={false} - selected={selectedByKeyboard === aiSandboxOffset} - onHover={() => hover(aiSandboxOffset)} - enterpriseLangs={[]} - label={aiSandboxLabel} - lang="claudesandbox" - on:click={() => { - dispatch('new', { - kind: selectedKind, - inlineScript: { - language: 'bun', + {#each aiSandboxRows as sandbox, i (sandbox.subkind)} + <FlowScriptPickerQuick + eeRestricted={false} + selected={selectedByKeyboard === aiSandboxOffset + i} + onHover={() => hover(aiSandboxOffset + i)} + enterpriseLangs={[]} + label={sandbox.label} + lang={sandbox.subkind} + on:click={() => { + dispatch('new', { kind: selectedKind, - subkind: 'claudesandbox', - summary - } - }) - }} - /> + inlineScript: { + language: 'bun', + kind: selectedKind, + subkind: sandbox.subkind, + summary + } + }) + }} + /> + {/each} {/if} {#if showHub && selectedKind != 'preprocessor' && selectedKind != 'flow'} {#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')} diff --git a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte index 6dde4b8575..e80408c8cf 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte @@ -20,7 +20,7 @@ let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi') </script> -<div class="flex shrink-0 flex-row gap-2 whitespace-nowrap"> +<div class="flex shrink-0 flex-row items-center gap-2 whitespace-nowrap"> {#if module.value.type === 'script'} {#if customUi?.tagEdit != false} <FlowModuleWorkerTagSelect diff --git a/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts b/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts index c00c1e4297..99db5b8a9f 100644 --- a/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts +++ b/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { FlowModule } from '$lib/gen' -import { agentChatFlow, agentChatGap, agentChatPath } from './agentEditorChat' +import { agentChatFlow, agentChatGap, agentChatPath, agentStepLabel } from './agentEditorChat' describe('agentChatPath', () => { it('is a path no flow can take, so a same-path flow never shares its conversations', () => { @@ -10,6 +10,14 @@ describe('agentChatPath', () => { }) }) +describe('agentStepLabel', () => { + it("names a run's agent step by the agent, a chat turn's included", () => { + expect(agentStepLabel('f/support/agent')).toBe('f/support/agent') + expect(agentStepLabel(agentChatPath('f/support/agent'))).toBe('f/support/agent') + expect(agentStepLabel(undefined)).toBe('AI Agent') + }) +}) + function agent(inputTransforms: Record<string, any>): FlowModule { return { id: '__wm_agent_root', diff --git a/frontend/src/lib/components/flows/conversations/agentEditorChat.ts b/frontend/src/lib/components/flows/conversations/agentEditorChat.ts index b754e04bb8..0f1bc86bda 100644 --- a/frontend/src/lib/components/flows/conversations/agentEditorChat.ts +++ b/frontend/src/lib/components/flows/conversations/agentEditorChat.ts @@ -13,6 +13,15 @@ export function agentChatPath(agentPath: string): string { return `${agentPath}.chat` } +/** The id of the one step a saved agent's run is made of, as the server names it too: job-read + * scoping and the run's agent detection match on it, so it is only ever relabeled for display. */ +export const AGENT_STEP_ID = '__wm_agent_root' + +/** What a run shows for that step: the agent it ran, read off the run's path. */ +export function agentStepLabel(runPath: string | undefined): string { + return runPath?.replace(/\.chat$/, '') || 'AI Agent' +} + /** * The inputs of the flow a saved agent is chatted with: the message, and the files the composer's * paperclip uploads. `user_message` is the name the server requires of a chat-mode run. diff --git a/frontend/src/lib/components/flows/conversations/messageAttachments.ts b/frontend/src/lib/components/flows/conversations/messageAttachments.ts index 36c7d103c3..18170a90df 100644 --- a/frontend/src/lib/components/flows/conversations/messageAttachments.ts +++ b/frontend/src/lib/components/flows/conversations/messageAttachments.ts @@ -1,6 +1,5 @@ /** The files a user message carried, as the lanes its bubble renders: image thumbnails and file chips. */ import type { ChatAttachment } from 'windmill-chat' -import { base } from '$lib/base' import { createAttachedFileContextElement, type ContextElement @@ -24,7 +23,7 @@ function looksLikeImage(attachment: ChatAttachment): boolean { function downloadUrl(workspace: string, attachment: ChatAttachment): string { const params = new URLSearchParams({ file_key: attachment.s3 }) if (attachment.storage) params.set('storage', attachment.storage) - return `${base}/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}` + return `/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}` } /** Without a workspace there is no link to build, so an image falls back to a file chip. */ diff --git a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts index 9d85a07452..030897a63b 100644 --- a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts +++ b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts @@ -108,7 +108,7 @@ export async function pickFlow( export async function createInlineScriptModule( language: RawScript['language'], kind: Script['kind'], - subkind: 'pgsql' | 'flow' | 'claudesandbox' | undefined, + subkind: 'pgsql' | 'flow' | 'claudesandbox' | 'pisandbox' | undefined, id: string, summary?: string ): Promise<[FlowModule, FlowModuleState]> { diff --git a/frontend/src/lib/components/flows/linkedAgentDrafts.ts b/frontend/src/lib/components/flows/linkedAgentDrafts.ts index 4a9aef24b1..9367b21b12 100644 --- a/frontend/src/lib/components/flows/linkedAgentDrafts.ts +++ b/frontend/src/lib/components/flows/linkedAgentDrafts.ts @@ -3,11 +3,12 @@ import { type FlowModule, type FlowValue, type InputTransform, + type OperatorSettings, type Resource } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' -import { canWrite } from '$lib/utils' +import { canEditItem } from '$lib/editRights' import type { UserExt } from '$lib/stores' import { dfs } from './dfs' import { flowLocalInputs, type AIAgentConfig } from './agentResourceUtils' @@ -122,11 +123,15 @@ export interface LinkedAgentDraft { extraPerms: Record<string, boolean> } -/** Whether `user` may write this agent's resource. Split from the load so that resolving the - * drafts of a whole flow costs no `whoami` — only the deploy dialog needs the answer, and it - * looks the user up once for every agent it lists. */ -export function agentDraftCanWrite(draft: LinkedAgentDraft, user: UserExt | undefined): boolean { - return canWrite(draft.path, draft.extraPerms, user) +/** Whether `user` may write this agent's resource, in a workspace with these `operator_settings`. + * Split from the load so that resolving the drafts of a whole flow costs no `whoami` — only the + * deploy dialog needs the answer, and it looks the user up once for every agent it lists. */ +export function agentDraftCanWrite( + draft: LinkedAgentDraft, + user: UserExt | undefined, + operatorSettings: OperatorSettings | undefined +): boolean { + return canEditItem('agent', draft.path, draft.extraPerms, user, operatorSettings) } /** A link that cannot resolve for the user rather than because something went wrong: the agent was diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte index a0ec4759f5..c87e20139e 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte @@ -4,6 +4,7 @@ import type { FlowNodeColorClasses } from '$lib/components/graph' import { twMerge } from 'tailwind-merge' import { getContext } from 'svelte' + import { AGENT_STEP_ID } from '../conversations/agentEditorChat' // Detached mode (sessions): a step's editor is hidden until asked for, so add the // hint to the name tooltip. Provided by FlowEditor; absent (=> false) elsewhere. @@ -69,7 +70,8 @@ </Popover> <div class="flex items-center space-x-2 relative" bind:clientWidth={idBadgeWidth}> - {#if id && id !== 'preprocessor' && !id.startsWith('failure') && !id.startsWith('subflow:')} + <!-- A saved agent's run is that one step, already named by the run page. --> + {#if id && id !== 'preprocessor' && id !== AGENT_STEP_ID && !id.startsWith('failure') && !id.startsWith('subflow:')} <Badge color="transparent" class="border-none" diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 9cf4bcd264..62ec111b6c 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -117,17 +117,21 @@ logReusableAgentUsage('linked') dispatch('new', { kind: 'aiagent', agentPath }) } - function newClaudeSandbox() { + function newAiSandbox(subkind: 'claudesandbox' | 'pisandbox') { dispatch('close') dispatch('new', { kind: 'script', inlineScript: { language: 'bun', kind: 'script', - subkind: 'claudesandbox' + subkind } }) } + const aiSandboxes = [ + { label: 'Claude Code', subkind: 'claudesandbox' }, + { label: 'Pi', subkind: 'pisandbox' } + ] as const // Rows of the AI panes in render order. FlowInputsQuick owns the keyboard index for the other // panes and is unmounted while these render, so the two never fight over Enter or the arrows. @@ -137,7 +141,7 @@ return [newBlankAgent, ...saved.map((a) => () => newLinkedAgent(a.path))] } if (selectedKind === 'aisandbox') { - return [newClaudeSandbox] + return aiSandboxes.map((s) => () => newAiSandbox(s.subkind)) } return [] }) @@ -418,14 +422,16 @@ </div> {:else if selectedKind === 'aisandbox'} <div class="h-full overflow-auto grow min-w-0 p-2 gap-1 flex flex-col"> - <TopLevelNode - label="Claude Code" - neutral - returnIcon - selected={aiSelected === 0} - onSelect={newClaudeSandbox} - onHover={() => (selectedByKeyboard = 0)} - /> + {#each aiSandboxes as sandbox, i (sandbox.subkind)} + <TopLevelNode + label={sandbox.label} + neutral + returnIcon + selected={aiSelected === i} + onSelect={() => newAiSandbox(sandbox.subkind)} + onHover={() => (selectedByKeyboard = i)} + /> + {/each} </div> {:else} <FlowInputsQuick diff --git a/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte b/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte index 93909d13f3..0ae64f3a69 100644 --- a/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte +++ b/frontend/src/lib/components/flows/pickers/FlowScriptPicker.svelte @@ -8,7 +8,7 @@ interface Props { disabled?: boolean label: string - lang?: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | undefined + lang?: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | 'pisandbox' | undefined id?: string | undefined } diff --git a/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte b/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte index 3ac279556f..673fce2525 100644 --- a/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte +++ b/frontend/src/lib/components/flows/pickers/FlowScriptPickerQuick.svelte @@ -7,7 +7,7 @@ interface Props { label: string - lang?: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | undefined + lang?: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | 'pisandbox' | undefined selected?: boolean eeRestricted: boolean enterpriseLangs?: string[] diff --git a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte index 0c58f0fc73..a8f3e040c4 100644 --- a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte +++ b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte @@ -52,6 +52,7 @@ 'AI Decision': { icon: Scale, iconClass: 'text-ai' }, 'AI Sandbox': { icon: BotIcon, showChevron: true, iconClass: 'text-ai' }, 'Claude Code': { icon: BotIcon, iconClass: 'text-ai' }, + Pi: { icon: BotIcon, iconClass: 'text-ai' }, MCP: { icon: Plug }, 'Web Search': { icon: Globe } } diff --git a/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte b/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte index 507e74ba86..ecc8e07b11 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import type { Job } from '$lib/gen' import { twMerge } from 'tailwind-merge' - import { base } from '$lib/base' import { ExternalLink } from 'lucide-svelte' import { emptyString } from '$lib/utils' @@ -42,7 +41,7 @@ {#if !emptyString(job.id)} <a target="_blank" - href="{base}/run/{job.id}?workspace={job.workspace_id}" + href="/run/{job.id}?workspace={job.workspace_id}" class="text-right float-right text-gray-300 group-hover:text-gray-400 transition-all duration-200 dark:text-gray-500 dark:group-hover:text-gray-300" title="See run detail in a new tab" > diff --git a/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte b/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte index aa9b14b762..e63db4e960 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte @@ -28,7 +28,6 @@ import { twMerge } from 'tailwind-merge' import DisplayResultControlBar from '$lib/components/DisplayResultControlBar.svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import { base } from '$lib/base' import { fade } from 'svelte/transition' import type { FlowEditorContext, OutputViewerJob } from '../types' import { NEVER_TESTED_THIS_FAR } from '../models' @@ -564,7 +563,6 @@ <DisplayResultControlBar workspaceId={selectedJob.workspace_id} jobId={selectedJob.id} - {base} result={selectedJob.result} disableTooltips={false} on:open-drawer={() => { @@ -576,7 +574,6 @@ {:else if mock?.enabled && displayResultMock && toolbarLocationMock === 'external'} <DisplayResultControlBar customUi={{ disableDownload: true }} - {base} result={mock.return_value} disableTooltips={false} on:open-drawer={() => { diff --git a/frontend/src/lib/components/git_sync/GitPushPreview.svelte b/frontend/src/lib/components/git_sync/GitPushPreview.svelte index bed6d741d1..25207b767c 100644 --- a/frontend/src/lib/components/git_sync/GitPushPreview.svelte +++ b/frontend/src/lib/components/git_sync/GitPushPreview.svelte @@ -14,6 +14,7 @@ buttons — the setup dialog puts them in its own footer, the standalone modal i import hubPaths from '$lib/hubPaths.json' import { jobManager } from '$lib/services/JobManager' import type { SyncResponse, SettingsObject } from '$lib/git-sync' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' interface Props { gitRepoResourcePath: string @@ -39,7 +40,7 @@ buttons — the setup dialog puts them in its own footer, the standalone modal i let showCliInstructions = $state(false) const cliInstructions = $derived(`# Setup (only needed if local folder not initialized yet) npm install -g windmill-cli -wmill workspace add ${$workspaceStore} ${$workspaceStore} ${window.location.origin} +wmill workspace add ${$workspaceStore} ${$workspaceStore} ${apiBaseUrl()}/ wmill init --workspace ${$workspaceStore} --repository ${gitRepoResourcePath} # Pull workspace content to git repository diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 04b23b60c3..2ec6696da5 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -12,7 +12,6 @@ import type { GitSyncRepository } from './GitSyncContext.svelte' import GitSyncModalManager from './GitSyncModalManager.svelte' import { enterpriseLicense, workspaceStore, userWorkspaces } from '$lib/stores' - import { base } from '$lib/base' import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { apiErrorMessage } from '$lib/utils' @@ -106,7 +105,7 @@ const devPairingHref = $derived.by(() => { const parent = currentWorkspace?.parent_workspace_id if (!parent || !$userWorkspaces?.some((w) => w.id === parent)) return undefined - return `${base}/workspace_settings?workspace=${parent}&tab=dev_workspace` + return `/workspace_settings?workspace=${parent}&tab=dev_workspace` }) const repositories = $derived(gitSyncContext?.repositories ?? []) diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index e5741137d7..98a03f8c0f 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -38,7 +38,7 @@ export type InsertKind = export type InlineScript = { language: RawScript['language'] kind: Script['kind'] - subkind: 'pgsql' | 'flow' | 'claudesandbox' + subkind: 'pgsql' | 'flow' | 'claudesandbox' | 'pisandbox' summary?: string instructions?: string } diff --git a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte index fecf82ee23..6a4d94db1f 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte @@ -3,6 +3,7 @@ import { Handle, Position } from '@xyflow/svelte' import { twMerge } from 'tailwind-merge' import ContextMenu, { type ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte' + import { contextMenuItemsFromMenu } from '../../../common/contextmenu/fromMenuItems' import { getGraphContext } from '../../graphContext' import type { Item } from '$lib/utils' @@ -28,21 +29,7 @@ }: Props = $props() let resolvedContextMenuItems: ContextMenuItem[] | undefined = $derived( - contextMenuItems ?? - menuItems?.flatMap((item) => [ - ...(item.separatorTop - ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] - : []), - { - id: item.displayName, - label: item.displayName, - icon: item.icon, - disabled: item.disabled, - type: item.type, - shortcut: item.shortcut, - onClick: item.action as (() => void) | undefined - } - ]) + contextMenuItems ?? (menuItems && contextMenuItemsFromMenu(menuItems)) ) // NodeWrapper is reused outside the flow graph (e.g. the app decision-tree diff --git a/frontend/src/lib/components/handlerDraft.svelte.ts b/frontend/src/lib/components/handlerDraft.svelte.ts new file mode 100644 index 0000000000..a1422e42ed --- /dev/null +++ b/frontend/src/lib/components/handlerDraft.svelte.ts @@ -0,0 +1,118 @@ +import type { ErrorHandler } from '$lib/gen' +import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts' +import hubPaths from '$lib/hubPaths.json' + +export type HandlerMode = 'error' | 'recovery' | 'success' +type BuiltinDestination = 'slack' | 'teams' | 'email' + +export type HandlerSlot = { path?: string; args: Record<string, any> } +export type HandlerDraft = { + selected: ErrorHandler + slack: HandlerSlot + teams: HandlerSlot + email: HandlerSlot + custom: HandlerSlot & { kind: 'script' | 'flow' } +} +type StoredHandler = { path?: string; kind: 'script' | 'flow'; args: Record<string, any> } + +const SLACK_BOT_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH + +// Suffixes rather than full paths, so a handler stored under an older hub id is still recognised. +// Email uses one script for every mode. +const EMAIL_SUFFIX = '/workspace-or-error-handler-email' +const BUILTIN_SUFFIXES: Record<HandlerMode, Record<'slack' | 'teams', string>> = { + error: { + slack: '/workspace-or-schedule-error-handler-slack', + teams: '/workspace-or-schedule-error-handler-teams' + }, + recovery: { + slack: '/schedule-recovery-handler-slack', + teams: '/schedule-recovery-handler-teams' + }, + success: { + slack: '/schedule-success-handler-slack', + teams: '/schedule-success-handler-teams' + } +} + +// The only settings each built-in hub script takes besides the ones the backend injects. +const BUILTIN_KEYS: Record<BuiltinDestination, string[]> = { + slack: ['channel'], + teams: ['channel', 'channel_name'], + email: ['email_recipients'] +} + +export function builtinHubPath(mode: HandlerMode, destination: 'slack' | 'teams'): string { + const key = + `${destination}${mode[0].toUpperCase()}${mode.slice(1)}Handler` as keyof typeof hubPaths + return hubPaths[key] +} + +export function handlerTypeForPath(mode: HandlerMode, path: string | undefined): ErrorHandler { + if (path?.startsWith('hub/')) { + if (path.endsWith(EMAIL_SUFFIX)) return 'email' + for (const [destination, suffix] of Object.entries(BUILTIN_SUFFIXES[mode])) { + if (path.endsWith(suffix)) return destination as BuiltinDestination + } + } + return 'custom' +} + +function emptyDraft(selected: ErrorHandler): HandlerDraft { + return { + selected, + slack: { args: {} }, + teams: { args: {} }, + email: { path: hubPaths.emailErrorHandler, args: {} }, + custom: { kind: 'script', args: {} } + } +} + +function pick(args: Record<string, any>, keys: string[]): Record<string, any> { + return Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]])) +} + +/** Puts a stored handler into the slot of its destination; every other slot starts empty. */ +export function handlerDraftFromStored( + mode: HandlerMode, + path: string | undefined, + kind: 'script' | 'flow' = 'script', + args: Record<string, any> | null | undefined = {}, + opts: { asCustom?: boolean; emptySelected?: ErrorHandler } = {} +): HandlerDraft { + if (!path) return emptyDraft(opts.emptySelected ?? 'custom') + const destination = opts.asCustom ? 'custom' : handlerTypeForPath(mode, path) + const draft = emptyDraft(destination) + if (destination === 'custom') { + draft.custom = { path, kind, args: { ...(args ?? {}) } } + } else if (destination !== 'instance_alerts') { + draft[destination] = { path, args: pick(args ?? {}, BUILTIN_KEYS[destination]) } + } + return draft +} + +/** What a save stores: the selected destination only. */ +export function handlerDraftToStored(draft: HandlerDraft): StoredHandler { + const none: StoredHandler = { kind: 'script', args: {} } + switch (draft.selected) { + case 'custom': { + const { path, kind, args } = draft.custom + return path ? { path, kind, args: $state.snapshot(args) } : none + } + case 'slack': + case 'teams': + case 'email': { + const { path, args } = draft[draft.selected] + if (!path) return none + const stored = { path, kind: 'script' as const, args: $state.snapshot(args) } + if (draft.selected === 'slack') stored.args.slack = SLACK_BOT_ARG + return stored + } + default: + return none + } +} + +export function cloneHandlerDraft(draft: HandlerDraft): HandlerDraft { + return $state.snapshot(draft) as HandlerDraft +} diff --git a/frontend/src/lib/components/handlerDraft.test.ts b/frontend/src/lib/components/handlerDraft.test.ts new file mode 100644 index 0000000000..ee6ec4a040 --- /dev/null +++ b/frontend/src/lib/components/handlerDraft.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + handlerDraftFromStored, + handlerDraftToStored, + handlerTypeForPath +} from './handlerDraft.svelte' + +const SLACK_BOT = '$res:f/slack_bot/bot_token' + +describe('handler draft', () => { + it('round-trips every destination unchanged', () => { + const stored = [ + { + path: 'hub/28794/workspace-or-schedule-error-handler-slack', + kind: 'script' as const, + args: { channel: 'alerts', slack: SLACK_BOT } + }, + { + path: 'hub/19742/workspace-or-schedule-error-handler-teams', + kind: 'script' as const, + args: { channel: 'c1', channel_name: 'General' } + }, + { + path: 'hub/19795/workspace-or-error-handler-email', + kind: 'script' as const, + args: { email_recipients: ['ops@example.com'] } + }, + { + path: 'f/handlers/on_error', + kind: 'flow' as const, + args: { slack: '$res:u/me/my_slack', channel: 'own' } + } + ] + for (const s of stored) { + const draft = handlerDraftFromStored('error', s.path, s.kind, s.args) + expect(handlerDraftToStored(draft)).toEqual(s) + } + }) + + it("saves only the selected destination's settings", () => { + const draft = handlerDraftFromStored('error', 'u/me/handler', 'script', { + webhook_url: 'https://hooks.example/custom' + }) + draft.selected = 'email' + draft.email.args.email_recipients = ['ops@example.com'] + expect(handlerDraftToStored(draft)).toEqual({ + path: 'hub/19795/workspace-or-error-handler-email', + kind: 'script', + args: { email_recipients: ['ops@example.com'] } + }) + }) + + it('recognises built-in handlers per mode', () => { + const recoverySlack = 'hub/28791/slack/schedule-recovery-handler-slack' + expect(handlerTypeForPath('recovery', recoverySlack)).toBe('slack') + expect(handlerTypeForPath('error', recoverySlack)).toBe('custom') + }) + + it('stores no handler when the destination has none', () => { + const draft = handlerDraftFromStored('error', undefined) + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + draft.selected = 'slack' + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + draft.selected = 'instance_alerts' + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + }) +}) diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 4b9e7f1e9b..2e22b0765c 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$lib/base' import { goto } from '$lib/navigation' import { Button } from '$lib/components/common' import Drawer from '$lib/components/common/drawer/Drawer.svelte' @@ -20,19 +19,20 @@ PanelLeftClose } from 'lucide-svelte' import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' + import DbtIcon from '$lib/components/icons/DbtIcon.svelte' import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons' import { HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts' import { importFlowStore } from '$lib/components/flows/flowStore.svelte' import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' import { importStore } from '$lib/components/apps/store' import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useEditRights } from '$lib/operatorWriteRights' import { createDropdownMenu, melt } from '@melt-ui/svelte' import YAML from 'yaml' import type { Snippet } from 'svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' - const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() interface Props { /** Replaces the default `New` button, e.g. with an inline text link. */ @@ -55,6 +55,23 @@ label: string icon: typeof PythonIcon onSelect: () => void + badge?: { label: string; class: string } + /** opened in a new tab from a link beside the variant */ + docHref?: string + /** shown in the doc panel while the variant is hovered, instead of its option's */ + doc?: Doc + } + + /** what the doc panel shows */ + type Doc = { + label: string + /** a lucide icon or a brand mark; rendered at 26px through both size APIs */ + icon: any + accent: string + tagline: string + description: string + bullets: string[] + badge?: { label: string; class: string } } /** an importable artifact kind handled by the shared YAML/JSON import drawer */ @@ -87,6 +104,9 @@ const badgeAdvanced = 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' const badgeLegacy = 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' const badgeAlpha = 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' + const badgeRecommended = 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' + + const DBT_DOCS = 'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt' const allOptions: Option[] = [ { @@ -102,7 +122,7 @@ 'Auto-generated UI from parameters', 'Instant deploy & versioning' ], - onSelect: () => goto(`${base}/scripts/add`) + onSelect: () => goto('/scripts/add') }, ...(HOME_SHOW_CREATE_FLOW ? ([ @@ -119,7 +139,7 @@ 'Branches, loops & error handling', 'Suspend / approval steps' ], - onSelect: () => goto(`${base}/flows/add`), + onSelect: () => goto('/flows/add'), extras: [{ label: 'Import flow', onSelect: () => openImport('flow') }] } ] as Option[]) @@ -135,7 +155,7 @@ description: 'Full control over the UI with React or Svelte and a powerful AI agent. Best for complex apps that need full flexibility.', bullets: ['React or Svelte', 'Full flexibility & control', 'AI-assisted authoring'], - onSelect: () => goto(`${base}/apps_raw/add`), + onSelect: () => goto('/apps_raw/add'), extras: [{ label: 'Import full-code app', onSelect: () => openImport('app-fullcode') }] } ] as Option[]) @@ -153,7 +173,7 @@ 'Reusable as a step in any flow', 'Tools from scripts, flows and MCP' ], - onSelect: () => goto(`${base}/agents/add`) + onSelect: () => goto('/agents/add') }, ...(HOME_SHOW_CREATE_FLOW ? ([ @@ -170,18 +190,18 @@ 'Full control via the SDK', 'Versioned as a regular script' ], - onSelect: () => goto(`${base}/scripts/add?wac=typescript`), + onSelect: () => goto('/scripts/add?wac=typescript'), badge: { label: 'Advanced', class: badgeAdvanced }, variants: [ { label: 'TypeScript', icon: TypeScriptIcon, - onSelect: () => goto(`${base}/scripts/add?wac=typescript`) + onSelect: () => goto('/scripts/add?wac=typescript') }, { label: 'Python', icon: PythonIcon, - onSelect: () => goto(`${base}/scripts/add?wac=python`) + onSelect: () => goto('/scripts/add?wac=python') } ], extras: [{ label: 'Import Workflow-as-Code', onSelect: () => openImport('wac') }] @@ -199,8 +219,35 @@ 'Partitioned & incremental', 'Asset-aware lineage' ], - onSelect: () => goto(`${base}/pipeline`), - badge: { label: 'Alpha', class: badgeAlpha } + onSelect: () => goto('/pipeline'), + badge: { label: 'Alpha', class: badgeAlpha }, + variants: [ + { + label: 'Windmill pipelines', + icon: Workflow, + badge: { label: 'Recommended', class: badgeRecommended }, + onSelect: () => goto('/pipeline') + }, + { + label: 'dbt', + icon: DbtIcon, + docHref: DBT_DOCS, + onSelect: () => goto('/scripts/add?lang=dbt'), + doc: { + label: 'dbt', + icon: DbtIcon, + accent: 'orange', + tagline: 'Run an existing dbt project', + description: + 'A pipeline is Windmill’s own abstraction; an existing dbt project stays a dbt project, and runs here unchanged as its own kind of script, with dbt still owning its models, refs and tests. All it needs is a warehouse configured under Settings → dbt. Its models show up in the asset graph as dbt:// nodes, next to whatever pipelines you do build, so you can adopt pipelines later, or never.', + bullets: [ + 'Your dbt project, unchanged', + 'Needs a warehouse under Settings → dbt', + 'Models appear as dbt:// assets' + ] + } + } + ] } ] as Option[]) : []), @@ -215,7 +262,7 @@ description: 'Assemble an internal UI from 60+ components wired to your scripts and flows. Best for simple apps or apps that need minimal customization.', bullets: ['60+ ready-made components', 'No code required', 'Backed by scripts & flows'], - onSelect: () => goto(`${base}/apps/add`), + onSelect: () => goto('/apps/add'), badge: { label: 'Legacy', class: badgeLegacy }, extras: [{ label: 'Import low-code app', onSelect: () => openImport('app-lowcode') }] } @@ -266,12 +313,11 @@ } } - // A builder composes runnables that already exist, so only flows are offered: everything else - // here writes code, which the backend refuses from an operator. + // Only the kinds this user's role authors: a builder operator gets the kinds its rights grant. // Derived, not computed once: switching workspace only sets `workspaceStore`, it does not // remount this component, so a snapshot would keep the previous workspace's kinds. const options: Option[] = $derived( - $operatorBuilderFlows ? allOptions.filter((o) => o.key === 'flow') : allOptions + allOptions.filter((o) => editRights.roleCanAuthor(o.key === 'app-fullcode' ? 'raw_app' : o.key)) ) // the doc panel only shows while an option is hovered or focused, so the menu opens compact @@ -301,18 +347,25 @@ // per-row fan-out submenus: open to the right when there's room. The popover hugs // the right viewport edge on most screens, so the default flip (right → left) would // cover the doc panel — fall back below the trigger row instead. + const variantSubmenu = () => + builders.createSubmenu({ + positioning: { + placement: 'right-start', + gutter: 4, + flip: { fallbackPlacements: ['bottom-end', 'bottom-start', 'top-end', 'top-start'] }, + fitViewport: true, + overflowPadding: 8 + } + }) const { elements: { subTrigger: wacSubTrigger, subMenu: wacSubMenu }, states: { subOpen: wacSubOpen } - } = builders.createSubmenu({ - positioning: { - placement: 'right-start', - gutter: 4, - flip: { fallbackPlacements: ['bottom-end', 'bottom-start', 'top-end', 'top-start'] }, - fitViewport: true, - overflowPadding: 8 - } - }) + } = variantSubmenu() + const { + elements: { subTrigger: pipelineSubTrigger, subMenu: pipelineSubMenu }, + states: { subOpen: pipelineSubOpen } + } = variantSubmenu() + let variantSubOpen = $derived($wacSubOpen || $pipelineSubOpen) const { elements: { subTrigger: importSubTrigger, subMenu: importSubMenu }, states: { subOpen: importSubOpen } @@ -378,7 +431,10 @@ if (isOpen && !wasOpen) { logFeatureUsage('home', 'new_menu_open', { key: source }) } - if (!isOpen) activeKey = undefined + if (!isOpen) { + activeKey = undefined + activeVariantDoc = undefined + } wasOpen = isOpen }) @@ -389,17 +445,25 @@ // only persist the non-default (hidden) state, so a cleared key means "shown" storeLocalSetting(SHOW_DOC_SETTING, value ? undefined : 'false') } - // The pointer crosses the gutter outside the menu on its way into the Workflow-as-Code - // submenu, so leaving the menu keeps the panel while that submenu is open; it clears - // once the submenu closes with neither pointer nor focus left on the menu. + // The pointer crosses the gutter outside the menu on its way into a variant submenu, + // so leaving the menu keeps the panel while that submenu is open; it clears once the + // submenu closes with neither pointer nor focus left on the menu. let menuEl: HTMLDivElement | undefined = $state(undefined) $effect(() => { - if ($wacSubOpen || !menuEl) return + if (variantSubOpen || !menuEl) return if (!menuEl.matches(':hover') && !menuEl.contains(document.activeElement)) { activeKey = undefined } }) - let active = $derived(options.find((o) => o.key === activeKey)) + // A hovered variant with its own doc (dbt under Data pipelines) takes over the panel. + let activeVariantDoc: Doc | undefined = $state(undefined) + function highlightOption(key: string | undefined) { + activeKey = key + activeVariantDoc = undefined + } + let active: Doc | undefined = $derived( + activeVariantDoc ?? options.find((o) => o.key === activeKey) + ) let activeAc = $derived(active ? accentClasses[active.accent] : undefined) // shared YAML/JSON import drawer, reused by every "Import …" extra @@ -427,17 +491,17 @@ const parsed = importType === 'yaml' ? YAML.parse(importRaw) : JSON.parse(importRaw) if (importKind === 'flow') { importFlowStore.set(parsed) - await goto(`${base}/flows/add`) + await goto('/flows/add') } else if (importKind === 'wac') { importScriptStore.set(parsed) - await goto(`${base}/scripts/add?import=true`) + await goto('/scripts/add?import=true') } else if (importKind === 'app-fullcode') { // The raw app editor reads the payload from sessionStorage on mount. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) - await goto(`${base}/apps_raw/add`) + await goto('/apps_raw/add') } else { importStore.set(parsed) - await goto(`${base}/apps/add`) + await goto('/apps/add') } importDrawer?.closeDrawer?.() } @@ -476,7 +540,7 @@ style={showDoc ? 'width: 780px;' : ''} bind:this={menuEl} onpointerleave={() => { - if (!$wacSubOpen) activeKey = undefined + if (!variantSubOpen) highlightOption(undefined) }} > <div @@ -492,7 +556,7 @@ <div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 {activeAc.tile}" > - <active.icon size={26} class={activeAc.iconText} /> + <active.icon size={26} width={26} height={26} class={activeAc.iconText} /> </div> <div class="min-w-0"> <div class="flex flex-row items-center gap-2"> @@ -551,48 +615,92 @@ </span> {/if} {/snippet} + {#snippet variantRow( + option: Option, + ac: (typeof accentClasses)[string], + rowClass: string, + subTrigger: any, + subMenu: any, + subOpen: boolean + )} + <button + use:melt={subTrigger} + class={rowClass} + onfocusin={() => highlightOption(option.key)} + onpointerenter={() => highlightOption(option.key)} + > + {@render rowBody(option, ac)} + <ChevronRight size={14} class="shrink-0 text-tertiary" /> + </button> + {#if subOpen} + <!-- The invisible `before:` margin bridges the gutter to the trigger row: when + the submenu falls below its row, that gutter lies over the next row, and + crossing it highlights that item, which closes the submenu. --> + <div + use:melt={subMenu} + use:hugViewportRight + class="pointer-events-auto z-[6001] flex flex-col gap-0.5 p-1 min-w-52 w-max rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none before:absolute before:-inset-2 before:-z-10 before:content-['']" + > + {#each option.variants ?? [] as variant (variant.label)} + {@const VariantIcon = variant.icon} + <div class="flex flex-row items-center gap-1"> + <button + use:melt={$item} + class="flex flex-row flex-1 items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover" + onclick={() => variant.onSelect()} + onfocusin={() => (activeVariantDoc = variant.doc)} + onpointerenter={() => (activeVariantDoc = variant.doc)} + > + <VariantIcon width={14} height={14} size={14} /> + <span class="text-xs font-medium text-primary whitespace-nowrap"> + {variant.label} + </span> + {#if variant.badge} + <span + class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {variant + .badge.class}" + > + {variant.badge.label} + </span> + {/if} + </button> + {#if variant.docHref} + <a + href={variant.docHref} + target="_blank" + rel="noreferrer" + class="px-2 text-[10px] text-tertiary hover:text-secondary hover:underline whitespace-nowrap" + onpointerenter={() => (activeVariantDoc = variant.doc)} + > + See documentation + </a> + {/if} + </div> + {/each} + </div> + {/if} + {/snippet} {#each options as option (option.key)} {@const ac = accentClasses[option.accent]} {@const rowClass = 'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'} - {#if option.variants} - <button - use:melt={$wacSubTrigger} - class={rowClass} - onfocusin={() => (activeKey = option.key)} - onpointerenter={() => (activeKey = option.key)} - > - {@render rowBody(option, ac)} - <ChevronRight size={14} class="shrink-0 text-tertiary" /> - </button> - {#if $wacSubOpen} - <!-- The invisible `before:` margin bridges the gutter to the trigger row: when - the submenu falls below its row, that gutter lies over the next row, and - crossing it highlights that item, which closes the submenu. --> - <div - use:melt={$wacSubMenu} - use:hugViewportRight - class="pointer-events-auto z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none before:absolute before:-inset-2 before:-z-10 before:content-['']" - > - {#each option.variants ?? [] as variant (variant.label)} - {@const VariantIcon = variant.icon} - <button - use:melt={$item} - class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover" - onclick={() => variant.onSelect()} - > - <VariantIcon width={14} height={14} /> - <span class="text-xs font-medium text-primary">{variant.label}</span> - </button> - {/each} - </div> - {/if} + {#if option.variants && option.key === 'pipeline'} + {@render variantRow( + option, + ac, + rowClass, + $pipelineSubTrigger, + $pipelineSubMenu, + $pipelineSubOpen + )} + {:else if option.variants} + {@render variantRow(option, ac, rowClass, $wacSubTrigger, $wacSubMenu, $wacSubOpen)} {:else} <button use:melt={$item} class={rowClass} - onfocusin={() => (activeKey = option.key)} - onpointerenter={() => (activeKey = option.key)} + onfocusin={() => highlightOption(option.key)} + onpointerenter={() => highlightOption(option.key)} onclick={() => option.onSelect()} > {@render rowBody(option, ac)} @@ -605,8 +713,8 @@ <button use:melt={$importSubTrigger} class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover" - onfocusin={() => (activeKey = undefined)} - onpointerenter={() => (activeKey = undefined)} + onfocusin={() => highlightOption(undefined)} + onpointerenter={() => highlightOption(undefined)} > <div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700" diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index a8e4d8b973..83e940602d 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -16,6 +16,24 @@ prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday' } ] + + /** An operator's session can run what is deployed, read runs and call MCP servers, but + * cannot author scripts or apps. It composes flows only where the workspace grants + * `operator_settings.builder_flows`, so none of these asks it to build anything. */ + export const homeAIOperatorExamples: { label: string; prompt: string }[] = [ + { + label: 'Run a flow', + prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday' + }, + { + label: 'Rerun failed jobs', + prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out' + }, + { + label: 'Summarize GitHub issues', + prompt: "Read this week's open GitHub issues and summarize the ones that mention billing" + } + ] </script> <script lang="ts"> @@ -37,7 +55,6 @@ import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { aiUserDisabled, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' import { HOME_SHOW_HUB } from '$lib/consts' - import { base } from '$lib/base' import { getLocalSetting, storeLocalSetting } from '$lib/utils' import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { useReducedMotion } from '$lib/svelte5Utils.svelte' @@ -46,22 +63,33 @@ import HomeConnectDrawer from './HomeConnectDrawer.svelte' import { USER_SETTINGS_HASH } from '../sidebar/settings' import { prefersSessionHandoff } from '../copilot/chat/global/gate' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { onboardingProfile } from '$lib/onboardingProfile' const COLLAPSED_SETTING = 'home-ai-composer-collapsed' + /** Bar width from which the two side trips stand in the bar rather than folding into the menu. + * Home's trail is ~250px and the rest of its actions ~400, so this leaves room for both + * labelled buttons without the trail starting to truncate. Below it they fold back. */ + const BAR_FITS_SIDE_TRIPS = 1024 + const sideTripsInBar = $derived(pageHeader.barWidth >= BAR_FITS_SIDE_TRIPS) + const hubOffered = $derived(!$userStore?.operator && HOME_SHOW_HUB) + let value = $state('') - // The stock examples, unless the invite that brought this person here wrote prompts for + // The stock examples for the reader's role, unless the invite that brought this person here wrote prompts for // them — those replace the set outright rather than joining it, since a prompt written // for someone's own stack next to "Ban Discord users" reads as the generic one. - let examples = $state(homeAIExamples) + let invitePrompts: { label: string; prompt: string }[] | undefined = $state(undefined) void onboardingProfile().then((p) => { if (!p?.starter_prompts?.length) return - examples = p.starter_prompts + invitePrompts = p.starter_prompts promptIndex = 0 - placeholder = examples[0].prompt }) - let placeholder = $state(homeAIExamples[0].prompt) + let examples = $derived( + invitePrompts ?? ($userStore?.operator ? homeAIOperatorExamples : homeAIExamples) + ) let placeholderVisible = $state(true) let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined) @@ -73,6 +101,26 @@ storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined) } + // Removing puts the composer away rather than throwing it out, and the way back is one item in + // the band's menu — so the ellipsis flashes as it appears, in a corner nobody is watching while + // the hero collapses in the middle of the page. + // + // A keyframe animation, not a transition: this mounts in the same flush that collapses the + // hero, so the frame that would paint it at rest is the frame it is already told to leave, and + // one `requestAnimationFrame` is not reliably past that paint. The flash then goes straight to + // invisible without ever being drawn. Mounted only while pulsing, so each removal replays it. + const PULSE_MS = 1000 + let pulsing = $state(false) + let pulseEndTimer: ReturnType<typeof setTimeout> | undefined + + function removeComposer() { + setCollapsed(true) + if (reducedMotion.val) return + clearTimeout(pulseEndTimer) + pulsing = true + pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS) + } + // In global-AI mode the layout's chat panel is disabled and never loads the copilot // config, so the home chat loads it for the current workspace itself. $effect(() => { @@ -107,8 +155,9 @@ // unlocked, so the far commoner unlocked workspace never pops the composer in mid-load. let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment')) - // The composer hands off to /sessions, which refuses operators — so hide it from them (the - // prompt would be silently dropped) while the AI-independent CLI/MCP row below stays. + // The composer hands off to /sessions, which refuses operators and users opted out of the + // sessions beta — so hide it from them (the prompt would be silently dropped) while the + // AI-independent CLI/MCP row below stays. let showComposer = $derived( prefersSessionHandoff($userStore?.operator) && !runOnlyWorkspace && @@ -116,7 +165,7 @@ ) // The hero's margins and centered column are for the full block. The lone button row left - // by a collapsed, operator, run-only or hidden-assistant view is a hint line and should + // by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should // cost the page almost nothing: no top margin, and the content column's full width so it // hugs the right edge instead of floating centered in empty space. let hero = $derived(showComposer && !collapsed) @@ -149,12 +198,13 @@ const FADE_MS = 600 // Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in. - // Only while the composer is shown — otherwise (operators) it would loop forever driving an + // Only while the composer is shown — otherwise it would loop forever driving an // unrendered input — and not under reduced motion, where the first prompt simply stays put. // The index lives outside the effect so re-showing the composer resumes the rotation from the // prompt currently displayed rather than restarting it. const reducedMotion = useReducedMotion() - let promptIndex = 0 + let promptIndex = $state(0) + let placeholder = $derived(prompts[promptIndex % prompts.length]) $effect(() => { if (!showComposer || collapsed || reducedMotion.val) return let timer: ReturnType<typeof setTimeout> @@ -163,7 +213,6 @@ placeholderVisible = false timer = setTimeout(() => { promptIndex = (promptIndex + 1) % prompts.length - placeholder = prompts[promptIndex] placeholderVisible = true timer = setTimeout(next, CYCLE_MS) }, FADE_MS) @@ -185,11 +234,13 @@ <!-- The one dismiss control while the composer is usable; the overlay below carries its own once it takes over, so the two never show at the same time. --> <div class="absolute right-0 top-0 z-20"> - <CloseButton small noBg title="Hide Build with AI" onClick={() => setCollapsed(true)} /> + <CloseButton small noBg title="Remove session chat" onClick={removeComposer} /> </div> {/if} <div class="flex items-center justify-center gap-2 mb-4"> - <p class="text-center font-regular text-3xl">Build with AI</p> + <p class="text-center font-regular text-3xl"> + {$userStore?.operator ? 'Run with AI' : 'Build with AI'} + </p> <Badge color="blue" small>Beta</Badge> </div> <!-- Anchors the send button / model settings to the input, not to the whole block — the row @@ -204,7 +255,9 @@ underlyingInputEl="textarea" inputProps={{ rows: 4, - 'aria-label': 'Describe what you want to build', + 'aria-label': $userStore?.operator + ? 'Describe what you want to do' + : 'Describe what you want to build', onkeydown: onKeydown }} /> @@ -267,14 +320,12 @@ unifiedSize="sm" variant="accent" startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }} - href="{base}/workspace_settings?tab=ai" + href="/workspace_settings?tab=ai" > {freeTierExhausted ? 'Add your own API key' : 'Configure AI'} </Button> {/if} - <Button unifiedSize="sm" variant="default" onClick={() => setCollapsed(true)}> - Hide - </Button> + <Button unifiedSize="sm" variant="default" onClick={removeComposer}>Remove</Button> </div> </div> {/if} @@ -295,49 +346,106 @@ </Button> {/each} </div> - {:else if showComposer} - <!-- All that is left of the composer once dismissed: sits with the CLI/MCP row so the - collapsed home page is one quiet line. --> - <Button - variant="subtle" - unifiedSize="xs" - btnClasses="!text-2xs !text-hint" - startIcon={{ icon: WandSparkles }} - onClick={() => setCollapsed(false)} - > - Build with AI - </Button> - {:else} - <div></div> {/if} - - <!-- Not AI-related, so shown even to operators / when the composer is hidden. --> - <div class="flex flex-row items-center gap-1"> - <Button - variant="subtle" - unifiedSize="xs" - btnClasses="!text-2xs !text-hint" - startIcon={{ icon: PlugZap }} - onClick={() => homeConnectDrawer?.openDrawer?.()} - > - CLI / MCP - </Button> - {#if !$userStore?.operator && HOME_SHOW_HUB} - <Button - variant="subtle" - unifiedSize="xs" - btnClasses="!text-2xs !text-hint" - startIcon={{ icon: Globe2 }} - endIcon={{ icon: ExternalLink }} - href={$hubBaseUrlStore} - target="_blank" - > - Hub - </Button> - {/if} - </div> </div> </div> </div> <HomeConnectDrawer bind:this={homeConnectDrawer} /> + +<!-- Everything the hero offered beside the composer lives in the band's menu: the connect helper, + the hub, and the way back to the composer once it has been put away. --> +<!-- Last in the bar whatever else the page registers: a menu of side trips belongs after the + buttons that act on what is on screen. --> +<PageHeaderContent actions={homeMenu} actionsOrder={100} /> + +{#snippet homeMenu()} + <!-- The band's own menu: the connect helper, the hub, and whether the composer is on this page + — all of them preferences or side trips, none of them the page's work. --> + <span class="flex items-center gap-1"> + <!-- Wide enough, and the two side trips are buttons of their own: a menu is where a thing + goes when the bar cannot hold it, not where it belongs. They fold back below the width + above. --> + {#if sideTripsInBar} + <!-- The same quiet shape the hero gave them before the bar took them: xs, hint-coloured, + and the hub keeps its outbound mark. They sit beside the page's own buttons, and a + side trip should not read as loudly as the thing the page is for. --> + <Button + variant="subtle" + unifiedSize="xs" + btnClasses="!text-2xs !text-hint" + startIcon={{ icon: PlugZap }} + onClick={() => homeConnectDrawer?.openDrawer?.()} + > + CLI / MCP + </Button> + {#if hubOffered} + <Button + variant="subtle" + unifiedSize="xs" + btnClasses="!text-2xs !text-hint" + startIcon={{ icon: Globe2 }} + endIcon={{ icon: ExternalLink }} + href={$hubBaseUrlStore} + target="_blank" + > + Hub + </Button> + {/if} + {/if} + <!-- Nothing left to hold once the side trips stand in the bar and the composer is on the + page: an ellipsis that opens an empty menu is a button that does nothing. --> + <!-- No menu while the hero is on the page and its side trips are in the bar: the hero's own + cross removes it, so an ellipsis holding a second way to do that is a button whose only + item is a duplicate. --> + {#if !sideTripsInBar || (showComposer && collapsed)} + <!-- The flash rides the ellipsis itself, not the row: with the side trips beside it the + row is three controls wide, and marking all three names none of them. --> + <span class="relative inline-flex"> + {#if pulsing} + <!-- `opacity-0` at rest: the keyframes carry no fill mode, so at the end of the + animation the element returns to its own opacity. At 1 that is the flash coming + back at full strength until the unmount timer catches up. --> + <span + aria-hidden="true" + class="pointer-events-none absolute -inset-1 rounded-lg bg-blue-500/30 opacity-0 animate-fade-out" + ></span> + {/if} + <DropdownV2 + placement="bottom-end" + size="sm" + items={[ + ...(showComposer && collapsed + ? [ + { + displayName: 'Pin session hero to homepage', + icon: WandSparkles, + action: () => setCollapsed(false) + } + ] + : []), + ...(sideTripsInBar + ? [] + : [ + { + displayName: 'CLI / MCP', + icon: PlugZap, + action: () => homeConnectDrawer?.openDrawer?.() + }, + ...(hubOffered + ? [ + { + displayName: 'Hub', + icon: Globe2, + href: $hubBaseUrlStore, + hrefTarget: '_blank' as const + } + ] + : []) + ]) + ]} + /> + </span> + {/if} + </span> +{/snippet} diff --git a/frontend/src/lib/components/home/HomeConnectDrawer.svelte b/frontend/src/lib/components/home/HomeConnectDrawer.svelte index e08ac2ddea..2b86025549 100644 --- a/frontend/src/lib/components/home/HomeConnectDrawer.svelte +++ b/frontend/src/lib/components/home/HomeConnectDrawer.svelte @@ -6,6 +6,7 @@ import { Bot, ExternalLink, Terminal } from 'lucide-svelte' import { shell } from 'svelte-highlight/languages' import { mcpTokenUrlDisabled } from '$lib/mcpAuth' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' type ConnectTab = 'cli' | 'mcp' @@ -14,7 +15,7 @@ let selectedTab: ConnectTab = $state('cli') let openVersion = $state(0) - const origin = $derived(typeof window === 'undefined' ? '' : window.location.origin) + const origin = $derived(typeof window === 'undefined' ? '' : `${apiBaseUrl()}/`) const workspaceId = $derived($workspaceStore ?? '<workspace>') const cliCommands = $derived(`npm install -g windmill-cli wmill workspace add ${workspaceId} ${workspaceId} ${origin} @@ -55,7 +56,8 @@ wmill sync pull`) <h3 class="text-sm font-semibold text-emphasis">Local setup</h3> <p class="text-xs text-secondary max-w-xl"> Run this in your local repo to bind the current workspace, create - <code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" + <code + class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" >wmill.yaml</code >, and pull the latest files. </p> @@ -80,15 +82,18 @@ wmill sync pull`) /> <p class="text-2xs text-secondary"> - <code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" + <code + class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" >wmill workspace add</code > will handle authentication, - <code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" + <code + class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" >wmill init</code > bootstraps the local config, and - <code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" + <code + class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis" >wmill sync pull</code > fetches the workspace content. diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte index a8837d555a..67dac7b3dd 100644 --- a/frontend/src/lib/components/home/Item.svelte +++ b/frontend/src/lib/components/home/Item.svelte @@ -45,10 +45,9 @@ // A raw app the listing returns is an `app` row carrying `raw_app`, and is // selectable like any other app. The separate `raw_app` type is the legacy // listing shape, which renders through RawAppRow — no selection control there, - // so it must not enter the selection either. An agent is a resource underneath, - // which none of the bulk actions handle. + // so it must not enter the selection either. let bulkItem = $derived( - homeSelection?.available && item.type !== 'raw_app' && item.type !== 'agent' + homeSelection?.available && item.type !== 'raw_app' ? toBulkItem(item, $userStore, $workspaceStore) : undefined ) @@ -145,6 +144,7 @@ {depth} bind:menuOpen {keyboardSelected} + {rowSelection} /> {/if} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index e44bcb08aa..ee22e1a43a 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -19,7 +19,7 @@ import { resource } from 'runed' import { getDraftItems } from '$lib/workspaceDrafts.svelte' import { disableHubStore, userStore, workspaceStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useEditRights } from '$lib/operatorWriteRights' import type uFuzzy from '@leeoniya/ufuzzy' import { ArrowDownUp, @@ -50,7 +50,7 @@ import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' import FlowIcon from './FlowIcon.svelte' - import { canWrite, getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils' + import { getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils' import { sendUserToast } from '$lib/toast' import Drawer from '../common/drawer/Drawer.svelte' import HighlightCode from '../HighlightCode.svelte' @@ -60,12 +60,11 @@ import { effectivePath, type ItemType } from './treeViewUtils' import { tick, untrack } from 'svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' - import { NetworkIcon } from 'lucide-svelte' - import { base } from '$lib/base' + import PipelineRow from '../common/table/PipelineRow.svelte' import BulkActionsBar from './BulkActionsBar.svelte' import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte' - const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() interface Props { subtab?: 'flow' | 'script' | 'app' | 'agent' @@ -358,11 +357,12 @@ const base = { ...it, canWrite: - canWrite(it.path, (it.extra_perms ?? {}) as any, $userStore) && - (it.type === 'script' || it.workspace_id == $workspaceStore) && - // The builder right covers flows only; a script or an app is still off limits, so - // the row must not offer edit or delete for those. - (!$userStore?.operator || (it.type === 'flow' && $operatorBuilderFlows)) + editRights.canEditItem( + it.type === 'app' && it.raw_app ? 'raw_app' : it.type, + it.path, + (it.extra_perms ?? {}) as any + ) && + (it.type === 'script' || it.workspace_id == $workspaceStore) } // combinedItems reads a script's time from `created_at`; the endpoint's // unified `edited_at` holds exactly that for scripts. @@ -435,7 +435,7 @@ agents = sorted.map((r, i) => ({ ...r, summary: r.description || undefined, - canWrite: canWrite(r.path, (r.extra_perms ?? {}) as any, $userStore) && !$userStore?.operator, + canWrite: editRights.canEditItem('agent', r.path, (r.extra_perms ?? {}) as any), ord: AGENT_ORD_BASE + i })) } @@ -835,6 +835,8 @@ // keeping a dead path. `tick` lets the reloaded rows re-register first. const renderedBefore = homeSelection.renderedKeys void ownerCountsRes.refetch() + // Deleting a pipeline removes it from this list, which no item reload covers. + void pipelineFoldersRes.refetch() await reloadItems() await tick() homeSelection.dropVanished(renderedBefore) @@ -1210,7 +1212,10 @@ * whose direct-deploy protection cleared `showEditButtons` — must not be shown them. * Reading archived items is not a write, so it is not gated on this. */ - let canCreateHere = $derived((!$userStore?.operator || $operatorBuilderFlows) && showEditButtons) + // Every author can create a flow or a full-code app, except a builder granted only the other. + let canCreateHere = $derived( + (editRights.roleCanAuthor('flow') || editRights.roleCanAuthor('raw_app')) && showEditButtons + ) // The workspace itself holds nothing — no filter is narrowing the list away. It stays // false until the first load resolves: a skeleton already means "loading", and the @@ -1820,8 +1825,7 @@ homeSelection.active && selectedIndex >= 0 && selectedIndex < displayedItems.length && - displayedItems[selectedIndex].type !== 'raw_app' && - displayedItems[selectedIndex].type !== 'agent' + displayedItems[selectedIndex].type !== 'raw_app' ) { e.preventDefault() homeSelection.toggle( @@ -2042,7 +2046,7 @@ script and flow hub pickers observe. Nor for a builder: a hub project brings scripts and apps along. --> <CreateActionsMenu - onImportHubProject={$disableHubStore || $operatorBuilderFlows + onImportHubProject={$disableHubStore || !editRights.roleCanAuthor('script') ? undefined : () => (hubPickerOpen = true)} /> @@ -2168,13 +2172,7 @@ <div class="border rounded-md bg-surface-tertiary" class:wm-imported={justImported}> {#if filter === ''} {#each [...visiblePipelineFolders].sort() as folder (folder)} - <a - href="{base}/pipeline/{encodeURIComponent(folder)}" - class="w-full inline-flex items-center gap-4 px-4 py-3 border-b last:border-b-0 hover:bg-surface-hover transition-colors text-sm first-of-type:rounded-t-md" - > - <NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" /> - <span class="text-xs font-medium text-emphasis truncate">Pipeline · f/{folder}</span> - </a> + <PipelineRow {folder} onDeleted={reloadItemsAndCounts} /> {/each} {/if} {#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))} diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 4c6f3e8ae6..806787cb76 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -1,14 +1,14 @@ <script lang="ts"> import TreeView from './TreeView.svelte' - import { onDestroy, untrack, type Snippet } from 'svelte' + import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte' import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte' - import { ChevronDown, ChevronUp, Folder, FolderTree, NetworkIcon, User } from 'lucide-svelte' + import { ChevronDown, ChevronUp, Folder, FolderTree, User } from 'lucide-svelte' + import PipelineRow from '../common/table/PipelineRow.svelte' import Item from './Item.svelte' import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils' import { twMerge } from 'tailwind-merge' import { pluralize } from '$lib/utils' - import { base } from '$lib/base' import { Button } from '$lib/components/common' interface Props { @@ -74,6 +74,8 @@ let visualDepth = $derived(depth + indent) + const dispatch = createEventDispatcher<{ reload: void }>() + // Bounds the request burst from "expand all": however many root owners the tree // renders (its slice grows as you scroll), it fetches at most this many. Lazy owners // past the cap stay collapsed and load on a single click (see the effect). @@ -316,17 +318,12 @@ {#if opened || isSearching} <div> {#if hasPipeline && isFolder(item)} - <!-- py-3 matches common/table/Row.svelte so this row sits at - the same height as the script/flow/app rows that follow - it under the same folder; py-2 was visibly shorter. --> - <a - href="{base}/pipeline/{encodeURIComponent(item.folderName)}" - class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors" - style="padding-left: {(visualDepth + 1) * 16}px;" - > - <NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" /> - <span class="text-xs font-medium text-emphasis">Pipeline</span> - </a> + <PipelineRow + folder={item.folderName} + depth={visualDepth + 1} + label="Pipeline" + onDeleted={() => dispatch('reload')} + /> {/if} {#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)} <TreeView diff --git a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte index 1f7bebb1ce..6e828eee34 100644 --- a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte +++ b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte @@ -4,11 +4,11 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import type { HubProjectPick } from '$lib/hubProject' import { disableHubStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useOperatorBuilderRights } from '$lib/operatorWriteRights' import CreateActionsMenu from './CreateActionsMenu.svelte' import HubTemplatePicker from './HubTemplatePicker.svelte' - const operatorBuilderFlows = useOperatorBuilderFlows() + const operatorBuilderRights = useOperatorBuilderRights() interface Props { /** A project was chosen here. The list owns the import dialog, and opens it on this. */ @@ -39,7 +39,7 @@ const rowOpacities = [1, 0.7, 0.4] // A builder gets no hub template: a hub project brings scripts and apps along. - const showHub = $derived(!$disableHubStore && !$operatorBuilderFlows) + const showHub = $derived(!$disableHubStore && !$operatorBuilderRights) // The inline "create a new one" link is the anchor for the very same New menu the // toolbar button opens, so the menu pops next to the words that promised it. diff --git a/frontend/src/lib/components/home/bulkActions.test.ts b/frontend/src/lib/components/home/bulkActions.test.ts index ccc29d063e..e00d5f5da5 100644 --- a/frontend/src/lib/components/home/bulkActions.test.ts +++ b/frontend/src/lib/components/home/bulkActions.test.ts @@ -57,6 +57,15 @@ describe('blockedReason', () => { expect(blockedReason('unarchive', item({ archived: false }), admin)).toBeDefined() }) + // AgentRow's delete is `disabled: !canWrite`; the server moves no resource draft. + it('gates an agent as its row does, and keeps a draft-only one in place', () => { + const agent = item({ kind: 'agent', owner: false }) + expect(blockedReason('delete', agent, member)).toBeUndefined() + expect(blockedReason('delete', { ...agent, canWrite: false }, member)).toBeDefined() + expect(blockedReason('archive', agent, admin)).toBeDefined() + expect(blockedReason('move', item({ kind: 'agent', draftOnly: true }), admin)).toBeDefined() + }) + it('lets a draft-only row move or discard, never archive/delete', () => { const draftOnly = item({ draftOnly: true, isDraft: true }) expect(blockedReason('discard', draftOnly, admin)).toBeUndefined() diff --git a/frontend/src/lib/components/home/bulkActions.ts b/frontend/src/lib/components/home/bulkActions.ts index 2345311459..20a65ec596 100644 --- a/frontend/src/lib/components/home/bulkActions.ts +++ b/frontend/src/lib/components/home/bulkActions.ts @@ -8,18 +8,19 @@ * addresses the deployed row. A draft-only item is therefore not deletable — * there is nothing deployed at its path. */ -import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen' +import { AppService, DraftService, FlowService, ResourceService, ScriptService } from '$lib/gen' import type { UserDraftItemKind } from '$lib/gen' import { updateItemPathAndSummary } from '$lib/components/moveRenameManager' import { discardDraft } from '$lib/utils_draft_deploy' import type { BulkItem } from './homeSelection.svelte' -/** The draft overlay is the one place a raw app is its own kind. Narrowed to the - * four kinds `moveDraft` accepts — a `BulkItem` is never anything else, and - * saying so lets the compiler check that rather than trusting it. */ +/** The draft overlay is the one place a raw app is its own kind, and an agent's + * draft is filed as the resource it is. Narrowed so the compiler checks that a + * `BulkItem` never maps to anything else. */ function draftKind( item: BulkItem -): Extract<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app'> { +): Extract<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app' | 'resource'> { + if (item.kind === 'agent') return 'resource' return item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind } @@ -43,6 +44,9 @@ export function blockedReason( switch (action) { case 'move': if (item.archived) return 'archived items cannot be moved' + // The server moves only script, flow and app drafts. + if (item.kind === 'agent' && item.draftOnly) + return 'a draft-only agent is moved by changing its path in the editor' if (!item.owner) return notOwner if (!item.canWrite) return 'you do not have write permission on this path' return undefined @@ -94,9 +98,11 @@ async function moveItem(ctx: BulkContext, item: BulkItem, target: string): Promi if (item.draftOnly) { // Nothing is deployed at this path, so there is no deploy to re-run: the // item IS its draft row, and moving it rewrites that row. + const kind = draftKind(item) + if (kind === 'resource') throw new Error('a draft-only agent cannot be moved here') await DraftService.moveDraft({ workspace: ctx.workspace, - kind: draftKind(item), + kind, path: item.path, requestBody: { new_path: newPath } }) @@ -138,6 +144,8 @@ async function deleteItem(ctx: BulkContext, item: BulkItem): Promise<void> { await ScriptService.deleteScriptByPath({ workspace: ctx.workspace, path: item.path }) } else if (item.kind === 'flow') { await FlowService.deleteFlowByPath({ workspace: ctx.workspace, path: item.path }) + } else if (item.kind === 'agent') { + await ResourceService.deleteResource({ workspace: ctx.workspace, path: item.path }) } else { await AppService.deleteApp({ workspace: ctx.workspace, path: item.path }) } diff --git a/frontend/src/lib/components/home/homeSelection.svelte.ts b/frontend/src/lib/components/home/homeSelection.svelte.ts index 70acdfa0fd..0b4cfcba7a 100644 --- a/frontend/src/lib/components/home/homeSelection.svelte.ts +++ b/frontend/src/lib/components/home/homeSelection.svelte.ts @@ -13,7 +13,7 @@ import type { UserExt } from '$lib/stores' /** A raw app is not a kind of its own here: the listing returns it as an `app` * row carrying `raw_app`, which every action addresses through `BulkItem.rawApp` * (only the draft overlay distinguishes the two). */ -export type BulkKind = 'script' | 'flow' | 'app' +export type BulkKind = 'script' | 'flow' | 'app' | 'agent' /** The flattened row facts every bulk action needs, snapshotted at selection * time so an action still knows what it is acting on after the list reloads. */ diff --git a/frontend/src/lib/components/icons/OutlookIcon.svelte b/frontend/src/lib/components/icons/OutlookIcon.svelte new file mode 100644 index 0000000000..a9cec0cfa6 --- /dev/null +++ b/frontend/src/lib/components/icons/OutlookIcon.svelte @@ -0,0 +1,94 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + size?: number + } + + let { height, width, size = 24 }: Props = $props() + let effectiveWidth = $derived(width ?? `${size}px`) + let effectiveHeight = $derived(height ?? `${size}px`) +</script> + +<!-- Microsoft's Outlook-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com, served on microsoft.com/microsoft-365/outlook), paths and gradient stops verbatim. + Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. --> +<svg + width={effectiveWidth} + height={effectiveHeight} + viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg" + fill="none" +> +<path d="M30.9322 9.34307L7.97588 23.8943L6.00165 20.7797V18.0959C6.00165 17.1188 6.49634 16.2081 7.31603 15.6762L20.6606 7.01716C22.6937 5.69792 25.3125 5.69771 27.3458 7.01665L30.9322 9.34307Z" fill="url(#ms-outlook-0)"/> +<path d="M27.1402 6.88928C27.2092 6.93033 27.2777 6.97284 27.3455 7.01682L37.76 13.7724L11.9375 30.1404L7.97498 23.8891L26.9262 11.8533C28.7213 10.7133 28.7999 8.14871 27.1402 6.88928Z" fill="url(#ms-outlook-1)"/> +<path d="M27.1402 6.88928C27.2092 6.93033 27.2777 6.97284 27.3455 7.01682L37.76 13.7724L11.9375 30.1404L7.97498 23.8891L26.9262 11.8533C28.7213 10.7133 28.7999 8.14871 27.1402 6.88928Z" fill="url(#ms-outlook-2)" fill-opacity="0.2"/> +<path d="M22.2402 33.2659L11.9377 30.1406L33.842 16.2557C35.6868 15.0864 35.682 12.3932 33.8331 11.2304L33.7344 11.1684L34.0185 11.3451L40.685 15.6695C41.5049 16.2014 41.9998 17.1122 41.9998 18.0895V20.687L22.2402 33.2659Z" fill="url(#ms-outlook-3)"/> +<path d="M22.2402 33.2659L11.9377 30.1406L33.842 16.2557C35.6868 15.0864 35.682 12.3932 33.8331 11.2304L33.7344 11.1684L34.0185 11.3451L40.685 15.6695C41.5049 16.2014 41.9998 17.1122 41.9998 18.0895V20.687L22.2402 33.2659Z" fill="url(#ms-outlook-4)" fill-opacity="0.2"/> +<path d="M27.3458 7.01665C25.3125 5.69771 22.6937 5.69792 20.6605 7.01716L7.31603 15.6762C6.49634 16.2081 6.00165 17.1188 6.00165 18.0959V18.2272C6.03382 19.208 6.55002 20.1121 7.38373 20.6378L23.9764 31.0985L40.6108 20.6537C41.4747 20.1112 41.999 19.1628 41.999 18.1427V20.6873L41.9994 18.0896C41.9994 17.1123 41.5045 16.2015 40.6846 15.6696L27.3458 7.01665Z" fill="url(#ms-outlook-5)"/> +<path d="M21.0513 42.0035H35.748C39.1998 42.0035 41.998 39.2053 41.998 35.7535L41.998 18.1426C41.998 19.1627 41.4736 20.1111 40.6098 20.6536L18.7495 34.3798C17.5703 35.1202 16.8546 36.4149 16.8547 37.8073C16.8549 40.1248 18.7337 42.0035 21.0513 42.0035Z" fill="url(#ms-outlook-6)"/> +<path d="M21.0513 42.0035H35.748C39.1998 42.0035 41.998 39.2053 41.998 35.7535L41.998 18.1426C41.998 19.1627 41.4736 20.1111 40.6098 20.6536L18.7495 34.3798C17.5703 35.1202 16.8546 36.4149 16.8547 37.8073C16.8549 40.1248 18.7337 42.0035 21.0513 42.0035Z" fill="url(#ms-outlook-7)" fill-opacity="0.4"/> +<path d="M21.0513 42.0035H35.748C39.1998 42.0035 41.998 39.2053 41.998 35.7535L41.998 18.1426C41.998 19.1627 41.4736 20.1111 40.6098 20.6536L18.7495 34.3798C17.5703 35.1202 16.8546 36.4149 16.8547 37.8073C16.8549 40.1248 18.7337 42.0035 21.0513 42.0035Z" fill="url(#ms-outlook-8)" fill-opacity="0.5"/> +<path d="M27.0268 42.0023H12.2492C8.79745 42.0023 5.99923 39.2041 5.99923 35.7523V18.1297C5.99923 19.1478 6.52165 20.0948 7.38292 20.6377L29.2215 34.4058C30.4171 35.1595 31.1423 36.4741 31.1421 37.8874C31.1419 40.1601 29.2995 42.0023 27.0268 42.0023Z" fill="url(#ms-outlook-9)"/> +<path d="M27.0268 42.0023H12.2492C8.79745 42.0023 5.99923 39.2041 5.99923 35.7523V18.1297C5.99923 19.1478 6.52165 20.0948 7.38292 20.6377L29.2215 34.4058C30.4171 35.1595 31.1423 36.4741 31.1421 37.8874C31.1419 40.1601 29.2995 42.0023 27.0268 42.0023Z" fill="url(#ms-outlook-10)"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-outlook-11)"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-outlook-12)" fill-opacity="0.5"/> +<path d="M11.959 35.5999C10.636 35.5999 9.54994 35.186 8.70069 34.3583C7.85144 33.5306 7.42682 32.4505 7.42682 31.1179C7.42682 29.7107 7.85785 28.5726 8.7199 27.7035C9.58195 26.8345 10.7107 26.3999 12.1062 26.3999C13.4249 26.3999 14.4982 26.8158 15.3261 27.6477C16.1583 28.4795 16.5744 29.5762 16.5744 30.9378C16.5744 32.3367 16.1433 33.4644 15.2813 34.3211C14.4235 35.1736 13.3161 35.5999 11.959 35.5999ZM11.9974 33.8431C12.7186 33.8431 13.299 33.5968 13.7386 33.1044C14.1781 32.6119 14.3979 31.9269 14.3979 31.0496C14.3979 30.1349 14.1845 29.4231 13.7578 28.9141C13.331 28.405 12.7613 28.1505 12.0486 28.1505C11.3146 28.1505 10.7235 28.4133 10.2754 28.9389C9.82733 29.4604 9.60328 30.1515 9.60328 31.0123C9.60328 31.8856 9.82733 32.5767 10.2754 33.0857C10.7235 33.5906 11.2975 33.8431 11.9974 33.8431Z" fill="white"/> +<path d="M11.9554 35.7233C10.6375 35.7233 9.55551 35.2982 8.7095 34.448C7.8635 33.5977 7.44049 32.4881 7.44049 31.1192C7.44049 29.6737 7.86987 28.5046 8.72864 27.6119C9.5874 26.7191 10.7119 26.2727 12.102 26.2727C13.4157 26.2727 14.4849 26.7 15.3097 27.5545C16.1387 28.409 16.5532 29.5356 16.5532 30.9343C16.5532 32.3712 16.1238 33.5297 15.265 34.4097C14.4105 35.2855 13.3073 35.7233 11.9554 35.7233ZM11.9936 33.9187C12.7121 33.9187 13.2903 33.6657 13.7282 33.1598C14.1661 32.6539 14.385 31.9503 14.385 31.049C14.385 30.1095 14.1724 29.3783 13.7473 28.8554C13.3222 28.3325 12.7546 28.071 12.0447 28.071C11.3134 28.071 10.7246 28.341 10.2782 28.8809C9.83185 29.4165 9.60865 30.1265 9.60865 31.0108C9.60865 31.9078 9.83185 32.6178 10.2782 33.1407C10.7246 33.6593 11.2964 33.9187 11.9936 33.9187Z" fill="white"/> +<defs> +<linearGradient id="ms-outlook-0" x1="9.98908" y1="22.3649" x2="30.9322" y2="9.37495" gradientUnits="userSpaceOnUse"> +<stop stop-color="#20A7FA"/> +<stop offset="0.4" stop-color="#3BD5FF"/> +<stop offset="1" stop-color="#C4B0FF"/> +</linearGradient> +<linearGradient id="ms-outlook-1" x1="17.1972" y1="26.7945" x2="28.8562" y2="8.12575" gradientUnits="userSpaceOnUse"> +<stop stop-color="#165AD9"/> +<stop offset="0.5008" stop-color="#1880E5"/> +<stop offset="1" stop-color="#8587FF"/> +</linearGradient> +<linearGradient id="ms-outlook-2" x1="25.7005" y1="27.0484" x2="12.7563" y2="16.5013" gradientUnits="userSpaceOnUse"> +<stop offset="0.236946" stop-color="#448AFF" stop-opacity="0"/> +<stop offset="0.792113" stop-color="#0032B1"/> +</linearGradient> +<linearGradient id="ms-outlook-3" x1="24.0534" y1="31.1099" x2="44.51" y2="18.0177" gradientUnits="userSpaceOnUse"> +<stop stop-color="#1A43A6"/> +<stop offset="0.492267" stop-color="#2052CB"/> +<stop offset="1" stop-color="#5F20CB"/> +</linearGradient> +<linearGradient id="ms-outlook-4" x1="29.8281" y1="30.3273" x2="17.3975" y2="19.5708" gradientUnits="userSpaceOnUse"> +<stop stop-color="#0045B9" stop-opacity="0"/> +<stop offset="0.669859" stop-color="#0D1F69"/> +</linearGradient> +<radialGradient id="ms-outlook-5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.0018 6.81788) rotate(-90) scale(27.0027 29.2262)"> +<stop offset="0.568182" stop-color="#275FF0" stop-opacity="0"/> +<stop offset="0.992424" stop-color="#002177"/> +</radialGradient> +<linearGradient id="ms-outlook-6" x1="41.998" y1="29.9431" x2="23.8517" y2="29.9431" gradientUnits="userSpaceOnUse"> +<stop stop-color="#4DC4FF"/> +<stop offset="0.196145" stop-color="#0FAFFF"/> +</linearGradient> +<radialGradient id="ms-outlook-7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(28.0928 37.9117) rotate(-45) scale(11.572)"> +<stop offset="0.259477" stop-color="#0060D1"/> +<stop offset="0.908166" stop-color="#0383F1" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-outlook-8" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(10.6314 46.472) rotate(-52.6577) scale(39.2814 35.5204)"> +<stop offset="0.732317" stop-color="#F4A7F7" stop-opacity="0"/> +<stop offset="1" stop-color="#F4A7F7"/> +</radialGradient> +<radialGradient id="ms-outlook-9" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(18.5708 27.5319) rotate(123.339) scale(20.7258 53.7859)"> +<stop stop-color="#49DEFF"/> +<stop offset="0.724349" stop-color="#29C3FF"/> +</radialGradient> +<linearGradient id="ms-outlook-10" x1="3.45756" y1="37.8723" x2="20.9291" y2="37.8597" gradientUnits="userSpaceOnUse"> +<stop offset="0.205882" stop-color="#6CE0FF"/> +<stop offset="0.535" stop-color="#50D5FF" stop-opacity="0"/> +</linearGradient> +<radialGradient id="ms-outlook-11" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(3.94291 23.6154) rotate(46.9242) scale(21.0618)"> +<stop offset="0.038877" stop-color="#0091FF"/> +<stop offset="0.919119" stop-color="#183DAD"/> +</radialGradient> +<radialGradient id="ms-outlook-12" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12 32.7439) rotate(90) scale(11.2 12.9188)"> +<stop offset="0.557796" stop-color="#0FA5F7" stop-opacity="0"/> +<stop offset="1" stop-color="#74C6FF"/> +</radialGradient> +</defs> +</svg> diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index bbefc16469..70d2450f29 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -90,6 +90,7 @@ import ResendIcon from './ResendIcon.svelte' import PushoverIcon from './PushoverIcon.svelte' import QuickbooksIcon from './QuickbooksIcon.svelte' import MsTeamsIcon from './MSTeamsIcon.svelte' +import OutlookIcon from './OutlookIcon.svelte' import MailgunIcon from './MailgunIcon.svelte' import IpinfoIcon from './IpinfoIcon.svelte' import GoogleCloudIcon from './GoogleCloudIcon.svelte' @@ -445,6 +446,7 @@ export const APP_TO_ICON_COMPONENT = { quickbooks: QuickbooksIcon, ms_teams_webhook: MsTeamsIcon, teams: MsTeamsIcon, + ms_outlook: OutlookIcon, mailgun: MailgunIcon, ipinfo: IpinfoIcon, gworkspace: GoogleIcon, @@ -776,6 +778,7 @@ export { PushoverIcon, QuickbooksIcon, MsTeamsIcon, + OutlookIcon, MailgunIcon, IpinfoIcon, GoogleCloudIcon, diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 9701b74e30..9681e821e6 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -5,6 +5,7 @@ import { instanceBannerFormError } from './instanceBanner' import { ACCENT_COLOR_SETTING, parseAccentColor } from '$lib/accentColor' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import { writable } from 'svelte/store' +import { OidcService } from '$lib/gen' /** * Bumped after instance settings are successfully saved. Settings whose display @@ -175,8 +176,7 @@ export function isValidWebhookBaseUrl(value: unknown): boolean { return ( (url.protocol === 'http:' || url.protocol === 'https:') && url.host !== '' && - // Userinfo would end up in the per-repository receiver stored in workspace - // settings, which workspace admins can read. + // Userinfo would end up in URLs that users who are not instance admins can read. url.username === '' && url.password === '' && // Tested on the raw string, not `url.search`/`url.hash`: those are `''` for a @@ -187,6 +187,8 @@ export function isValidWebhookBaseUrl(value: unknown): boolean { // `new URL` silently percent-encodes a space in the path, where the server // rejects it outright. !/\s/.test(trimmed) && + // Shell-active characters: the URL is pasted into commands users copy. + !/[$`'"\\;&|<>(){}*!]/.test(trimmed) && !trimmed.endsWith('/') ) } @@ -207,7 +209,29 @@ export const settings: Record<string, Setting[]> = { (value?.startsWith('http') && value.includes('://') && !value?.endsWith('/') && - !value?.endsWith(' ')) + !value?.endsWith(' ')), + advancedToggle: { + label: 'The API is served on a different url than the UI', + onChange(values) { + values['api_base_url'] = values['api_base_url'] == null ? '' : null + return values + }, + checked: (values) => values['api_base_url'] != null + } + }, + { + label: 'API base url', + description: + 'Base url shown to users for the endpoints external clients call (webhooks, HTTP routes, push trigger endpoints, the CLI remote), without trailing slash. Leave empty to show the url the UI is browsed on. Set it when the API is served on a separate domain or behind an API gateway. If that url does not also serve the UI, the CLI must authenticate with a token rather than the browser login.', + key: 'api_base_url', + fieldType: 'text', + placeholder: 'https://api.windmill.company.com', + storage: 'setting', + ee_only: '', + hiddenIfNull: true, + error: + 'API base url must be an http:// or https:// url with a host, no embedded username or password, no query string or fragment, no shell metacharacters, and no trailing slash', + isValid: isValidWebhookBaseUrl }, { label: 'Email domain', @@ -354,6 +378,15 @@ export const settings: Record<string, Setting[]> = { value === null || value === '' || parseAccentColor(value) !== undefined + }, + { + label: 'Sync resource types from the hub daily', + description: + 'Every day, the server updates the resource types of the admins workspace from the Hub, overwriting local edits to the types the Hub also defines. Superadmins can also sync them on demand with "Sync resource types with Hub".', + key: 'sync_hub_resource_types_daily', + fieldType: 'boolean', + storage: 'setting', + hideInQuickSetup: true } ], Jobs: [ @@ -798,23 +831,15 @@ export const settings: Record<string, Setting[]> = { } ], 'DB Health': [], + 'OIDC signing': [], Registries: [ { label: 'Instance Python Version', description: 'Default python version for newly deployed scripts', key: 'instance_python_version', fieldType: 'select_python', - // To change latest stable version: - // 1. Change placeholder in instanceSettings.ts - // 2. Change LATEST_STABLE_PY in dockerfile - // 3. Change #[default] annotation for PyVersion in backend - placeholder: '3.10,3.11,3.12,3.13', + placeholder: '3.10,3.11,3.12,3.13,3.14,3.15', select_items: [ - { - label: 'Latest Stable', - value: 'default', - tooltip: 'python-3.12' - }, { label: '3.10' }, @@ -826,6 +851,12 @@ export const settings: Record<string, Setting[]> = { }, { label: '3.13' + }, + { + label: '3.14' + }, + { + label: '3.15' } ], storage: 'setting' @@ -1223,7 +1254,7 @@ export const settings: Record<string, Setting[]> = { storage: 'setting', ee_only: '', error: - 'Webhook base url must be an http:// or https:// url with a host, no embedded username or password, no query string or fragment, and no trailing slash', + 'Webhook base url must be an http:// or https:// url with a host, no embedded username or password, no query string or fragment, no shell metacharacters, and no trailing slash', isValid: isValidWebhookBaseUrl } ], @@ -1306,6 +1337,13 @@ export const instanceSettingsNavigationGroups = [ aiId: 'instance-settings-scim-saml', aiDescription: 'Instance SCIM/SAML settings', isEE: true + }, + { + id: 'oidc_signing', + label: 'OIDC signing', + aiId: 'instance-settings-oidc-signing', + aiDescription: 'Keys that sign and verify the OIDC tokens Windmill issues', + isEE: true } ] }, @@ -1463,6 +1501,7 @@ export const tabToCategoryMap: Record<string, string> = { github_enterprise_app: 'GitHub App', websocket: 'WebSocket', db_health: 'DB Health', + oidc_signing: 'OIDC signing', lsp: 'LSP' } @@ -1472,6 +1511,30 @@ export const tabToAuthSubTab: Record<string, 'sso' | 'oauth' | 'scim'> = { scim_saml: 'scim' } +/** + * "OIDC signing" is listed only on instances that sign with an external signer: its page + * says nothing actionable elsewhere. + */ +export function listedNavigationGroups<G extends { items: { id: string }[] }>( + groups: G[], + externalOidcSigner: boolean +): G[] { + if (externalOidcSigner) return groups + return groups.map((group) => ({ + ...group, + items: group.items.filter((item) => item.id !== 'oidc_signing') + })) +} + +/** The status endpoint is EE only: call it once the license is known. */ +export async function usesExternalOidcSigner(): Promise<boolean> { + try { + return (await OidcService.getOidcSigningStatus()).mode === 'external' + } catch { + return false + } +} + // Navigation groups for the initial setup flow (no Users tab) export const setupNavigationGroups = instanceSettingsNavigationGroups .map((group) => ({ @@ -1500,6 +1563,7 @@ export const categoryToTabMap: Record<string, string> = { 'GitHub App': 'github_enterprise_app', WebSocket: 'websocket', 'DB Health': 'db_health', + 'OIDC signing': 'oidc_signing', LSP: 'lsp' } diff --git a/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte index 8d7d107940..2f2145da9b 100644 --- a/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte +++ b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { JobService, SettingService, WorkspaceService, type AIConfig } from '$lib/gen' + import { SettingService, WorkspaceService, type AIConfig } from '$lib/gen' import { setCopilotInfo } from '$lib/aiStore' import { workspaceStore, userStore } from '$lib/stores' import { getUserExt } from '$lib/user' @@ -7,6 +7,7 @@ import { workspaceAIClients } from '../copilot/lib' import AISettings from '../workspaceSettings/AISettings.svelte' import { Alert, Button } from '../common' + import SettingCard from './SettingCard.svelte' interface Props { hasUnsavedChanges?: boolean @@ -80,20 +81,15 @@ hubSyncStatus = 'loading' hubSyncMessage = '' try { - await JobService.runWaitResultScriptByPath({ - workspace: 'admins', - path: 'u/admin/hub_sync', - requestBody: {} - }) + const res = await fetch('/api/settings/sync_cached_resource_types', { method: 'POST' }) + if (!res.ok) { + const body = await res.text() + throw new Error(body || res.statusText) + } + hubSyncMessage = await res.text() hubSyncStatus = 'success' - hubSyncMessage = 'Resource types synced from hub successfully' } catch (e: any) { - hubSyncMessage = - e?.body?.error?.message || - e?.body?.message || - (typeof e?.body === 'string' ? e.body : null) || - e?.message || - 'Failed to sync from hub' + hubSyncMessage = e?.message ?? 'Failed to sync from hub' hubSyncStatus = 'error' } } @@ -101,37 +97,31 @@ {#if loaded} {#if showHubSync} - <div - class="p-3 border rounded-md bg-surface-secondary mb-4 mt-4 flex items-center justify-between gap-4" + <SettingCard + label="Resource types" + description="AI providers require their resource types. Sync from the Hub if they are missing." + class="my-4" > - <div> - <p class="text-xs font-medium text-secondary">Resource types</p> - <p class="text-2xs text-tertiary mt-0.5"> - AI providers require their resource types. Sync from the Hub if they are missing. - </p> - </div> - <Button - variant="default" - unifiedSize="sm" - loading={hubSyncStatus === 'loading'} - onClick={syncFromHub} - > - Sync from hub - </Button> - </div> - {#if hubSyncStatus === 'success'} - <div class="mb-4"> - <Alert type="success" title="Resource types synced"> + {#snippet headerAction()} + <Button + variant="default" + unifiedSize="sm" + loading={hubSyncStatus === 'loading'} + onClick={syncFromHub} + > + Sync from hub + </Button> + {/snippet} + {#if hubSyncStatus === 'success'} + <Alert type="success" title="Resource types synced" class="mt-2"> {hubSyncMessage} </Alert> - </div> - {:else if hubSyncStatus === 'error'} - <div class="mb-4"> - <Alert type="error" title="Sync failed"> + {:else if hubSyncStatus === 'error'} + <Alert type="error" title="Sync failed" class="mt-2"> {hubSyncMessage} </Alert> - </div> - {/if} + {/if} + </SettingCard> {/if} <AISettings diff --git a/frontend/src/lib/components/instanceSettings/OidcSigningStatus.svelte b/frontend/src/lib/components/instanceSettings/OidcSigningStatus.svelte new file mode 100644 index 0000000000..e223b7452c --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/OidcSigningStatus.svelte @@ -0,0 +1,100 @@ +<script lang="ts"> + import { OidcService } from '$lib/gen' + import { Alert, Badge, CopyButton } from '$lib/components/common' + import DataTable from '$lib/components/table/DataTable.svelte' + import Head from '$lib/components/table/Head.svelte' + import Cell from '$lib/components/table/Cell.svelte' + import { displayDate } from '$lib/utils' + + type Status = Awaited<ReturnType<typeof OidcService.getOidcSigningStatus>> + + let status = $state<Status | undefined>(undefined) + let loadError = $state<string | undefined>(undefined) + + const modeLabels: Record<Status['mode'], string> = { + database: 'Key stored in the database', + file: 'Key file (OIDC_SIGNING_KEY_FILE)', + external: 'External signer (OIDC_SIGNER_URL)' + } + + $effect(() => { + OidcService.getOidcSigningStatus() + .then((s) => { + status = s + loadError = undefined + }) + .catch((e) => { + loadError = e?.body ?? e?.message ?? String(e) + }) + }) +</script> + +{#if loadError} + <Alert type="error" title="Could not load the OIDC signing status">{loadError}</Alert> +{:else if status} + <div class="flex flex-col gap-4"> + <div class="text-xs text-primary"> + <span class="text-secondary">Tokens are signed by:</span> + {modeLabels[status.mode]} + </div> + {#if status.signer} + <div class="flex flex-wrap items-center gap-2 text-xs text-primary"> + <span class="text-secondary">Last key listing:</span> + {status.signer.last_refresh_success + ? displayDate(status.signer.last_refresh_success) + : 'never'} + {#if status.signer.breaker !== 'closed'} + <Badge color="red">Signer unreachable</Badge> + {:else if !status.signer.last_refresh_error && status.signer.last_refresh_success} + <Badge color="green">Signer reachable</Badge> + {/if} + </div> + {#if status.signer.last_refresh_error} + <Alert type="warning" title="The last key listing was refused"> + {status.signer.last_refresh_error} + </Alert> + {/if} + {#if status.signer.keys_from_store} + <Alert type="warning" title="The signer has not answered since this server started"> + The keys below are those stored by the last accepted listing. Tokens cannot be signed + until the signer answers. + </Alert> + {/if} + {#if status.signer.tls_files_error} + <Alert type="warning" title="The mTLS files on disk are not in use"> + {status.signer.tls_files_error}. The previous certificate and key are kept. + </Alert> + {/if} + {/if} + <DataTable size="xs"> + <Head> + <tr> + <Cell head first>Key ID (kid)</Cell> + <Cell head>Algorithm</Cell> + <Cell head>State</Cell> + <Cell head last>Source</Cell> + </tr> + </Head> + <tbody> + {#each status.keys as key, i (`${key.source}:${key.kid ?? i}`)} + <tr> + <Cell first> + <span class="flex items-center gap-1 font-mono text-xs"> + {key.kid} + {#if key.kid}<CopyButton value={key.kid} />{/if} + </span> + </Cell> + <Cell>{key.alg}</Cell> + <Cell> + <Badge color={key.state === 'active' ? 'green' : 'gray'}>{key.state}</Badge> + </Cell> + <Cell last>{key.source}</Cell> + </tr> + {/each} + </tbody> + </DataTable> + {#if status.keys.length === 0} + <div class="text-xs text-secondary">The JWKS serves no key.</div> + {/if} + </div> +{/if} diff --git a/frontend/src/lib/components/instanceSettingsWebhookBaseUrl.test.ts b/frontend/src/lib/components/instanceSettingsWebhookBaseUrl.test.ts index c8545b073e..175a251315 100644 --- a/frontend/src/lib/components/instanceSettingsWebhookBaseUrl.test.ts +++ b/frontend/src/lib/components/instanceSettingsWebhookBaseUrl.test.ts @@ -29,7 +29,10 @@ const REJECTED = [ 'https://hooks.example.com?', 'https://hooks.example.com#', 'https://user:password@hooks.example.com', - 'https://user@hooks.example.com' + 'https://user@hooks.example.com', + 'https://hooks.example.com/$(uname)', + 'https://hooks.example.com/`uname`', + 'https://hooks.example.com/a;b' ] describe('isValidWebhookBaseUrl', () => { @@ -41,10 +44,13 @@ describe('isValidWebhookBaseUrl', () => { expect(isValidWebhookBaseUrl(value)).toBe(false) }) - it.each([true, 42, {}, [], null] as unknown[])('rejects the non-string %j without throwing', (value) => { - // `Setting.isValid` is typed `any` and YAML mode can supply any JSON shape. - expect(isValidWebhookBaseUrl(value as never)).toBe(value === null) - }) + it.each([true, 42, {}, [], null] as unknown[])( + 'rejects the non-string %j without throwing', + (value) => { + // `Setting.isValid` is typed `any` and YAML mode can supply any JSON shape. + expect(isValidWebhookBaseUrl(value as never)).toBe(value === null) + } + ) it('treats unset and blank as valid, since the setting is optional', () => { expect(isValidWebhookBaseUrl(undefined)).toBe(true) diff --git a/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte b/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte index c6b86b1cc7..950700b28f 100644 --- a/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte +++ b/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import { base } from '$app/paths' import Alert from '$lib/components/common/alert/Alert.svelte' import { WorkerService } from '$lib/gen' import { resource } from 'runed' @@ -43,7 +42,7 @@ {subject} run as Windmill jobs tagged <b>{tag}</b>, and no worker is currently listening to that tag, so they stay queued until one is. If no worker group is meant to serve it, add <b>{tag}</b> - to a group's worker tags on the <a href="{base}/workers">workers page</a>. + to a group's worker tags on the <a href="/workers">workers page</a>. </Alert> </div> {/if} diff --git a/frontend/src/lib/components/mcp/McpConnect.svelte b/frontend/src/lib/components/mcp/McpConnect.svelte index 1fe1a2fed9..a8808aa052 100644 --- a/frontend/src/lib/components/mcp/McpConnect.svelte +++ b/frontend/src/lib/components/mcp/McpConnect.svelte @@ -8,7 +8,6 @@ import McpServerOAuthConnect from './McpServerOAuthConnect.svelte' import OauthScopes from '$lib/components/OauthScopes.svelte' import { sameTopDomainOrigin } from '$lib/cookies' - import { base } from '$lib/base' import { onDestroy, untrack } from 'svelte' import { ExternalLink, Pen } from 'lucide-svelte' import { MCP_REGISTRY, findMcpEntry, findMcpEntryByUrl } from './registry' @@ -449,7 +448,7 @@ Under <span class="font-mono">u/{$userStore?.username ?? 'you'}</span> it is yours alone; in a folder, everyone in it can use the connection and its token. Saved as an <a - href="{base}/resources?workspace={ws}" + href="/resources?workspace={ws}" target="_blank" rel="noopener noreferrer" class="text-accent hover:underline inline-flex items-center gap-1" diff --git a/frontend/src/lib/components/mcp/mcpMenu.svelte.ts b/frontend/src/lib/components/mcp/mcpMenu.svelte.ts index a911d71c07..853d994014 100644 --- a/frontend/src/lib/components/mcp/mcpMenu.svelte.ts +++ b/frontend/src/lib/components/mcp/mcpMenu.svelte.ts @@ -1,4 +1,4 @@ -import { List, Plus } from 'lucide-svelte' +import { List, Loader2, Plus } from 'lucide-svelte' import type { Component } from 'svelte' import { get } from 'svelte/store' import { ResourceService } from '$lib/gen' @@ -27,6 +27,14 @@ const MAX_MENU_SERVERS = 8 // menu nobody is reading that far down. const MAX_ICON_LOOKUPS = 20 +// Not `disabled`: melt stamps that on a row at mount and never clears it, and the +// unkeyed submenu reuses this row for the first server once the load lands. +const loadingItem: Item = { + displayName: 'Loading…', + icon: Loader2, + iconProps: { class: 'animate-spin shrink-0' } +} + /** * The chat "+" menu's MCP submenu: one row per connected server, checked when it * is on, then the way to manage them. Connecting and deleting live in the @@ -38,7 +46,7 @@ export class McpMenu { #seq = 0 /** Rows for the workspace named by `#rowsWorkspace`, and meaningless for any other. */ #rows = $state<Row[]>([]) - #rowsWorkspace: string | undefined = undefined + #rowsWorkspace = $state<string | undefined>(undefined) constructor(manager: AIChatManager, onManage: () => void) { this.#manager = manager @@ -73,6 +81,10 @@ export class McpMenu { editedAt: r.edited_at, enabled: isMcpEnabled(ws, r.path) })) + // Enabled first: those are the ones a quick visit is most likely about. + // Sorted here, once per load, not in `items()`: the open submenu re-derives + // from `items()`, so a toggle would move rows under the cursor. + .sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)) this.#rowsWorkspace = ws void this.#loadIcons(ws, seq) } catch { @@ -143,23 +155,22 @@ export class McpMenu { await this.#manager.refreshMcpServers(ws) } - /** Loaded on open so the checks are current. */ - async items(closeMenu?: () => void): Promise<Item[]> { + /** Called on open so the checks are current; never awaited, see `items`. */ + refresh() { + const ws = this.#ws + if (ws) void this.#load(ws) + } + + /** + * Built from the live rows, so call it from a getter: the submenu then fills in + * when a load lands instead of the whole "+" menu waiting on the fetch. Rows for + * another workspace are not shown — same path, different server. + */ + items(closeMenu?: () => void): Item[] { const ws = this.#ws if (!ws) return [] - // The menu opens on what is already known and refreshes behind it: awaited - // inline it would stall the whole "+" menu, attachments included. Rows for - // another workspace are not "already known" — same path, different server. - if (this.#rowsWorkspace !== ws) { - this.#rows = [] - await this.#load(ws) - } else { - void this.#load(ws) - } - // Enabled first: those are the ones a quick visit is most likely about. - const ordered = [...this.#rows].sort( - (a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path) - ) + if (this.#rowsWorkspace !== ws) return [loadingItem] + const ordered = this.#rows const shown = ordered.slice(0, MAX_MENU_SERVERS) const manage = () => { closeMenu?.() diff --git a/frontend/src/lib/components/mcp/providerIcon.ts b/frontend/src/lib/components/mcp/providerIcon.ts index ed8d059a57..a666b2b1c5 100644 --- a/frontend/src/lib/components/mcp/providerIcon.ts +++ b/frontend/src/lib/components/mcp/providerIcon.ts @@ -12,8 +12,11 @@ import { findMcpEntry, findMcpEntryByUrl } from './registry' * the chat does not otherwise reach that barrel. `import.meta.glob` gives the * filenames at build time and the module only when a match is actually used, so * a workspace with two connections downloads two icons. + * + * The pattern is relative because svelte-package rewrites `$lib` only in import + * specifiers: an aliased glob ships as-is and no consumer's bundler expands it. */ -const iconModules = import.meta.glob('$lib/components/icons/*.svelte') as Record< +const iconModules = import.meta.glob('../icons/*.svelte') as Record< string, () => Promise<{ default: Component<any> }> > diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index ab6de64123..02ed1d90f7 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -16,6 +16,10 @@ interface Props { placement?: Placement + /** Inline style on the panel. A margin here shifts it after floating-ui has placed it, + * which is the only way to anchor it to a box wider than the trigger: melt's menubar + * menu ignores `positioning.offset`. */ + contentStyle?: string justifyEnd?: boolean lightMode?: boolean maxHeight?: number @@ -45,6 +49,7 @@ let { placement = 'right-start', + contentStyle = '', justifyEnd = false, lightMode = false, maxHeight = 900, @@ -221,6 +226,7 @@ onmouseenter={cancelPendingClose} onmouseleave={scheduleClose} transition:placementFly={{ duration: 100, placement }} + style={contentStyle} class={twMerge( 'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none', // Default: scroll on the melt element. submenuSafe moves it to the inner diff --git a/frontend/src/lib/components/moveRenameManager.ts b/frontend/src/lib/components/moveRenameManager.ts index 1500f8fc69..e5844a3351 100644 --- a/frontend/src/lib/components/moveRenameManager.ts +++ b/frontend/src/lib/components/moveRenameManager.ts @@ -1,7 +1,8 @@ -import { AppService, FlowService, ScriptService } from '$lib/gen' +import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen' +import { agentEditorRefusal } from './flows/agentResourceUtils' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' -type ItemKind = 'flow' | 'script' | 'app' +type ItemKind = 'flow' | 'script' | 'app' | 'agent' /** * Check whether a flow uses on_behalf_of_email. @@ -71,6 +72,24 @@ export async function updateItemPathAndSummary(opts: { skip_draft_deletion: true } }) + } else if (kind === 'agent') { + // An agent is a resource, and its summary is the resource's description. The read before + // the write is the same one the agent editor does: an update carries no resource type, so + // a path deleted and recreated as something else in the meantime would otherwise take an + // agent's config. + const current = await ResourceService.getResource({ workspace, path: initialPath }) + const refused = agentEditorRefusal(initialPath, current.resource_type) + if (refused) { + throw new Error(refused) + } + // Only what this popover edits: the backend sets a column per field it is given, so the + // agent's own config, its type and `ws_specific` are left where they are rather than read + // here and written back over whatever changed in between. + await ResourceService.updateResource({ + workspace, + path: initialPath, + requestBody: { path: newPath, description: newSummary, labels } + }) } else if (kind === 'app') { await AppService.updateApp({ workspace, diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts index 93a7f2c0de..70f1c63c1c 100644 --- a/frontend/src/lib/components/operatingWorkspace.svelte.ts +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -22,6 +22,14 @@ export function setOperatingWorkspace(resolve: () => string | undefined): void { setContext(KEY, outer ? () => resolve() ?? outer() : resolve) } +/** This subtree's operating workspace as an entry for a `ContextBridge` contexts map. A snippet + * handed to the page header's band renders outside the subtree that declared the workspace, so + * without this it would quietly act on the navigation workspace instead. Reads context: call + * during component initialisation. */ +export function operatingWorkspaceContextEntry(): [any, any] { + return [KEY, getContext(KEY)] +} + /** The workspace this component acts on, as a store. Reads context: call during component * initialisation. Falls back to the navigation workspace where no host set one, or where the * host's resolves to nothing. */ diff --git a/frontend/src/lib/components/otherUserDraftLoad.svelte.ts b/frontend/src/lib/components/otherUserDraftLoad.svelte.ts index 7ba5cde6fb..e2bf7c87d8 100644 --- a/frontend/src/lib/components/otherUserDraftLoad.svelte.ts +++ b/frontend/src/lib/components/otherUserDraftLoad.svelte.ts @@ -1,6 +1,5 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity' import { goto } from '$lib/navigation' -import { base } from '$app/paths' import { UserDraft, draftValuesEqual, type UserDraftItemKind } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' @@ -55,13 +54,13 @@ const overwriteOpen = new SvelteSet<string>() export function editRouteFor(itemKind: UserDraftItemKind, path: string): string { switch (itemKind) { case 'script': - return `${base}/scripts/edit/${path}` + return `/scripts/edit/${path}` case 'flow': - return `${base}/flows/edit/${path}` + return `/flows/edit/${path}` case 'app': - return `${base}/apps/edit/${path}` + return `/apps/edit/${path}` case 'raw_app': - return `${base}/apps_raw/edit/${path}` + return `/apps_raw/edit/${path}` default: throw new Error(`Cannot load drafts of kind ${itemKind}`) } diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts new file mode 100644 index 0000000000..6098095a30 --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -0,0 +1,226 @@ +import type { Snippet } from 'svelte' +import type { WorkspaceItemKind } from './workspacePicker' + +/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */ +export type PageHeaderItem = { + /** Absent for an item the picker does not file under a kind — an agent's detail page. The + * breadcrumb then names the item without claiming it is one of the three. */ + kind?: WorkspaceItemKind + /** Shown whole. Clicking it opens whatever the page hangs off it with `pathTrigger`, and copies + * the path where there is none. */ + path: string | undefined + /** Wraps the band's own path segment in whatever the page wants behind it — its rename + * popover, anchored there rather than beside the summary, so the editor opens under the half + * of the name that was clicked. The band passes the path as it renders it and the classes its + * own segment wears, so the page's control keeps the breadcrumb's look rather than inventing + * one. Without this the segment copies the path on click. */ + pathTrigger?: Snippet<[Snippet, string]> + summary?: string + /** Rendered in place of the plain summary, for a page whose summary is itself a control — + * a detail page's rename-and-labels popover, an editor's editable title. */ + summaryContent?: Snippet +} + +/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */ +export type PageHeaderSection = { + label: string + /** The page the section belongs to, when the section is one pane of it — a settings page's + * active tab. Drawn as a lead segment before `label`. */ + parent?: string + /** Rendered in place of `label` when the name is itself a control, such as an editable title. */ + content?: Snippet + /** Scopes the segment's picker, when the section maps to a kind of item. */ + kind?: WorkspaceItemKind +} + +export type PageHeaderContent = { + item?: PageHeaderItem + section?: PageHeaderSection + /** Rendered at the right end of the bar — the page's own buttons. */ + actions?: Snippet + /** Orders these buttons among the other registrations', ascending; registration order breaks a + * tie. For a menu that must stay at the far end however many pages register before it. */ + actionsOrder?: number + /** These actions can give width back under pressure, so the bar lets their box shrink on a + * phone. Two ways to earn it: a control that is narrower when it has to be (the Runs filter + * row, whose search field yields), or a set that scrolls inside its own box (the app and + * pipeline editors, whose rows are too wide for any bar). Without it the box holds its content + * width, because squeezing a plain row of buttons only pushes them out of the bar. */ + actionsFlexible?: boolean + /** These actions are not a row of buttons at the far end but a surface of their own, running + * from the breadcrumb to the bar's right edge: a session in full screen, whose tab strip and + * controls share one shape that opens into the page below. The bar gives up its right padding + * to it, since the surface is what meets the edge. */ + actionsFill?: boolean + /** How many levels past the full row these actions can fold through, each a narrower row than + * the last. The bar picks the level from the room its breadcrumb leaves once it has dropped + * all it can, and the page reads it back as `pageHeader.actionFold`. Without it the actions + * never fold and the breadcrumb gives up width around them. */ + actionFolds?: number + /** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons + * that are there only while the bar has room for everything. */ + earlyActionFolds?: number + /** Changes whenever the actions may have changed width at a level that is not on screen — a + * run's Cancel, folded into a menu, going once the job completes. The bar keeps what it + * measured per version, so a new one is measured afresh rather than judged by stale widths. */ + actionsVersion?: string + /** A second row of the band, under the breadcrumb and the actions: where a control too wide + * for one row goes at a fold that has no room for it beside them, rather than being squeezed + * until it is useless. The band is taller while it is there. */ + belowBar?: Snippet + /** The fold from which the bar shows `belowBar`. The bar decides rather than the page passing + * the row in only at that fold: the fold is derived from this registration, so a registration + * that read it would be a cycle. */ + belowBarFold?: number + /** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name, + * like a documentation tooltip, or a control that acts on the thing named — an app's Edit, + * which at the far end of the bar would be a journey away from what it edits. */ + afterName?: Snippet + /** The workspace this page acts on, when it is not the one the app is pointed at — a session + * running in a fork. The breadcrumb then names that workspace, and its fork part shows only + * when it really is a fork. */ + actingWorkspaceId?: string + /** Width in px, at the right of the viewport, that this page owns from the top down — a + * session's side panel. The band stops there instead of running over it, and the band floats + * above the page rather than pushing it down, so that column starts at the top. */ + barRightInset?: number + /** The bottom edge under the bar, which the bar owns so a page cannot draw a second one. + * `onScroll` (the default) shows it only while something is passing under the bar — at the top + * of a list there is nothing to divide. `always` is for a page whose content starts right + * under the bar and needs the division at rest: an editor, a detail page, a settings pane. + * `none` is for a page that genuinely draws its own first line, such as the script editor's + * toolbar or the flow editor's canvas. */ + separator?: 'onScroll' | 'always' | 'none' + /** Contexts this registration's snippets look up — its actions, what it hangs after the name, + * and its summary. They all render under the header rather than under the page that wrote + * them, so anything the page's tree provides has to travel with them. */ + contexts?: Map<any, any> +} + +// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb +// and actions by registering here. +// +// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or +// summary the page edits reaches the bar through the layout's own dependency tracking, with no +// effect keeping two copies in step. +// +// Registrations stack, and what the bar shows is their merge (see `content`). Pages register on +// mount, so the first entry is the outermost one — an editor nested in a session pane or a drawer +// is asked to register nothing. +let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([]) +let nextId = 0 + +/** + * A phone's bar: a tighter gutter, and the width below which controls that cannot fold through + * `actionFolds` — a flexible filter row, a banner — slim down. A phone in portrait is ~390px of + * bar and a tablet in portrait ~720, which is why the line sits above the tablet. + */ +export const PHONE_BAR = 800 + +/** The bar's height, and the padding a page owes the band when the band floats over it rather than + * sitting in the flow (see `barRightInset`). They are one measurement in two forms: raise one and + * the other has to follow, or the page's own content disappears under the bar. Written as literal + * class names so Tailwind sees them. */ +export const BAR_HEIGHT = 'h-12' +export const BAR_TOP_PAD = 'pt-12' +/** The same height in px, for the places that animate to the bar's bottom edge rather than + * standing under it — a transition's `css` and a measurement's fallback take a number. */ +export const BAR_HEIGHT_PX = 48 + +// The bar's own width, written by the bar and read by the pages that fill it. A page's buttons +// share that row with the breadcrumb, so it is the width they crowd against — not the window's, +// which the sidebar and a page's side panel both take from. +let barWidth = $state(0) +// The bar owns the fold level: it is derived from the bar's own measurements, so the bar hands +// over a getter rather than writing a copy that an effect would have to keep in step. +let actionFoldSource = $state<(() => number) | undefined>(undefined) + +/** + * One registration's content, or undefined while it cannot answer. + * + * An entry is a getter closing over another component's props, evaluated here, in a tree of its + * own. Nothing orders a parent clearing those props against the child's teardown: a route that + * drops the `{#if}` around an editor and the value it binds into it does both in one flush, and + * this derived re-runs on the same flush, before the editor's own cleanup releases its entry. The + * entry has nothing to say in that window, which is not the same as the page having no header — + * so it is skipped, and whatever registered before it still stands. + */ +function read(e: { get: () => PageHeaderContent }): PageHeaderContent | undefined { + try { + return e.get() + } catch { + return undefined + } +} + +export const pageHeader = { + /** + * The registrations merged, later ones winning field by field: a route sets the frame (where + * the bar goes) and something nested in it — the active session, say — fills the breadcrumb + * and the actions. + */ + get content(): PageHeaderContent | undefined { + if (entries.length === 0) return undefined + const merged: PageHeaderContent = {} + for (const e of entries) { + const c = read(e) + if (!c) continue + if (c.item !== undefined) merged.item = c.item + if (c.section !== undefined) merged.section = c.section + if (c.afterName !== undefined) merged.afterName = c.afterName + if (c.contexts !== undefined) merged.contexts = c.contexts + if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId + if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset + if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible + if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill + if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds + if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds + if (c.actionsVersion !== undefined) merged.actionsVersion = c.actionsVersion + if (c.belowBar !== undefined) merged.belowBar = c.belowBar + if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold + if (c.separator !== undefined) merged.separator = c.separator + } + return merged + }, + /** + * Every registration's actions, in registration order: a page and something inside it can both + * put buttons in the band, and each renders with the contexts its own registration named. + */ + get actions(): { render: Snippet; contexts?: Map<any, any> }[] { + return entries + .flatMap((e, i) => { + const c = read(e) + return c?.actions + ? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }] + : [] + }) + .sort((a, b) => a.order - b.order || a.i - b.i) + }, + /** Width of the bar in px, or 0 before it has been measured. */ + get barWidth(): number { + return barWidth + }, + setBarWidth(w: number) { + barWidth = w + }, + /** How far the page's actions are folded: 0 for the full row, up to its `actionFolds`. */ + get actionFold(): number { + return actionFoldSource?.() ?? 0 + }, + /** Called by the bar once; returns the release to call when the bar goes away. */ + provideActionFold(get: () => number): () => void { + actionFoldSource = get + return () => { + if (actionFoldSource === get) actionFoldSource = undefined + } + }, + /** Registers on mount and returns the id to release on destroy. */ + register(get: () => PageHeaderContent): number { + const id = nextId++ + entries = [...entries, { id, get }] + return id + }, + release(id: number) { + entries = entries.filter((e) => e.id !== id) + } +} diff --git a/frontend/src/lib/components/pageHeaderRegistry.test.ts b/frontend/src/lib/components/pageHeaderRegistry.test.ts new file mode 100644 index 0000000000..035eabb60e --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, afterEach } from 'vitest' +import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' +import type { Snippet } from 'svelte' + +// The merge rule every registration site depends on: a page sets the frame and something nested +// in it fills the breadcrumb, so whether a component registers at all decides whose name the band +// shows. A viewer that registered wherever it was mounted renamed the band of the session hosting +// it. +const snippet = (name: string) => name as unknown as Snippet + +const ids: number[] = [] +function register(c: PageHeaderContent) { + ids.push(pageHeader.register(() => c)) +} +afterEach(() => { + while (ids.length) pageHeader.release(ids.pop()!) +}) + +describe('pageHeader', () => { + it('is empty with nothing registered', () => { + expect(pageHeader.content).toBeUndefined() + }) + + it('lets a later registration win field by field', () => { + register({ section: { label: 'Sessions' }, barRightInset: 400 }) + register({ item: { path: 'u/admin/app' } }) + expect(pageHeader.content).toEqual({ + section: { label: 'Sessions' }, + item: { path: 'u/admin/app' }, + barRightInset: 400 + }) + }) + + it('gives the fields of a released registration back', () => { + register({ section: { label: 'Sessions' } }) + register({ item: { path: 'u/admin/app' } }) + pageHeader.release(ids.pop()!) + expect(pageHeader.content?.item).toBeUndefined() + expect(pageHeader.content?.section).toEqual({ label: 'Sessions' }) + }) + + it('orders actions by actionsOrder, then by registration', () => { + register({ actions: snippet('page'), actionsOrder: 10 }) + register({ actions: snippet('nested') }) + register({ actions: snippet('alsoNested') }) + expect(pageHeader.actions.map((a) => a.render)).toEqual(['nested', 'alsoNested', 'page']) + }) + + it('carries the contexts of each registration with its actions', () => { + const pageContexts = new Map([['TriggerContext', 1]]) + register({ actions: snippet('page'), contexts: pageContexts }) + register({ actions: snippet('nested') }) + expect(pageHeader.actions.map((a) => a.contexts)).toEqual([pageContexts, undefined]) + }) +}) diff --git a/frontend/src/lib/components/pageHeaderStages.test.ts b/frontend/src/lib/components/pageHeaderStages.test.ts new file mode 100644 index 0000000000..7366fdd244 --- /dev/null +++ b/frontend/src/lib/components/pageHeaderStages.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { BAR_STAGES, fitBar, type BarMeasure } from './pageHeaderStages' + +// Each stage 50px narrower than the last, so the bar width alone says which one fits. +const trail = BAR_STAGES.map((_, i) => 600 - i * 50) +const base: BarMeasure = { + barWidth: 2000, + chrome: 0, + trail, + summary: BAR_STAGES.map(() => 0), + actions: [300, 200, 100], + folds: 2, + natural: { family: 0, fork: 0, summary: 0, name: 0 } +} + +describe('fitBar', () => { + it('picks the first stage that fits beside the full row of actions', () => { + expect(fitBar({ ...base, barWidth: 900 }).stage).toBe(0) + expect(fitBar({ ...base, barWidth: 800 })).toMatchObject({ stage: 2, fold: 0 }) + }) + + it('folds the actions only once the breadcrumb is at its last stage, and keeps it there', () => { + // 350 + 300 overflows a 600px bar; 350 + 200 fits. Stage 0 would fit beside the folded row + // too, but the parts a narrowing bar dropped do not come back when the buttons fold. + expect(fitBar({ ...base, barWidth: 600 })).toMatchObject({ stage: 5, fold: 1 }) + expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 }) + }) + + it('spends early folds before the breadcrumb drops anything', () => { + // 600 + 200 fits an 800px bar at the first fold; without early folds stage 2 would have + // dropped parts beside the full row instead. + expect(fitBar({ ...base, barWidth: 800, earlyFolds: 1 })).toMatchObject({ stage: 0, fold: 1 }) + // Past that, the breadcrumb gives way at the early fold before the later folds start. + expect(fitBar({ ...base, barWidth: 700, earlyFolds: 1 })).toMatchObject({ stage: 2, fold: 1 }) + }) + + it('tries a level it has not seen at the width of the next one folded further', () => { + // Only the second fold has been measured: the levels before it are assumed no wider, so the + // bar tries the full row first and goes on to the first fold once it has seen it overflow. + const unseen = { ...base, barWidth: 600, actions: [undefined, undefined, 100] } + expect(fitBar(unseen).fold).toBe(0) + expect(fitBar({ ...unseen, actions: [300, undefined, 100] }).fold).toBe(1) + }) + + it("takes the squeeze from the path's name before the summary", () => { + const fit = fitBar({ + ...base, + barWidth: 360, + actions: [0], + folds: 0, + trail: trail.map((w) => w - 400), + summary: BAR_STAGES.map(() => 100), + natural: { family: 0, fork: 0, summary: 300, name: 300 } + }) + // Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 100, so 180 + // has to go: all 140 the name has above its floor, then 40 from the summary. + expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 260 }, scopesGive: false }) + }) + + it('keeps the summary and drops the path when only one of them fits', () => { + const natural = { family: 0, fork: 0, summary: 300, name: 300 } + // Stage 2 is the first without the path: the name is no longer a part the bar sizes. + const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 }) + expect(fit.stage).toBe(2) + expect(fit.caps.name).toBeUndefined() + // A placeholder offering to add a summary names nothing: the path stays a part the bar sizes. + const placeholder = fitBar({ + ...base, + barWidth: 520, + natural, + actions: [0], + folds: 0, + summaryNamesItem: false + }) + expect(placeholder.caps.name).toBeDefined() + }) +}) diff --git a/frontend/src/lib/components/pageHeaderStages.ts b/frontend/src/lib/components/pageHeaderStages.ts new file mode 100644 index 0000000000..ca776c9199 --- /dev/null +++ b/frontend/src/lib/components/pageHeaderStages.ts @@ -0,0 +1,213 @@ +/** + * How the page header gives up width. Whole parts drop in a fixed order, and the bar shows the + * first stage whose parts, each held at its floor width, fit beside the page's actions. Which + * stage that is depends on the names in the bar rather than on a pixel breakpoint. Past the last + * stage the page's actions fold, one level at a time; past their last level the item's name and + * its summary truncate, then the folders above it. + */ + +/** What the breadcrumb itself drops. */ +export type TrailStage = { + /** Inside a fork, the family's name before the fork's. Outside one it is the workspace's own + * name and stays until `wsButton`. */ + familyName: boolean + /** The item's path. Dropped only for an item that has a summary: the summary is what a + * person calls the thing, while its path is where it is filed. Without one, the path is the + * item's only name and stays. */ + path: boolean + forkName: boolean + /** The workspace part as its disc alone, still leading home, beside its picker's chevron. */ + wsButton: boolean + /** The folders between the first one and the name; collapsed, they read as `…`. */ + middleLevels: boolean +} + +export type BarStage = TrailStage & { label: string } + +export const BAR_STAGES: BarStage[] = [ + { + label: 'Full', + familyName: true, + path: true, + forkName: true, + wsButton: false, + middleLevels: true + }, + { + label: 'Family name dropped', + familyName: false, + path: true, + forkName: true, + wsButton: false, + middleLevels: true + }, + { + label: 'Path dropped for the summary', + familyName: false, + path: false, + forkName: true, + wsButton: false, + middleLevels: true + }, + { + label: 'Fork name dropped', + familyName: false, + path: false, + forkName: false, + wsButton: false, + middleLevels: true + }, + { + label: 'Workspace as its disc', + familyName: false, + path: false, + forkName: false, + wsButton: true, + middleLevels: true + }, + { + label: 'Middle folders collapsed', + familyName: false, + path: false, + forkName: false, + wsButton: true, + middleLevels: false + } +] + +export type Part = 'family' | 'fork' | 'summary' | 'name' + +/** The trail at each stage with its names at their floors, and each name's own width. */ +export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number } + +/** The width a part may truncate to before the next stage drops something whole. The summary's + * is wide: below it the path goes rather than the summary losing most of what it says. */ +export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 200, name: 160 } +/** Past the last stage and fold, the item's name and then its summary keep this much before the + * folders give any. */ +export const SQUEEZED_FLOOR = 40 + +/** Middle folders collapsed to one `…`; the first folder and the name always stay. */ +export function shownPathLevels(levels: string[], stage: TrailStage): string[] { + const scopes = levels.slice(0, -1) + if (stage.middleLevels || scopes.length <= 1) return levels + return [scopes[0], '…', levels[levels.length - 1]] +} + +/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */ +export function partVisible( + part: Part, + stage: BarStage, + natural: Record<Part, number>, + summaryNamesItem = true +): boolean { + if (natural[part] <= 0) return false + switch (part) { + case 'summary': + return true + case 'family': + // Outside a fork the family's name is the workspace's: it goes only with the whole part. + return !stage.wsButton && (stage.familyName || natural.fork <= 0) + case 'fork': + return !stage.wsButton && stage.forkName + case 'name': + return stage.path || natural.summary <= 0 || !summaryNamesItem + } +} + +export type BarMeasure = { + barWidth: number + /** The bar's padding and the sidebar handle: what is there at every stage. */ + chrome: number + /** Per stage: the breadcrumb with each name at its floor. */ + trail: number[] + /** Per stage: the summary at its floor with the separator before it, 0 where there is none. */ + summary: number[] + /** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */ + actions: (number | undefined)[] + /** How many levels past the full row the page's actions can fold through. */ + folds: number + /** How many of those levels fold before the breadcrumb drops anything: buttons that are there + * only while there is room for everything. */ + earlyFolds?: number + natural: Record<Part, number> + /** The summary is the item's own text, not a placeholder offering to add one: only then does it + * name the item well enough for the path to go. */ + summaryNamesItem?: boolean +} + +export type BarFit = { + stage: number + fold: number + /** Max widths for the parts that give width, in the order they give it. */ + caps: Partial<Record<Part, number>> + /** Even the name at its squeezed floor does not fit: the folders above it truncate too. */ + scopesGive: boolean +} + +/** + * Flex shrinks every part in proportion, so however lopsided the factors, the name gives a few + * pixels while the summary still has room, and a few pixels are an ellipsis. The parts are rigid + * instead and the deficit is taken from them in a strict order, each down to its floor. + */ +export function fitBar(m: BarMeasure): BarFit { + // A level not yet seen is assumed as narrow as the next one folded further: trying it is how it + // gets measured, and once it has been, the bar never picks it again unless it fits. + const actionsAt = (fold: number) => { + for (let f = fold; f < m.actions.length; f++) + if (m.actions[f] !== undefined) return m.actions[f]! + return 0 + } + const last = BAR_STAGES.length - 1 + const floorWidth = (s: number, f: number) => + m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f) + + const early = Math.min(m.earlyFolds ?? 0, m.folds) + let stage = last + let fold = m.folds + choose: { + for (let f = 0; f < early; f++) { + if (floorWidth(0, f) <= m.barWidth) { + stage = 0 + fold = f + break choose + } + } + for (let s = 0; s <= last; s++) { + if (floorWidth(s, early) <= m.barWidth) { + stage = s + fold = early + break choose + } + } + // The other levels fold only once the breadcrumb has given all it can, so a narrowing bar + // only ever loses things: the parts it dropped do not come back when the buttons fold. + for (let f = early + 1; f <= m.folds; f++) { + if (floorWidth(last, f) <= m.barWidth) { + fold = f + break choose + } + } + } + + const at = BAR_STAGES[stage] + // The summary gives last: it is what a person calls the item, so the path's name goes first. + const parts = (['family', 'fork', 'name', 'summary'] as Part[]).filter((p) => + partVisible(p, at, m.natural, m.summaryNamesItem ?? true) + ) + const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p])) + // The stage was measured with its parts at their floors; this is its width with them whole. + let deficit = floorWidth(stage, fold) + parts.reduce((s, p) => s + slack(p), 0) - m.barWidth + const caps: Partial<Record<Part, number>> = {} + const take = (p: Part, floor: number) => { + if (deficit <= 0 || !parts.includes(p)) return + const current = caps[p] ?? m.natural[p] + const give = Math.min(deficit, Math.max(0, current - Math.min(m.natural[p], floor))) + if (give > 0) caps[p] = current - give + deficit -= give + } + for (const p of parts) take(p, FLOOR[p]) + take('name', SQUEEZED_FLOOR) + take('summary', SQUEEZED_FLOOR) + return { stage, fold, caps, scopesGive: deficit > 0 } +} diff --git a/frontend/src/lib/components/preview/FlowPreviewStatus.svelte b/frontend/src/lib/components/preview/FlowPreviewStatus.svelte index 68587fbf24..9d794828d8 100644 --- a/frontend/src/lib/components/preview/FlowPreviewStatus.svelte +++ b/frontend/src/lib/components/preview/FlowPreviewStatus.svelte @@ -1,6 +1,5 @@ <script lang="ts"> import type { CompletedJob, QueuedJob } from '$lib/gen' - import { base } from '$lib/base' import JobStatus from '../JobStatus.svelte' import { ExternalLinkIcon } from 'lucide-svelte' @@ -32,7 +31,7 @@ <a rel="noreferrer" target="_blank" - href="{base}/run/{job?.id}?workspace={job?.workspace_id}" + href="/run/{job?.id}?workspace={job?.workspace_id}" > {truncateRev(job?.id, 8)} <ExternalLinkIcon size={14} class="inline mb-1 ml-1" /> diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index c3eadc1f68..9f675664b1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -52,6 +52,11 @@ // Job ids launched by this app instance — see `gateJobIds`. const launchedJobs = new Set<string>() + /** For a host that loads this component after the bundle may already have sent requests. */ + export function handleMessage(event: MessageEvent) { + return listener(event) + } + let listener = async (event) => { // Only accept messages from the bundle iframe (opaque origin) or the // detached preview window we opened, so other frames/extensions can't diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index af0af7ed5b..0ebb67094b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -14,6 +14,7 @@ ADMIN_DATATABLE_ROLE, datatableNameTakesRole, defaultMigrationRole, + namedDefaultRole, type DbInput } from '../dbTypes' import type { PendingRowAction, SelectedTable } from '../DBManager.svelte' @@ -173,15 +174,15 @@ return rolesOfCurrent?.permissioned ? selectedRole : undefined } const entry = datatableTree.current.find((t) => t.datatable_name === datatable) - return entry?.permissioned ? entry.default_role : undefined + return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined } function defaultRoleOf(datatable: string): string | undefined { if (datatable === selectedDatatable && rolesOfCurrent?.permissioned) { - return rolesOfCurrent.default_role + return namedDefaultRole(rolesOfCurrent.default_role) } const entry = datatableTree.current.find((t) => t.datatable_name === datatable) - return entry?.permissioned ? entry.default_role : undefined + return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined } const tableDatatable = (t: SelectedTable) => t.datatable ?? selectedDatatable diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8a0655d70e..454030009b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -21,6 +21,7 @@ genWmillTs, normalizeRawAppRuntimeLogs, createRawAppBuildTracker, + RAW_APP_BUILD_WAIT_MS, type Runnable, type RawAppRuntimeLogEntry, type RawAppRuntimeLogRequester, @@ -50,7 +51,14 @@ import { createAppSelectedContext, type AppCodeSelectionElement } from '../copilot/chat/context' import { captureScale, MAX_IMAGE_EDGE } from '../copilot/chat/imageUtils' import { rawAppLintStore } from './lintStore' - import { dbSchemas } from '$lib/stores' + import { dbSchemas, userStore } from '$lib/stores' + import RawAppSdkConsent from './RawAppSdkConsent.svelte' + import { + hasStoredSdkConsent, + movePreviewSdkConsent, + sdkConsentCovers, + storeSdkConsent + } from './sdkScopes' import { MousePointerSquareDashed, RefreshCw, @@ -80,8 +88,11 @@ import { randomSecret, randomUUID } from '$lib/utils/uuid' import { editorFontSize } from '$lib/editorFontSize.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { conformBuilderAppPolicy } from './builderAppPolicy' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderApps = useOperatorBuilderApps() interface Props { files?: Record<string, string> @@ -147,6 +158,8 @@ * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ pendingDraftPath?: string | undefined // Threaded to the AutosaveIndicator's "Reset to deployed" button. + /** `Exit & see details`: see RawAppEditorHeader. */ + onDetails?: (e: { path: string }) => void onResetToDeployed?: () => void | Promise<void> /** The app_version the draft forked from, for the deploy-time "new version * deployed" guard: deploying is refused with a confirmation while it is not @@ -191,6 +204,8 @@ // EditorHeader's path/breadcrumb row dropped (summary only). Used by the // session preview to save vertical room. condensedHeader?: boolean + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } let { @@ -214,6 +229,7 @@ autosavePath = undefined, defaultSplitWithPreview = true, pendingDraftPath = $bindable(undefined), + onDetails, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -230,6 +246,7 @@ onRestore, onSavedNewAppPath, condensedHeader = false, + ownsPageHeader = false, version = undefined, onTakeLatest = undefined, draftBaseVersion = undefined @@ -399,6 +416,11 @@ if (url) URL.revokeObjectURL(url) } }) + // The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user + // deployed carries, so the editor conforms the policy as soon as it opens. + $effect(() => { + if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore) + }) // An app with sandbox isolation previews the way it runs once deployed: in the // served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce // in its URL authenticates its `windmill:ready`, as in `RawAppPreview`. @@ -414,6 +436,9 @@ // carry our origin, so they travel wrapped: listeners that trust a same-origin // message must not see the isolated app's as their own. let previewControllerUrl: string | undefined = undefined + // Editor-to-controller only, never relayed to the app: the consent prompt lives in + // the editor, so the pop-out says why it is blank. + const PREVIEW_NOTICE = 'wm:previewNotice' onDestroy(() => { if (previewControllerUrl) URL.revokeObjectURL(previewControllerUrl) }) @@ -423,14 +448,19 @@ [ `<!DOCTYPE html><html><head><meta charset="utf-8"><title>App preview - + - + { @@ -2467,7 +2589,12 @@ addEventListener('message', function (e) { {onTakeLatest} {draftBaseVersion} {onRestore} - {onSavedNewAppPath} + onSavedNewAppPath={(newAppPath) => { + if (opWorkspace && $userStore?.email) { + movePreviewSdkConsent($userStore.email, opWorkspace, path, newAppPath) + } + onSavedNewAppPath?.(newAppPath) + }} {policy} {diffDrawer} {newApp} @@ -2484,6 +2611,7 @@ addEventListener('message', function (e) { {getBundle} {onNavigate} {onDeploy} + {onDetails} {onResetToDeployed} {loadedFromDraft} {othersDraftsCount} @@ -2496,6 +2624,7 @@ addEventListener('message', function (e) { sidebarCollapsed={sidebarCollapsed.val} onToggleSidebar={() => (sidebarCollapsed.val = !sidebarCollapsed.val)} {condensedHeader} + {ownsPageHeader} /> {/key} {/if} - {#if buildError} + {#if sdkPrompt} + +
+ {#if sdkPrompt.declined} +
+

+ The preview is blocked until you grant the permissions this app declares. +

+ +
+ {:else} + sdkPrompt && (sdkPrompt.declined = true)} + /> + {/if} +
+ {:else if buildError} + {/snippet} +

+					
+			
+ + +{/if} +{#if !job || (job?.job_kind != 'flow' && job?.job_kind != 'flownode' && job?.job_kind != 'flowpreview')} + +{/if} + +{#snippet flowRestartButton( + state: ReturnType, + step: string, + triggerStyle: 'button' | 'link' +)} + {#if job} + { + onNavigate('/run/' + newJobId + '?workspace=' + workspaceId) + }} + flowPath={job.script_path} + flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined} + disabled={!$enterpriseLicense} + enterpriseOnly={!$enterpriseLicense} + {triggerStyle} + unifiedSize="sm" + /> + {/if} +{/snippet} + + + + + +{#if loadError?.status === 403} +
+
+ +
+

+ This run exists in {workspaceId}, but you don't have + permission to view it. +

+

+ Ask a colleague who can see it to open the run and use the + Share button to send you a read-only link. Opening that + link will grant you access to this run (and its steps). +

+
+
+
+ +
+
+
+{:else if notfound || (job?.workspace_id != undefined && workspaceId != undefined && job?.workspace_id != workspaceId)} + {@const currentWorkspace = $userWorkspaces.find((w) => w.id === workspaceId)} + {@const otherWorkspaces = $userWorkspaces.filter((w) => w.id !== workspaceId)} +
+
+
+ +
+

Run not found

+

+ No run with ID + {runId} + exists in + {currentWorkspace?.name ?? workspaceId}. It + may belong to another workspace, or it may have been deleted. +

+
+ + {#if otherWorkspaces.length > 0} +
+

Look in another workspace

+
+ {#each otherWorkspaces as workspace (workspace.id)} + + {/each} +
+
+ {/if} + +
+ +
+
+{:else} + + + {#if embedded} +
+ {@render runActions()} +
+ {:else} + + {/if} + + {#snippet runActions()} + {#if actionsInMenu} + {#if restartStep !== undefined && canRestart(restart)} + {@render flowRestartButton(restart, restartStep, 'button')} + {/if} + {#if canEdit && showEditButton} + + {/if} + + {#snippet buttonReplacement()} + + {/if} + {/if} + {#if job} + job && shareReadLink(job.id) + }, + { + displayName: 'Copy public link', + icon: Globe, + disabled: !canSharePublicly, + tooltip: canSharePublicly + ? "Read-only link that anyone on the internet can open, without logging in. It shows a minimal version of this page: this run's inputs, result and logs, and for a flow its graph plus every step's inputs, result, logs and code. The link cannot be revoked." + : 'Sharing a run publicly is restricted in this workspace. Ask an admin to share it, or to grant you a bypass on the ruleset.', + action: () => job && sharePublicLink(job.id) + } + ]} + > + {#snippet buttonReplacement()} + + {/snippet} + + {/if} + {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} +
+ { + debugInfo() + } + } + ]} + class="h-auto" + > + {#snippet buttonReplacement()} + + {/snippet} + +
+ {/if} + {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} + + {/if} + {#if persistentScriptDefinition !== undefined} + + {/if} + {#if canRunNow} + + {/if} + {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} + {#if !forceCancel} + + {:else} + + {/if} + {/if} + {#if job?.schedule_path} + + {/if} + {#if restartStep !== undefined && canRestart(restart)} + {@render flowRestartButton(restart, restartStep, 'button')} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} + {#if !actingUser?.operator} + {#if editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})} + + {#if showEditButton} + + + {/if} + {/if} + {#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)} + + {/if} + {/if} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {/snippet} +
+ +
+ + {#if job} + { + persistentScriptDrawer?.open?.(persistentScriptDefinition) + }} + {concurrencyKey} + /> + {/if} +
+ {#if job?.['deleted']} +
+ + The content of this run was deleted (by an admin, no less) + +
+
+ {/if} + + + {#if job?.job_kind === 'flow' || job?.job_kind === 'flowpreview'} +
+ + {#snippet errorAction()} + + {#if failedTopLevelStep && failedTopLevelStep !== AGENT_STEP_ID} + at step {failedTopLevelStep} + {#if $enterpriseLicense && canRestart(failedRestart)} + · + {@render flowRestartButton(failedRestart, failedTopLevelStep, 'link')} + on this flow version, or on a new one after you fix it + {/if} + {/if} + {/snippet} + + {#if suspendStatus} + + {/if} +
+ {/if} + + +
+
Inputs
+
+ + {#if job && currentJobIsLongRunning && showExplicitProgressTip && !scriptProgress && 'running' in job} + + + + {/if} +
+
+ + {#if job} + + {/if} + + {#if isNotFlow(job?.job_kind)} + {#if ['python3', 'bun', 'deno'].includes(job?.language ?? '') && (job?.job_kind == 'script' || isScriptPreview(job?.job_kind))} + + {/if} +
+ {#if job?.workflow_as_code_status && job.job_kind !== 'aiagent'} +
+

Workflow Timeline

+
+ +
+
+ {/if} + {#if scriptProgress} + + {/if} + + + {#if job?.language === 'dbt' && job?.script_path} +
+

Models

+ +
+ {/if} + + + {#if job} +
+

Result

+
+ {#if job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined)} + + {:else} +
No output is available yet
+ {/if} +
+
+ {#if job.id && job.workspace_id} + + + {/if} + {/if} + + +
+ + + + + + {#if isScriptPreview(job?.job_kind)} + + {/if} + + + + {#if job} +
+ {#if viewTab == 'logs'} +
+ +
+ {:else if viewTab == 'assets'} +
+ +
+ {:else if viewTab == 'traces'} +
+ +
+ {:else if viewTab == 'code'} + {#if job && 'raw_code' in job && job.raw_code} +
+ +
+ {:else if job} + No code available + {:else} + + {/if} + {:else if viewTab == 'stats'} +
+ +
+ {:else} +
Select a tab to view content
+ {/if} +
+ {/if} +
+
+ {:else if !job?.['deleted']} +
+ +
+ {#if job?.id} + { + job = newJob + }} + onDone={({ job: newJob }) => { + job = newJob + }} + initialJob={job} + {workspaceId} + bind:selectedJobStep + bind:suspendStatus + bind:isOwner + bind:localModuleStates={graphModuleStates} + bind:expandedSubflows + /> + {:else} + + {/if} +
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/runs/RunRow.svelte b/frontend/src/lib/components/runs/RunRow.svelte index 959ee2fd06..249933f3b4 100644 --- a/frontend/src/lib/components/runs/RunRow.svelte +++ b/frontend/src/lib/components/runs/RunRow.svelte @@ -1,5 +1,4 @@ - goto('/schedules')} bind:this={scheduleEditor} /> + (hosted ? hosted.openLink('/schedules') : goto('/schedules'))} + bind:this={scheduleEditor} + /> @@ -166,12 +170,12 @@ {/if} {#if job && job.is_flow_step && job.parent_job}
Step of flow -
+ {truncateRev(job.parent_job, 10)} {:else if job && job.parent_job}
Parent - + {truncateRev(job.parent_job, 10)} {/if} @@ -322,7 +326,7 @@
{#snippet trigger()} -
+ +
{#if queue_count && ($queue_count ?? 0) > 0}
+ + {#snippet trigger()} -
+
- {:else} + {:else if suspended_count && ($suspended_count ?? 0) > 0} {@render suspendedContent()} {/if}
@@ -103,28 +102,28 @@ {/snippet} {#snippet suspendedContent()} - {#if suspended_count && ($suspended_count ?? 0) > 0} - - {#snippet tooltip()} - Jobs waiting for an event or approval before being resumed - {/snippet} -
0 - ? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center' - : ''}>{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}
-
- -
-
- {/if} + + {#snippet tooltip()} + Jobs waiting for an event or approval before being resumed + {/snippet} +
0 + ? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center' + : ''} + > + {suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'} +
+
+ +
+
{/snippet} diff --git a/frontend/src/lib/components/runs/RunsTable.svelte b/frontend/src/lib/components/runs/RunsTable.svelte index 479c785943..be997f1096 100644 --- a/frontend/src/lib/components/runs/RunsTable.svelte +++ b/frontend/src/lib/components/runs/RunsTable.svelte @@ -13,7 +13,6 @@ Wrench } from 'lucide-svelte' import Popover from '../Popover.svelte' - import { userStore, workspaceStore } from '$lib/stores' import './runs-grid.css' import { useKeyPressed } from '$lib/svelte5Utils.svelte' import { twMerge } from 'tailwind-merge' @@ -21,6 +20,9 @@ import DropdownMenu, { type Props as DropdownMenuProps } from '../DropdownMenu.svelte' import { clickOutside, isJobCancelable, isJobReRunnable, isJobResolvable } from '$lib/utils' import { goto } from '$lib/navigation' + import { useHostedPage } from '../hostedPage' + import { useOperatingUser, useOperatingWorkspace } from '../operatingWorkspace.svelte' + import { withWorkspaceParam } from '../sessions/sessionMode.svelte' import BarsStaggered from '../icons/BarsStaggered.svelte' interface Props { @@ -201,6 +203,16 @@ } const dispatch = createEventDispatcher() + const hosted = useHostedPage() + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + + // Under a host the link opens where it decides, in the workspace the run belongs to: the + // document's own router would leave the host for the navigation workspace. + function open(href: string) { + if (!hosted) goto(href) + else hosted.openLink(withWorkspaceParam(href, selectedWorkspace ?? $operatingWorkspace)) + } let scrollToIndex = $state(0) @@ -272,7 +284,7 @@ actions.push({ label: 'Show run details', icon: ExternalLinkIcon, - onClick: () => goto(`/run/${selectedIds[0]}`) + onClick: () => open(`/run/${selectedIds[0]}`) }) const job = flatJobs?.find( (jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0] @@ -282,14 +294,20 @@ actions.push({ label: 'Go to script page', icon: Code2Icon, - onClick: () => goto(`/scripts/get/${job.job.script_hash}`) + onClick: () => + open( + // A host addresses an item by its path, and pins a version in the query. + hosted + ? `/scripts/get/${job.job.script_path}?version=${job.job.script_hash}` + : `/scripts/get/${job.job.script_hash}` + ) }) } if (job.job.job_kind === 'flow') { actions.push({ label: 'Go to flow page', icon: BarsStaggered, - onClick: () => goto(`/flows/get/${job.job.script_path}`) + onClick: () => open(`/flows/get/${job.job.script_path}`) }) } } @@ -314,7 +332,7 @@ onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null) }) // Operators are rejected by the endpoint, so offering the action would only 403. - const canResolve = !$userStore?.operator + const canResolve = !operatingUser.current?.operator const resolvable = canResolve ? selectedIdsPossibleActions.resolvableJobIds.length : 0 const unresolvable = canResolve ? selectedIdsPossibleActions.unresolvableJobIds.length : 0 if (resolvable) @@ -401,7 +419,7 @@ ? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd) : ''}{externalJobs.length} jobs obscured
- {:else if $workspaceStore !== 'admins' && omittedObscuredJobs} + {:else if $operatingWorkspace !== 'admins' && omittedObscuredJobs}
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''} diff --git a/frontend/src/lib/components/runs/ScriptRetryChain.svelte b/frontend/src/lib/components/runs/ScriptRetryChain.svelte index 2b2fc2449b..f7334467da 100644 --- a/frontend/src/lib/components/runs/ScriptRetryChain.svelte +++ b/frontend/src/lib/components/runs/ScriptRetryChain.svelte @@ -6,7 +6,10 @@ import { resource } from 'runed' import { canSkipRetryChainQuery } from './scriptRetryChain' - let { job }: { job: Job } = $props() + let { + job, + onNavigate = goto + }: { job: Job; onNavigate?: (url: string) => void | Promise } = $props() type AttemptColor = 'green' | 'red' | 'blue' | 'gray' type Attempt = { id: string; color: AttemptColor; current: boolean } @@ -141,7 +144,7 @@ size="xs" color="gray" variant="border" - onclick={() => goto(`/run/${self?.handledJob}?workspace=${ws}`)} + onclick={() => onNavigate(`/run/${self?.handledJob}?workspace=${ws}`)} > Handled run @@ -163,7 +166,7 @@ size="xs" color={attempt.color} variant={attempt.current ? 'contained' : 'border'} - onclick={() => goto(`/run/${attempt.id}?workspace=${ws}`)} + onclick={() => onNavigate(`/run/${attempt.id}?workspace=${ws}`)} > {i === 0 ? 'Original' : `Attempt ${i}`} @@ -180,7 +183,7 @@ size="xs" color={handler.color} variant="border" - onclick={() => goto(`/run/${handler.id}?workspace=${ws}`)} + onclick={() => onNavigate(`/run/${handler.id}?workspace=${ws}`)} > {HANDLER_LABEL[handler.kind]} diff --git a/frontend/src/lib/components/runs/TimeframeSelect.svelte b/frontend/src/lib/components/runs/TimeframeSelect.svelte index f8d2b0df71..7daa75f5fa 100644 --- a/frontend/src/lib/components/runs/TimeframeSelect.svelte +++ b/frontend/src/lib/components/runs/TimeframeSelect.svelte @@ -3,7 +3,8 @@ type Timeframe, buildManualTimeframe, serviceLogsTimeframes, - runsTimeframes + runsTimeframes, + auditTimeframes } from './timeframes' import { type Timeframe, buildManualTimeframe } from './timeframes' @@ -77,7 +78,7 @@ {#snippet loading()} @@ -130,6 +178,15 @@ {:then Module} {/await} + {:else if runsOf !== undefined} + + {#key moved} + {#await import('$lib/components/RunsPage.svelte')} + {@render loading()} + {:then Module} + + {/await} + {/key} {/if} {/key}
diff --git a/frontend/src/lib/components/sessions/PipelineEditorView.svelte b/frontend/src/lib/components/sessions/PipelineEditorView.svelte index b1e461eb32..d6e9f28935 100644 --- a/frontend/src/lib/components/sessions/PipelineEditorView.svelte +++ b/frontend/src/lib/components/sessions/PipelineEditorView.svelte @@ -1,9 +1,10 @@
@@ -299,6 +438,31 @@ f/{path} · data pipeline + {#if workspaceId} + + {/if} +
+ {#if deployErrors.size > 0} + + {/if} + {#if pendingCount > 0} + + {/if}
+ {#key itemPath} + + {/key} {/await} {:else} {#await import('./RawAppEditorView.svelte')} @@ -365,6 +382,7 @@ path={itemPath} {workspaceId} {onNavigate} + onSeeDetails={seeDetails} {isActiveSession} {active} {newRawApp} @@ -383,6 +401,8 @@ kind={itemKind} path={itemPath} version={pinnedVersion} + locationHash={viewerUrl?.hash} + dbtRetryFrom={viewerUrl?.searchParams.get('dbt_retry_from') ?? undefined} {workspaceId} tabId={tab.id} container={overlayHostEl} @@ -390,6 +410,11 @@ /> {/await}
+ {:else} + +
+ The pipeline editor saves drafts, which your role cannot do in this workspace. +
{/if}
{:else if slot.kind === 'pageitem' && mounted && runtime} @@ -429,6 +454,29 @@ {/await} {/if}
+{:else if slot.kind === 'run' && mounted && runtime} +
+ {#if overlayHostEl} + {#await import('./RunViewerView.svelte')} + {@render editorLoading()} + {:then Module} + + + {/await} + {/if} +
{:else if slot.kind === 'artifact' && mounted}
import { tick, untrack } from 'svelte' - import { base } from '$lib/base' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' import { UserDraft } from '$lib/userDraft.svelte' @@ -16,10 +15,15 @@ import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' import { removeSession, type SessionRuntime } from './sessionRuntime.svelte' - import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' + import { + BUILDER_FRAMEWORK_TEMPLATES, + FRAMEWORK_TEMPLATES, + STARTER_RUNNABLES + } from '$lib/components/raw_apps/templates' import SessionEditorTarget from './SessionEditorTarget.svelte' import { runResetToDeployed } from '$lib/userDraftToast' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' + import { operatorBuilderAppsIn } from '$lib/operatorWriteRights' import type { RawAppRuntimeLogRequester, RawAppRunsProvider, @@ -33,6 +37,7 @@ path, workspaceId, onNavigate, + onSeeDetails, isActiveSession = true, active = true, newRawApp = false @@ -41,6 +46,9 @@ path: string workspaceId: string onNavigate?: (item: WorkspaceItem) => void + /** `Exit & see details` — flips this tab to the item's deployed view rather than + * navigating out of the session. */ + onSeeDetails?: (e: { path: string }) => void /** Forwarded to SessionEditorTarget — only the visible session claims the * workspace's single live-editor slot. */ isActiveSession?: boolean @@ -59,7 +67,10 @@ const files = cell.store.val?.files if (!newRawApp || (cell.saved.val && !cell.saved.val.no_deployed) || !files) return false const count = Object.keys(files).length - return Object.values(FRAMEWORK_TEMPLATES).some((template: Record) => { + return [ + ...Object.values(FRAMEWORK_TEMPLATES), + ...Object.values(BUILDER_FRAMEWORK_TEMPLATES) + ].some((template: Record) => { const keys = Object.keys(template) return count === keys.length && keys.every((k) => files[k] === template[k]) }) @@ -87,7 +98,10 @@ const setUp = { ...val, files: { ...result.files }, - runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) }, + runnables: { + ...result.runnables, + ...(operatorBuilderAppsIn(workspaceId) ? {} : structuredClone(STARTER_RUNNABLES)) + }, data: result.data, policy: result.policy } @@ -106,7 +120,7 @@ if (mode === 'code') { // The session was opened for this app alone; with nothing said in it, it goes. const unused = runtime.manager.displayMessages.length === 0 - await goto(withWorkspaceParam(`${base}/apps_raw/edit/${path}`, workspaceId)) + await goto(withWorkspaceParam(`/apps_raw/edit/${path}`, workspaceId)) if (unused) removeSession(runtime.sessionId) return } @@ -357,6 +371,7 @@ newApp={!cell.saved.val || cell.saved.val.no_deployed === true} {diffDrawer} {onNavigate} + onDetails={onSeeDetails} condensedHeader={true} onResetToDeployed={reloadDeployed} onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', path, e.path)} diff --git a/frontend/src/lib/components/sessions/RunViewerView.svelte b/frontend/src/lib/components/sessions/RunViewerView.svelte new file mode 100644 index 0000000000..0266327f89 --- /dev/null +++ b/frontend/src/lib/components/sessions/RunViewerView.svelte @@ -0,0 +1,106 @@ + + +
+ + {#key `${workspaceId}/${jobId}/${reloadNonce}`} + + {/key} +
diff --git a/frontend/src/lib/components/sessions/SessionChangesBar.svelte b/frontend/src/lib/components/sessions/SessionChangesBar.svelte index 140300ea30..3929b78ca9 100644 --- a/frontend/src/lib/components/sessions/SessionChangesBar.svelte +++ b/frontend/src/lib/components/sessions/SessionChangesBar.svelte @@ -22,7 +22,7 @@ import { badgeCounts, badgeOf, buildDeployItems, type DeployItem } from './sessionDeployModel' import { useExistingMaskKeys } from './sessionDeployModel.svelte' import { previewTargetForDeployKind } from './sessionPreviewTabs.svelte' - import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths' + import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths' import type { PreviewTarget } from './previewRouter' import JobsSegment from '$lib/components/copilot/chat/JobsSegment.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' @@ -378,6 +378,11 @@ onDataChanged={refreshDock} onItemDeployed={(item) => void runtime?.manager.renameModifiedItem(item.draftKind, item.path, item.displayPath)} - onItemDiscarded={(item) => void runtime?.manager.removeModifiedItem(item.draftKind, item.path)} + onItemDiscarded={(item) => { + void runtime?.manager.removeModifiedItem(item.draftKind, item.path) + const folder = pipelineFolderFromBundlePath(item.path) + if (item.draftKind === PIPELINE_DRAFT_KIND && folder) void runtime?.forgetPipelineDrafts(folder) + }} + deployPipeline={runtime?.deployPipeline} /> {/if} diff --git a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte index fe5ab2d199..d921e435b0 100644 --- a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte @@ -2,10 +2,10 @@ import WorkspaceDiffDrawer from './WorkspaceDiffDrawer.svelte' import { ArrowRight, GitFork, Pencil } from 'lucide-svelte' import { userWorkspaces } from '$lib/stores' - import Badge from '$lib/components/common/badge/Badge.svelte' - import { devBadgeText } from '$lib/utils/devWorkspaceLabel' + import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte' import { useSessionDeployModel } from './sessionDeployModel.svelte' import type { DeployItem } from './sessionDeployModel' + import type { DeployResult } from '$lib/utils_workspace_deploy' // Session "Edits" drawer. Builds the deploy model over the session // workspace's drafts and hands it to WorkspaceDiffDrawer, which renders the @@ -19,7 +19,8 @@ keys, onDataChanged, onItemDeployed, - onItemDiscarded + onItemDiscarded, + deployPipeline }: { workspaceId: string parentWorkspaceId?: string @@ -36,6 +37,8 @@ * drops it. */ onItemDeployed?: (item: DeployItem) => void onItemDiscarded?: (item: DeployItem) => void + /** Forwarded to the deploy model: deploys a pipeline folder's draft bundle. */ + deployPipeline?: (folder: string) => Promise } = $props() const isFork = $derived(!!parentWorkspaceId) @@ -51,7 +54,8 @@ mask: keys, onDataChanged, onItemDeployed, - onItemDiscarded + onItemDiscarded, + deployPipeline })) // Editor URL for a row (every item lives in the session workspace). @@ -95,7 +99,7 @@ {ws?.name ?? workspaceId} {#if ws?.is_dev_workspace} - {devBadgeText(ws.dev_workspace_label)} + {/if} diff --git a/frontend/src/lib/components/sessions/SessionModeSwitch.svelte b/frontend/src/lib/components/sessions/SessionModeSwitch.svelte index 9a7fb20bd6..2d3d0fbef2 100644 --- a/frontend/src/lib/components/sessions/SessionModeSwitch.svelte +++ b/frontend/src/lib/components/sessions/SessionModeSwitch.svelte @@ -6,7 +6,6 @@ import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' import { page } from '$app/state' - import { base } from '$lib/base' // Which side of the switch is active. `nav` = workspace navigation (the classic // app), `session` = the sessions sidebar + chat + preview. @@ -47,7 +46,7 @@ if (mode !== 'nav') return // `goto` has no same-URL short-circuit, so navigating from home would push a // duplicate history entry and make the next Back press look broken. - if (page.url.pathname === `${base}/`) return + if (page.url.pathname === '/') return void goto('/') } @@ -69,7 +68,7 @@ iconOnly={isCollapsed} tooltip={isCollapsed ? 'Workspace' : undefined} size="sm" - class="w-full justify-center" + class="w-full justify-center whitespace-nowrap" onActivate={onNavActivate} /> {/snippet} diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index 9707b537b4..7e8283e406 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -2,7 +2,7 @@ import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import Modal from '$lib/components/common/modal/Modal.svelte' import Checkbox from '$lib/components/common/checkbox/Checkbox.svelte' - import { Badge, Button } from '$lib/components/common' + import { Button } from '$lib/components/common' import { Archive, ArchiveRestore, @@ -66,8 +66,7 @@ import { sendUserToast } from '$lib/toast' import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte' import { page } from '$app/state' - import { base } from '$app/paths' - import { devBadgeText } from '$lib/utils/devWorkspaceLabel' + import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte' import { dateBucket, GROUP_BY_OPTIONS, @@ -119,7 +118,7 @@ // Only highlight the active session while the sessions page is open — elsewhere // `currentSessionId` lingers but no row should appear selected. - const onSessionsPage = $derived(page.url.pathname.startsWith(`${base}/sessions`)) + const onSessionsPage = $derived(page.url.pathname.startsWith('/sessions')) const sessionActive = $derived(onSessionsPage) interface Props { @@ -1220,9 +1219,12 @@ {group.label} {#if groupWs?.is_dev_workspace} - - {devBadgeText(groupWs.dev_workspace_label)} - + {/if} {#if groupWsId} diff --git a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte index 48284b6de2..ce481cbcb3 100644 --- a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte @@ -32,8 +32,9 @@ import DropdownV2 from '$lib/components/DropdownV2.svelte' import InputError from '$lib/components/InputError.svelte' import PrefixedInput from '$lib/components/PrefixedInput.svelte' - import { Badge, Button, CopyButton } from '$lib/components/common' - import { devBadgeText, devLabelNoun } from '$lib/utils/devWorkspaceLabel' + import { Button, CopyButton } from '$lib/components/common' + import { devLabelNoun } from '$lib/utils/devWorkspaceLabel' + import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte' import Select from '../select/Select.svelte' import { Building, Check, GitFork, Plus, Settings } from 'lucide-svelte' @@ -77,6 +78,7 @@ // Narrow the list to this workspace and its direct forks, and fork only from it — // for moving something into a fork of where it is, rather than anywhere in the family. forksOf, + open: dropdownOpen = $bindable(), trigger }: { selectedId?: string @@ -92,6 +94,7 @@ settingsLabel?: string forkableWorkspaces?: UserWorkspace[] forksOf?: string + open?: boolean trigger: Snippet<[{ open: boolean }]> } = $props() @@ -229,7 +232,6 @@ forkAffordanceOpen && ceWorkspaceCapReached && !onRequestCreateFork ) - let dropdownOpen = $state(false) let creatingFork = $state(false) // Bare fork id, without the wm-fork- prefix. Forks have no separate display // name — the id is the name (it also becomes the git branch). @@ -365,7 +367,7 @@ let lastDropdownOpen = $state(false) $effect(() => { const wasOpen = lastDropdownOpen - lastDropdownOpen = dropdownOpen + lastDropdownOpen = !!dropdownOpen if (dropdownOpen && !wasOpen && pendingFork && !creatingFork && showCreateFork) { // Preserve the base the fork was staged from; without this the re-entry falls back to the // default base and silently re-parents a fork that was staged off another workspace. @@ -401,7 +403,7 @@ /> !!dropdownOpen, (v) => (dropdownOpen = v)} customMenu placement="bottom-start" fixedHeight={false} @@ -409,7 +411,7 @@ class={triggerClass} > {#snippet buttonReplacement()} - {@render trigger({ open: dropdownOpen })} + {@render trigger({ open: !!dropdownOpen })} {/snippet} {#snippet menu()} {@const rowBase = @@ -473,12 +475,12 @@ >{f.name} {#if f.is_dev_workspace} - {devBadgeText(f.dev_workspace_label)} + {/if} {#if isSelected(f.id)} diff --git a/frontend/src/lib/components/sessions/modifiedItemsMask.test.ts b/frontend/src/lib/components/sessions/modifiedItemsMask.test.ts index 6cad6121cd..e5f4ff9873 100644 --- a/frontend/src/lib/components/sessions/modifiedItemsMask.test.ts +++ b/frontend/src/lib/components/sessions/modifiedItemsMask.test.ts @@ -62,6 +62,12 @@ describe('diffInMask', () => { expect(diffInMask(diff('script', 'u/me/s'), mask)).toBe(true) }) + it('selects only the scripts a masked pipeline deployed, not the rest of its folder', () => { + const mask = new Set(['data_pipeline:f/crm/data_pipeline', 'script:f/crm/clean']) + expect(diffInMask(diff('script', 'f/crm/clean'), mask)).toBe(true) + expect(diffInMask(diff('script', 'f/crm/unrelated'), mask)).toBe(false) + }) + it('matches a legacy app diff under both its identity and bridged mask keys', () => { expect(diffInMask(diff('app', 'u/me/legacy'), new Set(['app:u/me/legacy']))).toBe(true) expect(diffInMask(diff('app', 'u/me/legacy'), new Set(['raw_app:u/me/legacy']))).toBe(true) diff --git a/frontend/src/lib/components/sessions/modifiedItemsMask.ts b/frontend/src/lib/components/sessions/modifiedItemsMask.ts index 7d0d3abbae..d597c46ec9 100644 --- a/frontend/src/lib/components/sessions/modifiedItemsMask.ts +++ b/frontend/src/lib/components/sessions/modifiedItemsMask.ts @@ -1,4 +1,5 @@ import type { UserDraftItemKind, WorkspaceItemDiff } from '$lib/gen' +import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' // The "modified items mask" tracks which workspace items an AI chat touched via // tool calls. Each key is `${UserDraftItemKind}:${storagePath}`. UserDraftItemKind @@ -45,6 +46,21 @@ export function forkDiffKindToUserDraftKind(kind: ForkDiffKind): UserDraftItemKi return FORK_DIFF_KIND_TO_USER_DRAFT_KIND[kind] } +// A pipeline the chat edited is masked as its folder's draft bundle, and each +// script that bundle deploys is masked too, so a fork review selects exactly +// those. The session's changes list shows deployed scripts of a masked +// pipeline's folder as that pipeline's one row — any of them, since a script's +// mask entry does not say whether a pipeline deploy or the chat put it there. +export function foldedIntoPipelineRow( + mask: ReadonlySet, + kind: UserDraftItemKind, + path: string +): boolean { + if (kind !== 'script') return false + const folder = path.match(/^f\/([^/]+)\//)?.[1] + return !!folder && mask.has(maskKey(PIPELINE_DRAFT_KIND, pipelineBundlePath(folder))) +} + // True when a fork-comparison diff names an item present in the chat-modified mask. export function diffInMask(diff: WorkspaceItemDiff, mask: Set): boolean { const kind = forkDiffKindToUserDraftKind(diff.kind) diff --git a/frontend/src/lib/components/sessions/openInSessionContext.ts b/frontend/src/lib/components/sessions/openInSessionContext.ts index ddecb01b4f..af1a5113ad 100644 --- a/frontend/src/lib/components/sessions/openInSessionContext.ts +++ b/frontend/src/lib/components/sessions/openInSessionContext.ts @@ -1,6 +1,6 @@ import { getContext, onDestroy, setContext } from 'svelte' import type { OpenInSessionSource } from './OpenInSessionButton.svelte' -import type { PreviewItemRoute } from './previewPaths' +import type { SessionTarget } from './sessionState.svelte' // The "Open in AI session" hand-off, published by the component that owns the // item being edited (FlowBuilder, RawAppEditor) for AI entry points too deep in @@ -38,20 +38,17 @@ export function getOpenInSessionHandoff(): OpenInSessionHandoff | undefined { return getContext(KEY) } -/** The mounted editor's hand-off for the item `route` names, or undefined when - * no editor on screen publishes one for it (a legacy app, a detail page). - * Matched on the item rather than taken as "the latest registered": a script - * editor mounted in a flow's drawer registers too, and the rail wants the - * page's own item. */ +/** The mounted editor's hand-off for `target`, or undefined when no editor on + * screen publishes one for it (a detail page). Matched on the item rather than + * taken as "the latest registered": a script editor mounted in a flow's drawer + * registers too, and the rail wants the page's own item. */ export function findMountedOpenInSessionSource( - route: PreviewItemRoute + target: SessionTarget ): OpenInSessionSource | undefined { - const kind = route.kind === 'app' ? (route.raw_app ? 'raw_app' : undefined) : route.kind - if (!kind) return undefined for (const handoff of mounted) { const source = handoff.source() - const target = source?.target - if (target && target.kind === kind && target.path === route.itemPath) return source + const t = source?.target + if (t && t.kind === target.kind && t.path === target.path) return source } return undefined } diff --git a/frontend/src/lib/components/sessions/pageDrawerSession.ts b/frontend/src/lib/components/sessions/pageDrawerSession.ts index 0a3e7435ca..35d0a4560b 100644 --- a/frontend/src/lib/components/sessions/pageDrawerSession.ts +++ b/frontend/src/lib/components/sessions/pageDrawerSession.ts @@ -12,8 +12,7 @@ import type { UserDraftItemKind } from '$lib/gen' // every trigger's save utils eager. import { drawerHashFor, - pageHref, - stripBase, + barePath, TRIGGER_PAGES, RESOURCES_PATH, SCHEDULES_PATH, @@ -77,7 +76,7 @@ async function flushOrRefuse(query: Parameters[0 export function setPageDrawerAnchor(pagePath: string, itemPath: string | undefined): void { if (!itemPath) return const { pathname, search, hash } = window.location - if (stripBase(pathname) !== pagePath) return + if (barePath(pathname) !== pagePath) return const anchor = `#${drawerHashFor(pagePath, itemPath)}` if (hash === anchor) return history.replaceState(history.state, '', `${pathname}${search}${anchor}`) @@ -94,7 +93,7 @@ export async function clearPageDrawerAnchor(pagePath: string): Promise { // filters with shallow routing, which never reaches `page.url`, so rebuilding the query // from it would drop the filters the user typed along with the anchor. const { pathname, search, hash } = window.location - if (stripBase(pathname) !== pagePath || !hash) return + if (barePath(pathname) !== pagePath || !hash) return await goto(`${pathname}${search}`, { replaceState: true, noScroll: true }) } @@ -112,14 +111,14 @@ export function pageDrawerSessionSource( itemPath: string | undefined, workspaceId: string | undefined ): OpenInSessionSource | undefined { - if (!itemPath || stripBase(page.url.pathname) !== pagePath) return undefined + if (!itemPath || barePath(page.url.pathname) !== pagePath) return undefined const anchor = drawerHashFor(pagePath, itemPath) const itemKind = DRAWER_DRAFT_KIND[pagePath] return { // The list behind the drawer is part of what the user is looking at, and its filters // are written with shallow routing — so the query has to come from the document at // click time, as the thunk exists for. - page: () => `${pageHref(pagePath)}${window.location.search}#${anchor}`, + page: () => `${pagePath}${window.location.search}#${anchor}`, workspaceId, // Autosave is debounced, and the preview reads the draft back through the server // from a document of its own — routing before the POST lands opens the drawer on a diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 10ad5b00ef..6c462ff322 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -1,5 +1,5 @@ -import { base } from '$lib/base' import type { WorkspaceItemKind } from '$lib/components/workspacePicker' +import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' // The paths a preview location can point at, and the base handling around them. Kept apart // from `previewRouter`, which reads a location's *view* from each page's filter schema and @@ -62,7 +62,7 @@ export const drawerHashFor = (pagePath: string, itemPath: string) => /** The full page a page item is edited on: its list page, with the row's drawer open. */ export function pageItemPageHref(ref: PageItemRef): string { const listPath = pageItemListPath(ref) - return `${pageHref(listPath)}#${drawerHashFor(listPath, ref.path)}` + return `${listPath}#${drawerHashFor(listPath, ref.path)}` } /** The list page a page item is edited from. */ @@ -79,14 +79,25 @@ export function pageItemListPath(ref: PageItemRef): string { } } -/** Whether a (base-stripped) path is the list page of a page item kind. */ +/** Whether a path is the list page of a page item kind. */ export function isPageItemListPath(path: string): boolean { return pageItemForListPath(path, '') !== undefined } +/** Whether a path is the runs list: every run, or `/runs/` for one item's. */ +export function isRunsListPath(path: string): boolean { + const clean = barePath(path) + return clean === RUNS_PATH || clean.startsWith(`${RUNS_PATH}/`) +} + +/** Whether a path is a list page a session preview mounts in process rather than frames. */ +export function isPreviewListPath(path: string): boolean { + return isPageItemListPath(path) || isRunsListPath(path) +} + /** The page item a list page's row names, or undefined for a page that lists none. */ export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined { - const clean = stripBase(pagePath) + const clean = barePath(pagePath) if (clean === VARIABLES_PATH) return { kind: 'variable', path } if (clean === RESOURCES_PATH) return { kind: 'resource', path } if (clean === SCHEDULES_PATH) return { kind: 'schedule', path } @@ -134,21 +145,24 @@ export function pageItemKindLabel(ref: PageItemRef): string { } } -/** Label a trigger list page from its (base-stripped) pathname, or undefined. */ +/** Label a trigger list page from its pathname, or undefined. */ export function triggerLabelForPath(path: string): string | undefined { - const clean = stripBase(path) + const clean = barePath(path) return Object.values(TRIGGER_PAGES).find((t) => t.path === clean)?.label } export const pageKey = (path: string) => `page:${path}` -export const pageHref = (path: string) => `${base}${path}` -/** Strip the deployment base prefix (and any query/hash) from a preview path - * so it can be matched against `PREVIEW_PAGES` / parsed as an item route. */ -export function stripBase(path: string): string { - let p = path.split('?')[0].split('#')[0] - if (base && p.startsWith(base)) p = p.slice(base.length) - return p || '/' +/** Strip the query and hash from a preview path so it can be matched against + * `PREVIEW_PAGES` / parsed as an item route. */ +export function barePath(path: string): string { + // The router serves `/runs/` as `/runs`, and links are written both ways. + return ( + path + .split('?')[0] + .split('#')[0] + .replace(/(.)\/+$/, '$1') || '/' + ) } /** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the @@ -166,7 +180,7 @@ export type PreviewItemRoute = { // non-item page (home, runs, …). Shared by the breadcrumb (drill segments) and // `previewRouter`'s tab resolver so both agree on what counts as an item route. export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null { - const p = stripBase(fullPath) + const p = barePath(fullPath) const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(edit|get)\/(.+)$/) if (!m) return null const itemPath = decodeURIComponent(m[3]) @@ -186,3 +200,11 @@ export function parseHistoricalScriptEdit(url: string): { path: string; hash: st const hash = new URL(url, 'http://x').searchParams.get('hash') return hash ? { path: route.itemPath, hash } : null } + +// A `/pipeline/` route is the data-pipeline graph editor for that folder +// (the folder is a single path segment, not a workspace item path). The bare +// `/pipeline` list page is not an editor. Returns the folder name, or null. +export function parsePipelineRoute(fullPath: string): string | null { + const m = barePath(fullPath).match(/^\/pipeline\/([^/?#]+)/) + return m ? normalizePipelineFolder(decodeURIComponent(m[1])) : null +} diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index d9259c900d..6d22309ee1 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -5,13 +5,13 @@ import { pageItemUrl, parsePageItemRoute, parsePreviewItemRoute, - stripBase, + barePath, TRIGGER_PAGES, type PageItemRef, type TriggerKind } from './previewPaths' -// Which list pages a completed chat tool can change, as base-stripped paths +// Which list pages a completed chat tool can change, as paths // (e.g. `/schedules`). This allowlist is the single source of truth for "does // this tool change a list page a preview tab might show". A new mutating tool // that surfaces on one of these pages must be added here or that tab silently @@ -128,7 +128,7 @@ export function tabsToReload( if (pages.size === 0) return [] return tabs.filter((t) => { const pageItem = parsePageItemRoute(t.url) - if (!pageItem) return pages.has(stripBase(whereIs(t))) + if (!pageItem) return pages.has(barePath(whereIs(t))) const listPath = pageItemListPath(pageItem) if (!pages.has(listPath)) return false const named = [...items].some((u) => pageItemListPath(parsePageItemRoute(u)!) === listPath) diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index 038691da55..18d287769e 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -313,7 +313,30 @@ describe('itemDisplayName', () => { describe('resolvePreviewTab', () => { it('routes a static page to the iframe fallback', () => { - expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' }) + expect(resolvePreviewTab('/audit_logs')).toEqual({ kind: 'iframe' }) + }) + + it('mounts a run in process, in the workspace its URL names', () => { + expect(resolvePreviewTab('/run/01a0?workspace=fork')).toEqual({ + kind: 'run', + jobId: '01a0', + workspace: 'fork' + }) + expect(resolvePreviewTab('/run/01a0')).toEqual({ kind: 'run', jobId: '01a0' }) + expect(resolvePreviewTab('/run/01a0?workspace=fork&view_token=t')).toEqual({ kind: 'iframe' }) + }) + + it('mounts the runs list in process, for every run or for one item', () => { + expect(resolvePreviewTab('/runs?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' }) + expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'pagelist', path: '/runs/f/a/b' }) + expect(resolvePreviewTab('/runsheet')).toEqual({ kind: 'iframe' }) + }) + + it('tells two runs lists apart by their filters, trailing slash or not', () => { + expect(resolvePreviewTab('/runs/?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' }) + expect(showsView('/runs/?schedule_path=a', '/runs/?schedule_path=b')).toBe(false) + expect(showsView('/runs?schedule_path=a', '/runs/?schedule_path=a')).toBe(true) + expect(showsView('/runs/f/a/b?status=failure', '/runs/f/a/b')).toBe(false) }) it('mounts the list page of a page item kind in process, whatever its filters', () => { diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 6614adfe80..4d9a678859 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -3,20 +3,21 @@ import { AUDIT_LOGS_PATH, FOLDERS_PATH, GROUPS_PATH, - isPageItemListPath, + isPreviewListPath, + isRunsListPath, pageItemForListPath, pageItemListPath, pageItemPageHref, pageItemUrl, pageKey, - pageHref, parsePageItemRoute, + parsePipelineRoute, parseHistoricalScriptEdit, parsePreviewItemRoute, RESOURCES_PATH, RUNS_PATH, SCHEDULES_PATH, - stripBase, + barePath, VARIABLES_PATH, WORKSPACE_SETTINGS_PATH, triggerLabelForPath, @@ -26,17 +27,19 @@ import { type PreviewItemRoute, type TriggerKind } from './previewPaths' +import type { UserExt, UserWorkspace } from '$lib/stores' +import { sidebarPageAllowed, type OperatorPageKey } from '../sidebar/operatorRoutes' // Re-exported so the preview code that already reads locations through this module keeps // one import, while a caller needing only a path can reach for the leaf instead. export { pageItemListPath, pageItemUrl, pageKey, - pageHref, parseHistoricalScriptEdit, parsePageItemRoute, + parsePipelineRoute, parsePreviewItemRoute, - stripBase, + barePath, TRIGGER_PAGES, type PageItemRef, type PreviewItemMode, @@ -63,7 +66,6 @@ import { buildVariablesFilterSchema } from '$lib/components/variables/variablesF import { buildResourcesFilterSchema } from '$lib/components/resources/resourcesFilter' import { buildAssetsFilterSchema } from '$lib/components/assets/assetsFilter' import { COMPARE_ITEMS_PARAM } from './modifiedItemsMask' -import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionTargetKind } from './sessionRuntime.svelte' @@ -102,22 +104,40 @@ export function previewModeFor(item: Pick): PreviewI return item.draftOnly ? 'edit' : 'view' } -export type PreviewPage = { label: string; path: string; icon: DrillIcon } +export type PreviewPage = { + label: string + path: string + icon: DrillIcon + /** The `operator_settings` key that admits an operator, as the sidebar reads it. */ + operatorKey?: OperatorPageKey + adminOnly?: boolean +} + +/** Whether `page` loads for `user` rather than showing its "not available" state. An unknown + * user is let through to every page but the admin-only ones. */ +export function previewPageAllowed( + page: PreviewPage, + user: Pick | undefined, + workspace: UserWorkspace | undefined +): boolean { + if (page.adminOnly) return !!user && (user.is_admin || user.is_super_admin) + return !page.operatorKey || sidebarPageAllowed(user?.operator, workspace, page.operatorKey) +} // Core workspace-level destinations the preview can route to. Intentionally // curated — the main pages, not every trigger sub-page (those live behind // EE/feature gating in SidebarContent and aren't worth duplicating here). export const PREVIEW_PAGES: PreviewPage[] = [ { label: 'Home', path: '/', icon: Home }, - { label: 'Runs', path: RUNS_PATH, icon: Play }, - { label: 'Variables', path: VARIABLES_PATH, icon: DollarSign }, - { label: 'Resources', path: RESOURCES_PATH, icon: Boxes }, - { label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar }, - { label: 'Assets', path: ASSETS_PATH, icon: Database }, - { label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen }, - { label: 'Groups', path: GROUPS_PATH, icon: Users }, - { label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings }, - { label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText } + { label: 'Runs', path: RUNS_PATH, icon: Play, operatorKey: 'runs' }, + { label: 'Variables', path: VARIABLES_PATH, icon: DollarSign, operatorKey: 'variables' }, + { label: 'Resources', path: RESOURCES_PATH, icon: Boxes, operatorKey: 'resources' }, + { label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar, operatorKey: 'schedules' }, + { label: 'Assets', path: ASSETS_PATH, icon: Database, operatorKey: 'assets' }, + { label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen, operatorKey: 'folders' }, + { label: 'Groups', path: GROUPS_PATH, icon: Users, operatorKey: 'groups' }, + { label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings, adminOnly: true }, + { label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, operatorKey: 'audit_logs' } ] // The Compare & Deploy review page. Kept out of PREVIEW_PAGES (it's not a picker @@ -142,7 +162,7 @@ const DRAWER_ANCHOR_PAGES = [SCHEDULES_PATH, VARIABLES_PATH, RESOURCES_PATH] as export function drawerAnchorFor(location: string): string | undefined { const hashAt = location.indexOf('#') if (hashAt < 0) return undefined - const route = stripBase(location) + const route = barePath(location) const known = (DRAWER_ANCHOR_PAGES as readonly string[]).includes(route) || Object.values(TRIGGER_PAGES).some((p) => p.path === route) @@ -228,9 +248,14 @@ function pageRequestParamTable(): Record { }) } +// The page whose filters a path takes: one item's runs filter as every run does. +function filterPageOf(path: string): string { + return isRunsListPath(path) ? RUNS_PATH : path +} + /** The query params a request can set on `path` — empty for a page that takes none. */ export function pageRequestParams(path: string): readonly string[] { - return pageRequestParamTable()[stripBase(path)] ?? [] + return pageRequestParamTable()[filterPageOf(barePath(path))] ?? [] } /** What a preview location means, read against the page it points at. */ @@ -262,12 +287,12 @@ export function describeLocation(loc: string): PreviewLocation { return { identity: `/scripts/edit/${historical.path}@${historical.hash}`, view: '', anchor: '' } } const canonical = canonicalizeObservedLoc(loc) - const path = stripBase(canonical) + const path = barePath(canonical) const bare = canonical.split('#')[0] const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : '' return { identity: path, - view: requestedParams(query, pageRequestParamTable()[path]), + view: requestedParams(query, pageRequestParamTable()[filterPageOf(path)]), anchor: drawerAnchorFor(canonical) ?? '' } } @@ -300,7 +325,7 @@ export function showsView(observed: string, commanded: string): boolean { const y = describeLocation(commanded) if (x.identity !== y.identity || x.anchor !== y.anchor) return false if (x.view === y.view) return true - const seeded = PAGE_SEEDED_PARAMS[x.identity] + const seeded = PAGE_SEEDED_PARAMS[filterPageOf(x.identity)] if (!seeded?.length) return false // Both views come out of `requestedParams` already sorted and encoded, so they compare // as strings once the seeded params are dropped. One direction only: the page may add @@ -414,10 +439,10 @@ export function previewLocationContext(loc: string): { } } -// Match a base-stripped preview pathname to a known page, for breadcrumb +// Match a preview pathname to a known page, for breadcrumb // labelling + picker highlight. Exact match; '/' only matches home. export function matchPreviewPage(path: string): PreviewPage | undefined { - const clean = stripBase(path) + const clean = barePath(path) return PREVIEW_PAGES.find((p) => p.path === clean) } @@ -427,7 +452,7 @@ export function matchPreviewPage(path: string): PreviewPage | undefined { * matched — they take the generic path in `SessionPreviewTabs.open`, which * dedupes on the location ignoring the hash and re-points the tab it finds. */ export function matchReusablePage(href: string): PreviewPage | undefined { - if (stripBase(href) === COMPARE_PAGE.path) return COMPARE_PAGE + if (barePath(href) === COMPARE_PAGE.path) return COMPARE_PAGE return matchPreviewPage(href) } @@ -445,11 +470,11 @@ export function previewLocationLabel(url: string): string { if (page) return page.label const trigger = triggerLabelForPath(url) if (trigger) return trigger - const run = stripBase(url).match(/^\/run\/([^/?#]+)/) + const run = parseRunRoute(url) // Job ids are time-ordered, so runs started moments apart share their head. - if (run) return `Run ${decodeURIComponent(run[1]).slice(-8)}` + if (run) return `Run ${run.jobId.slice(-8)}` // The runs of one item, as a detail page's Runs button links them. - const itemRuns = stripBase(url).match(/^\/runs\/(.+)$/) + const itemRuns = barePath(url).match(/^\/runs\/(.+)$/) if (itemRuns) return `Runs · ${decodeURIComponent(itemRuns[1]).split('/').pop()}` const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) return pipelineFolder @@ -457,7 +482,7 @@ export function previewLocationLabel(url: string): string { if (historical) return `${historical.path.split('/').pop()} @ ${historical.hash.slice(0, 8)}` const parsed = parsePreviewItemRoute(url) if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath - return stripBase(url) + return barePath(url) } /** The friendly display leaf for a preview tab, or `undefined` to fall back to @@ -502,14 +527,6 @@ export function parsePreviewSelectedId(url: string): string | undefined { } } -// A `/pipeline/` route is the data-pipeline graph editor for that folder -// (the folder is a single path segment, not a workspace item path). The bare -// `/pipeline` list page is not an editor. Returns the folder name, or null. -export function parsePipelineRoute(fullPath: string): string | null { - const m = stripBase(fullPath).match(/^\/pipeline\/([^/?#]+)/) - return m ? normalizePipelineFolder(decodeURIComponent(m[1])) : null -} - // The id (before the query) is the artifact's stable routing identity; the name rides in the // hash so the tab strip labels it without a store lookup, and the version in the query so the // tab persists it. @@ -539,6 +556,25 @@ export function parseRunFormRoute(url: string): { toolCallId: string; label: str } } +/** The run a `/run/` location shows, in the workspace its query names (the opener's, + * when it names none). */ +export function parseRunRoute( + url: string +): { jobId: string; workspace?: string; viewToken: boolean } | null { + const m = barePath(url).match(/^\/run\/([^/?#]+)$/) + if (!m) return null + try { + const query = new URL(url, 'http://_').searchParams + return { + jobId: decodeURIComponent(m[1]), + workspace: query.get('workspace') || undefined, + viewToken: query.has('view_token') + } + } catch { + return null + } +} + export function runFormUrl(toolCallId: string, label: string): string { return `runform:${encodeURIComponent(toolCallId)}#${encodeURIComponent(label)}` } @@ -562,9 +598,10 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:') // How a preview tab should render: as an in-process live editor, the deployed // item's view page, or an iframe fallback. An item of a wrappable kind (script, // flow, raw app) mounts its per-(kind,path) cell editor on `/edit/` and its viewer -// on `/get/`; a `/pipeline/` route mounts the data-pipeline graph editor -// (single, shared runtime.pipelineEditorState — `path` is the folder); the list page -// of a page item kind mounts that list; everything +// on `/get/`; a `/pipeline/` route mounts the data-pipeline graph editor of +// that folder (`path` is the folder); the list page of a page item kind mounts that +// list, and so does the runs list; a `/run/` route without a share token mounts that +// run's page; everything // else (static pages, regular drag-and-drop apps, any other route) stays an iframe. export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } @@ -573,8 +610,21 @@ export type PreviewSlot = | { kind: 'runform'; toolCallId: string } | { kind: 'pageitem'; ref: PageItemRef } | { kind: 'pagelist'; path: string } + | { kind: 'run'; jobId: string; workspace?: string } | { kind: 'iframe' } +/** Whether a tab on this slot shows a page, framed or mounted in process, and so has a + * location to stamp as the chat's ACTIVE PREVIEW. Editors register as the ACTIVE EDITOR. */ +export function slotShowsPage(kind: PreviewSlot['kind']): boolean { + return ( + kind === 'iframe' || + kind === 'pageitem' || + kind === 'pagelist' || + kind === 'run' || + kind === 'viewer' + ) +} + export function resolvePreviewTab(url: string): PreviewSlot { const artifact = parseArtifactRoute(url) if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version } @@ -582,8 +632,14 @@ export function resolvePreviewTab(url: string): PreviewSlot { if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId } const pageItem = parsePageItemRoute(url) if (pageItem) return { kind: 'pageitem', ref: pageItem } - const route = stripBase(url) - if (isPageItemListPath(route)) return { kind: 'pagelist', path: route } + const route = barePath(url) + if (isPreviewListPath(route)) return { kind: 'pagelist', path: route } + const run = parseRunRoute(url) + // A share link's token authorizes every request of the window that installs it, so a run + // opened through one keeps a frame of its own rather than lending the token to the session. + if (run && !run.viewToken) { + return { kind: 'run', jobId: run.jobId, ...(run.workspace && { workspace: run.workspace }) } + } const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder } @@ -617,6 +673,7 @@ export function workspacePageHref(location: string): string | undefined { case 'editor': case 'viewer': case 'pagelist': + case 'run': case 'iframe': return location default: { diff --git a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts index 981b4cc752..4ade5552bf 100644 --- a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts @@ -1,6 +1,10 @@ import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte' +import { get } from 'svelte/store' +import { userWorkspaces } from '$lib/stores' +import { roleCanAuthor } from '$lib/editRights' import { checkDeployPermission, + checkDeployRules, checkItemExists, deployPermissionForKind, getItemValue, @@ -27,6 +31,8 @@ import { import { maskKey } from './modifiedItemsMask' import { sessionState } from './sessionState.svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' +import { AssetService } from '$lib/gen' +import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths' export type DeploymentStatus = { status: 'loading' | 'failed'; error?: string } @@ -52,6 +58,9 @@ export interface SessionDeployModelArgs { onItemDiscarded?: (item: DeployItem) => void /** Chat-modified-items mask; undefined shows every draft. */ mask?: Set + /** Deploys a pipeline folder's draft bundle, which no per-item deploy can: it + * expands into the folder's scripts and triggers. */ + deployPipeline?: (folder: string) => Promise } export interface DiffValues { @@ -59,6 +68,15 @@ export interface DiffValues { after: unknown } +// A pipeline has no row of its own: it exists while its folder holds pipeline scripts. +async function itemExists(kind: Kind, path: string, workspace: string): Promise { + if (kind !== PIPELINE_DRAFT_KIND) return checkItemExists(kind, path, workspace) + const folder = pipelineFolderFromBundlePath(path) + if (!folder) return false + const pipelines = await AssetService.listPipelineFolders({ workspace }) + return pipelines.some((p) => p.folder === folder) +} + /** Resolves which mask-only candidates still exist in the session workspace * (deployed) vs are gone (discarded drafts / deleted items) — the * `existingKeys` input of `buildDeployItems`. Undefined while resolving, so @@ -100,7 +118,7 @@ export function useExistingMaskKeys( // workspace; a discarded or deleted one doesn't. void Promise.all( cands.map((c) => - checkItemExists(c.deployKind, c.path, workspaceId) + itemExists(c.deployKind, c.path, workspaceId) .then((exists) => (exists ? c.key : null)) .catch(() => null) ) @@ -214,6 +232,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { // button disables with a reason instead of failing on click. `ok` defaults // true while resolving (fail-open). let deployPerm = $state({ ok: true }) + // `checkDeployPermission` refuses every operator, but flows.rs and apps.rs let one with the + // workspace's matching builder right deploy that kind, under the rules alone. + let builder = $state<{ perm: DeployPermission; kinds: Kind[] } | undefined>(undefined) let deployPermFetchedFor = '' $effect(() => { const ws = getArgs().workspaceId @@ -223,12 +244,27 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { // Reset to fail-open for the new workspace and drop a stale resolution // (a slower fetch for the previous workspace must not gate this one). deployPerm = { ok: true } - void checkDeployPermission(ws).then((perm) => { - if (deployPermFetchedFor === ws) deployPerm = perm + builder = undefined + void checkDeployPermission(ws).then(async (perm) => { + if (deployPermFetchedFor !== ws) return + deployPerm = perm + const settings = get(userWorkspaces).find((w) => w.id === ws)?.operator_settings + const kinds = (['flow', 'raw_app'] as const).filter((k) => + roleCanAuthor(k, { operator: true }, settings) + ) + if (perm.refusedBy === 'operator' && kinds.length) { + const rules = await checkDeployRules(ws) + if (deployPermFetchedFor === ws) builder = { perm: rules, kinds } + } }) }) }) + function permissionFor(kind: Kind): DeployPermission { + if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind) + return deployPermissionForKind(deployPerm, kind) + } + // ── Deploy execution ───────────────────────────────────────────────────── // Per-item transient deploy state (keyed by DeployItem.key): loading or // failed. Success has no status — the row's badge flipping to Deployed is @@ -246,11 +282,18 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { async function runPlan(entry: DeployPlanEntry): Promise { const cur = getArgs().workspaceId switch (entry.op) { - case 'deploy_draft': + case 'deploy_draft': { + const folder = + entry.draftKind === PIPELINE_DRAFT_KIND + ? pipelineFolderFromBundlePath(entry.path) + : undefined + const deployPipeline = getArgs().deployPipeline + if (folder && deployPipeline) return deployPipeline(folder) return deployDraft(entry.draftKind, entry.path, cur, { draftOnly: entry.draftOnly, rawApp: entry.rawApp }) + } case 'discard': return discardDraft(entry.draftKind, entry.path, cur, entry.draftOnly, entry.legacy) } @@ -271,8 +314,7 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { // Don't attempt a deploy we know the user can't make (no write permission on // the path, or refused by the preflight for this kind) — the UI disables it // too; this is the guard behind that. - if (!discard && (!item.canWrite || !deployPermissionForKind(deployPerm, item.deployKind).ok)) - return false + if (!discard && (!item.canWrite || !permissionFor(item.deployKind).ok)) return false setStatus(item.key, { status: 'loading' }) deploying = true try { @@ -314,6 +356,16 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { // ── Diff values (one resolver for tree + column) ───────────────────────── async function loadDiffValues(item: DeployItem): Promise { + const pipelineFolder = + item.draftKind === PIPELINE_DRAFT_KIND ? pipelineFolderFromBundlePath(item.path) : undefined + if (pipelineFolder) { + // Dynamic: the pipeline deploy module reaches every trigger kind's save utils. + const { loadPipelineDraftDiff } = await import( + '$lib/components/assets/AssetGraph/pipelineDeploy.svelte' + ) + if (item.hasDraft) return loadPipelineDraftDiff(getArgs().workspaceId, pipelineFolder) + return { before: undefined, after: undefined } + } const base = diffBaseFor(item, getArgs().workspaceId) if (base.kind === 'draft') { const { deployed, draft } = await getDraftDiffValues( @@ -358,7 +410,7 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { * that kind stays deployable while a script row does not. */ deployPermissionForKind(kind: Kind): DeployPermission { - return deployPermissionForKind(deployPerm, kind) + return permissionFor(kind) }, deployRow, discardRow diff --git a/frontend/src/lib/components/sessions/sessionDeployModel.test.ts b/frontend/src/lib/components/sessions/sessionDeployModel.test.ts index 7fa4e3cca8..ee2a4115da 100644 --- a/frontend/src/lib/components/sessions/sessionDeployModel.test.ts +++ b/frontend/src/lib/components/sessions/sessionDeployModel.test.ts @@ -101,6 +101,15 @@ describe('buildDeployItems', () => { expect(items[0].hasDraft).toBe(true) }) + it("a pipeline's deployed scripts fold into its one row", () => { + const bundle = maskKey('data_pipeline', 'f/crm/data_pipeline') + const node = maskKey('script', 'f/crm/clean') + const mask = new Set([bundle, node]) + const items = buildDeployItems({ draftItems: [], mask, existingKeys: new Set([bundle, node]) }) + expect(items.map((i) => i.key)).toEqual([bundle]) + expect(maskOnlyCandidates({ draftItems: [], mask }).map((c) => c.key)).toEqual([bundle]) + }) + it('maskOnlyCandidates returns keys not covered by a draft', () => { const cands = maskOnlyCandidates({ draftItems: [draft('script', 'u/a/draft')], diff --git a/frontend/src/lib/components/sessions/sessionDeployModel.ts b/frontend/src/lib/components/sessions/sessionDeployModel.ts index d38de06c86..4933f3a64a 100644 --- a/frontend/src/lib/components/sessions/sessionDeployModel.ts +++ b/frontend/src/lib/components/sessions/sessionDeployModel.ts @@ -1,7 +1,7 @@ import type { UserDraftItemKind } from '$lib/gen' import type { DraftItem } from '$lib/workspaceDrafts.svelte' import type { Kind } from '$lib/utils_deployable' -import { maskKey } from './modifiedItemsMask' +import { foldedIntoPipelineRow, maskKey } from './modifiedItemsMask' // Unified item model for the session Review & Deploy surface. This is the pure, // UI-free core: every item the session touched is either a pending **draft** or @@ -128,6 +128,7 @@ export function buildDeployItems(input: BuildInput): DeployItem[] { if (sep < 0) continue const udk = k.slice(0, sep) as UserDraftItemKind const path = k.slice(sep + 1) + if (foldedIntoPipelineRow(mask, udk, path)) continue seen.add(k) out.push({ key: k, @@ -172,6 +173,7 @@ export function maskOnlyCandidates( if (sep < 0) continue const udk = k.slice(0, sep) as UserDraftItemKind const path = k.slice(sep + 1) + if (foldedIntoPipelineRow(mask, udk, path)) continue out.push({ key: k, draftKind: udk, deployKind: deployKindOf(udk, udk === 'raw_app'), path }) } return out diff --git a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts index f30d66ca45..657f120e98 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts @@ -998,9 +998,23 @@ async function flush(): Promise { let settledAny = false let leftForNext = false for (const [ws, w] of work) { - const out = await pushWorkspace(ws, w, email) - if (out.status === 'abort') return - if (out.status === 'off') { + // A push carries whole transcripts, and a workspace that keeps no backups is + // asked again on every page load: one this page has not heard from yet is asked + // with the listing first, which costs nothing to refuse. + let probe: 'on' | 'off' | 'failed' = 'on' + if (!wsState.has(ws)) { + const listing = await fetchListing(ws) + if (getCurrentUserEmail() !== email) return + probe = listing === 'off' || listing === 'failed' ? listing : 'on' + if (probe === 'on') wsState.set(ws, 'on') + } + if (probe === 'failed') { + backOff() + continue + } + const out = probe === 'off' ? undefined : await pushWorkspace(ws, w, email) + if (out?.status === 'abort') return + if (!out || out.status === 'off') { markOff(ws) await staleWorkspaceSync(ws, email) // Same rule as the loop above: a removal is worth keeping only for a session @@ -1189,22 +1203,28 @@ function unpackBackup( type BackupListing = Awaited> -/** The workspace's listing; `off` when it keeps no backups, `failed` when it could not be - * listed this time. */ -async function listWorkspace(ws: string, email: string): Promise { +/** The workspace's listing as the server answers it; `off` when it keeps no backups, + * `failed` when it could not be listed this time. */ +async function fetchListing(ws: string): Promise { let listing try { listing = await AiService.listAiSessionBackups({ workspace: ws }) } catch (e) { const status = statusOf(e) - if (status === 404 || status === 403) { - markOff(ws) - return 'off' - } + // 404: a build without object storage, or a storage whose resource is gone. + // 403: nothing this token may back up. + if (status === 404 || status === 403) return 'off' console.warn('Could not list session backups', e) return 'failed' } - if (!listing.enabled) { + return listing.enabled ? listing : 'off' +} + +/** `fetchListing`, with what it says of the workspace recorded for the page. */ +async function listWorkspace(ws: string, email: string): Promise { + const listing = await fetchListing(ws) + if (listing === 'failed') return 'failed' + if (listing === 'off') { markOff(ws) return 'off' } diff --git a/frontend/src/lib/components/sessions/sessionMirror.test.ts b/frontend/src/lib/components/sessions/sessionMirror.test.ts index 55a1acc138..9a8a211ded 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.test.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.test.ts @@ -150,6 +150,7 @@ beforeEach(async () => { __resetMirrorForTesting() pushMock.mockReset() listMock.mockReset() + listMock.mockResolvedValue({ enabled: true, sessions: [] }) pullMock.mockReset() chatImport.unavailable = false chatImport.pruneFails = false @@ -316,6 +317,30 @@ describe('sessionMirror flush', () => { expect((await __syncRowsForTesting(EMAIL)).some((r) => r.id === 'sr')).toBe(false) }) + it('asks a workspace it has not heard from before sending it a transcript', async () => { + const s: Session = { id: 'sp', name: 'session-1', createdAt: 1, workspace_id: 'ws' } + sessionState.sessions = [s] + await putSession(s) + const { ApiError } = await import('$lib/gen') + listMock.mockRejectedValueOnce( + new ApiError({ method: 'GET', url: '' } as never, { status: 404 } as never, 'no resource') + ) + await __flushForTesting() + expect(listMock).toHaveBeenCalledTimes(1) + expect(pushMock).not.toHaveBeenCalled() + expect(await pendingDirty()).toEqual(['sp']) + + // A page that heard the workspace keeps backups pushes without asking again. + __resetMirrorForTesting() + pushMock.mockResolvedValue({ enabled: true, results: [{ id: 'sp' }] }) + await __flushForTesting() + await putSession({ ...s, summary: 'changed' }) + await __flushForTesting() + expect(listMock).toHaveBeenCalledTimes(2) + expect(pushMock).toHaveBeenCalledTimes(2) + expect(await pendingDirty()).toEqual([]) + }) + it('keeps the marks when the push fails, and stops for a workspace without storage', async () => { const s: Session = { id: 's2', name: 'session-2', createdAt: 1, workspace_id: 'ws' } const never: Session = { id: 's2b', name: 'session-3', createdAt: 2, workspace_id: 'ws' } @@ -355,11 +380,11 @@ describe('sessionMirror flush', () => { // Backups an admin turns on again elsewhere are noticed once the page's memory of // them being off expires, with nothing else prompting it: the removal goes then. - listMock.mockResolvedValue({ enabled: true, sessions: [] }) + listMock.mockClear() pushMock.mockResolvedValueOnce({ enabled: true, results: [{ id: 's2' }] }) await vi.waitFor(() => expect(pushMock).toHaveBeenCalledTimes(4), { timeout: 3000 }) expect(pushMock.mock.calls[3][0].requestBody.removed).toEqual(['s2']) - await vi.waitFor(() => expect(listMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(listMock).toHaveBeenCalled()) await __settleForTesting() expect(pendingKeys()).toEqual([]) }) @@ -426,7 +451,7 @@ describe('sessionMirror flush', () => { await __flushForTesting() expect(pushMock).toHaveBeenCalledTimes(2) - listMock.mockResolvedValue({ enabled: true, sessions: [] }) + listMock.mockClear() pushMock.mockResolvedValueOnce({ enabled: true, results: [{ id: 'so' }] }) usersWorkspaceStore.set({ email: EMAIL, workspaces: [] } as never) backupSettingsChanged('ws') diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index a2c20e08a8..c6aea96a67 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -1,9 +1,12 @@ -import { base } from '$app/paths' import type { SessionTarget } from './sessionState.svelte' +import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' // Maps a session's editor target to the canonical full-page Windmill route. export function sessionTargetHref(target: SessionTarget | undefined): string | undefined { if (!target) return undefined + if (target.kind === 'pipeline') { + return `/pipeline/${encodeURIComponent(normalizePipelineFolder(target.path))}` + } const seg = target.kind === 'script' ? 'scripts/edit' @@ -13,12 +16,12 @@ export function sessionTargetHref(target: SessionTarget | undefined): string | u ? 'apps_raw/edit' : undefined if (!seg) return undefined - return `${base}/${seg}/${target.path}` + return `/${seg}/${target.path}` } // Point a preview tab's URL at a place inside the previewed editor (the flow // step to select). The params are not part of the item's identity — every tab -// resolver strips the query (see previewRouter's stripBase) — so they only ever +// resolver strips the query (see previewRouter's barePath) — so they only ever // steer where the editor opens. No-op without params. export function withPreviewParams( url: string | undefined, @@ -47,6 +50,30 @@ export function withMenuHidden(url: string, workspaceId?: string): string { } } +// Remembers `nomenubar` for the rest of this iframe's life. Navigating inside a preview drops +// the query param (client-side routing and full loads both), and the global nav must not pop +// back in. sessionStorage so it survives a reload within the frame; the top window never writes +// it, so an ordinary tab and the oauth callback still toggle normally. +export function rememberMenuHidden(url: URL): void { + if (typeof window === 'undefined' || window.self === window.top) return + if (url.searchParams.get('nomenubar') !== 'true') return + try { + sessionStorage.setItem('nomenubar_embedded', 'true') + } catch {} +} + +// Whether this page is shown without the workspace navigation. The layout drops the sidebar and +// the band's trail, so a page whose only control lives in the band has to carry it itself — see +// InWorkspaceAppViewer's Edit button. Read this rather than the raw query param: a preview tab +// that has navigated no longer carries it. +export function isMenuHidden(url: URL): boolean { + return ( + url.searchParams.get('nomenubar') === 'true' || + url.pathname.startsWith('/oauth/callback/') || + isSessionPreviewFrame() + ) +} + // True when this window is a sessions-preview iframe: embedded, with the `nomenubar` flag // the preview always sets and the logged layout stickies into sessionStorage. export function isSessionPreviewFrame(): boolean { diff --git a/frontend/src/lib/components/sessions/sessionMode.test.ts b/frontend/src/lib/components/sessions/sessionMode.test.ts index 9bc67b9a58..75d3445fed 100644 --- a/frontend/src/lib/components/sessions/sessionMode.test.ts +++ b/frontend/src/lib/components/sessions/sessionMode.test.ts @@ -13,9 +13,13 @@ describe('sessionTargetHref', () => { expect(sessionTargetHref({ kind: 'raw_app', path: 'u/me/app' })).toBe('/apps_raw/edit/u/me/app') }) - it('returns undefined for no target or a kind without a full-page route', () => { + it('maps a pipeline to its folder page, whichever form the folder is given in', () => { + expect(sessionTargetHref({ kind: 'pipeline', path: 'crm' })).toBe('/pipeline/crm') + expect(sessionTargetHref({ kind: 'pipeline', path: 'f/crm' })).toBe('/pipeline/crm') + }) + + it('returns undefined for no target', () => { expect(sessionTargetHref(undefined)).toBeUndefined() - expect(sessionTargetHref({ kind: 'pipeline', path: 'u/me/pipe' })).toBeUndefined() }) }) diff --git a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts new file mode 100644 index 0000000000..caceb2e623 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts @@ -0,0 +1,31 @@ +import { fromStore } from 'svelte/store' +import { userWorkspaces } from '$lib/stores' +import { useActingUser } from '$lib/actingUser.svelte' +import { useEditRights } from '$lib/operatorWriteRights' +import type { WorkspaceItem } from '$lib/components/workspacePicker' +import { previewPageAllowed, type PreviewPage } from './previewRouter' + +/** + * What the session UI may offer in `workspace`. Its editors save drafts, so it reads + * `roleCanDraft` / `canDraftItem` — the rule the chat's draft tools and `open_preview` follow — so + * a control and the tool behind it never disagree. An unknown user reads as able to draft, as the toolset does before access + * resolves: hiding every editor from a developer while `whoami` is in flight is the worse + * mistake, and the server refuses an operator anyway. Reads context and registers an effect: + * call during component initialisation. + */ +export function useSessionPermissions(workspace: () => string | undefined) { + const rights = useEditRights(workspace) + const user = useActingUser(workspace) + const workspaces = fromStore(userWorkspaces) + const entry = $derived(workspaces.current.find((w) => w.id === workspace())) + + return { + /** Whether this user can save the drafts the `kind` editor writes. */ + canOpenEditor: (kind: string) => rights.roleCanDraft(kind), + /** The same, and this item's own permissions let them write it. */ + canEditItem: (item: Pick) => + rights.canDraftItem(item.raw_app ? 'raw_app' : item.kind, item.path, item.extraPerms), + /** Whether the page loads for this user rather than refusing them. */ + pageAllowed: (page: PreviewPage) => previewPageAllowed(page, user.current, entry) + } +} diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index 1104e1c4b3..b1f14fadbe 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -1,4 +1,3 @@ -import { base } from '$lib/base' import { randomUUID } from '$lib/utils/uuid' import { editPathFor, viewPathFor, type WorkspaceItem } from '$lib/components/workspacePicker' import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' @@ -20,7 +19,7 @@ import { previewLocationLabel, resolvePreviewTab, runFormUrl, - stripBase, + barePath, type ArtifactVersionTarget, type PreviewItemMode, type PreviewTarget @@ -113,11 +112,11 @@ function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string { // A re-point with no mode keeps the side the tab is already on, so an AI write to // an item the user left on View does not yank them back to the editor. const mode = target.mode ?? (onto ? (parsePreviewItemRoute(onto.url)?.mode ?? 'edit') : 'edit') - if (mode !== 'view') return `${base}${editPathFor(target.item)}` + if (mode !== 'view') return editPathFor(target.item) // Not carried over from `onto`: a version pin is something the reader asked for // once, so every other re-point of this tab lands on the current deployed version. const pin = target.version ? `?version=${encodeURIComponent(target.version)}` : '' - return `${base}${viewPathFor(target.item)}${pin}` + return `${viewPathFor(target.item)}${pin}` } // Point a tab at a new destination. Clears `friendlyLabel`/`friendlyPath` @@ -175,7 +174,7 @@ export function previewTargetForSessionTarget( ): PreviewTarget | undefined { if (kind === 'pipeline') { const folder = normalizePipelineFolder(path) - return { type: 'page', href: `${base}/pipeline/${encodeURIComponent(folder)}`, label: folder } + return { type: 'page', href: `/pipeline/${encodeURIComponent(folder)}`, label: folder } } const item: WorkspaceItem = kind === 'raw_app' @@ -455,20 +454,15 @@ export class SessionPreviewTabs { } } const url = targetUrl(target) - // Pipeline previews all share one runtime.pipelineEditorState, so keep at - // most one pipeline tab: re-point the existing one to the requested folder - // rather than opening a second pipeline editor that would fight over the - // shared state (`focused` when it already showed this folder, else `opened` - // since the view now shows a different pipeline). + // One tab per pipeline folder: two would mount two editors over one folder's + // state and draft. const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { - const existing = this.#tabs.find((t) => parsePipelineRoute(t.url) !== null) + const existing = this.#tabs.find((t) => parsePipelineRoute(t.url) === pipelineFolder) if (existing) { - const same = existing.url === url - this.#retarget(existing, url) this.#activeId = existing.id this.#flush() - return { status: same ? 'focused' : 'opened' } + return { status: 'focused' } } } // Dedupe artifacts by id, not full url: an update may have changed the name the url carries. @@ -543,15 +537,11 @@ export class SessionPreviewTabs { return } } - // Keep at most one pipeline tab (all share runtime.pipelineEditorState): if a - // *different* tab already hosts a pipeline, retarget and focus it rather than - // turning the active tab into a second pipeline editor racing the shared - // state. Same invariant as open(); a no-op when the active tab is that tab. + // Same one-tab-per-folder rule as open(): focus the tab already on this folder. const pipelineFolder = parsePipelineRoute(targetUrl(target)) if (pipelineFolder) { - const existing = this.#tabs.find((x) => parsePipelineRoute(x.url) !== null) + const existing = this.#tabs.find((x) => parsePipelineRoute(x.url) === pipelineFolder) if (existing && existing.id !== t.id) { - this.#retarget(existing, targetUrl(target, existing)) this.#activeId = existing.id this.#flush() return @@ -803,7 +793,7 @@ export function selectPreviewTabsToClose( // whose hash is app state, and any page can carry a filter value the user typed. function previewLocationDetail(where: string): string { const { location, open } = previewLocationContext(where) - const detail = [location === stripBase(where) ? undefined : location, open && `open: ${open}`] + const detail = [location === barePath(where) ? undefined : location, open && `open: ${open}`] .filter(Boolean) .join(', ') return detail ? ` (${detail})` : '' @@ -839,7 +829,7 @@ export function describePreview( ? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"` : // Trigger list pages land here (they're outside PREVIEW_PAGES), and // their `#` is the trigger the drawer has open. - `${stripBase(where)}${previewLocationDetail(where)}` + `${barePath(where)}${previewLocationDetail(where)}` const slotKind = resolvePreviewTab(t.url).kind const live = slotKind === 'editor' || slotKind === 'pageitem' diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts index 994624511a..669e9fc594 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts @@ -11,7 +11,6 @@ import { } from './sessionPreviewTabs.svelte' import { artifactUrl, type PreviewTarget } from './previewRouter' import type { SessionPreviewTab } from './sessionState.svelte' -import { base } from '$lib/base' // In-memory adapter spy: records persisted snapshots, no IDB. function makeAdapter() { @@ -48,10 +47,10 @@ const dndAppTarget: PreviewTarget = { item: { kind: 'app', path: 'u/me/legacy', summary: '' } } const pageTarget: PreviewTarget = { type: 'page', href: '/runs', label: 'Runs' } -const pipelineTarget: PreviewTarget = { type: 'page', href: `${base}/pipeline/crm`, label: 'crm' } +const pipelineTarget: PreviewTarget = { type: 'page', href: '/pipeline/crm', label: 'crm' } const pipelineTarget2: PreviewTarget = { type: 'page', - href: `${base}/pipeline/sales`, + href: '/pipeline/sales', label: 'sales' } const artifactTarget: PreviewTarget = { type: 'artifact', id: 'art1', name: 'Plan' } @@ -149,7 +148,7 @@ describe('previewTargetForSessionTarget', () => { it('maps pipeline to its folder route page target', () => { expect(previewTargetForSessionTarget('pipeline', 'my_folder')).toEqual({ type: 'page', - href: `${base}/pipeline/my_folder`, + href: '/pipeline/my_folder', label: 'my_folder' }) }) @@ -159,7 +158,7 @@ describe('previewTargetForSessionTarget', () => { // path `f/f//…`. expect(previewTargetForSessionTarget('pipeline', 'f/my_folder')).toEqual({ type: 'page', - href: `${base}/pipeline/my_folder`, + href: '/pipeline/my_folder', label: 'my_folder' }) }) @@ -252,7 +251,7 @@ describe('page item tabs', () => { { kind: 'raw_app', path: 'u/me/draft_1' }, { kind: 'raw_app', path: 'u/me/app' } ) - const url = `${base}/apps_raw/edit/u/me/app` + const url = '/apps_raw/edit/u/me/app' expect(o.tabs).toEqual([{ id, url, loc: url }]) }) @@ -454,7 +453,7 @@ describe('SessionPreviewTabs.open', () => { it('dedupes a run form by tool call, and hands that tab to the run in place', () => { const o = owner() - o.open({ type: 'page', href: `${base}/runs`, label: 'Runs' }) + o.open({ type: 'page', href: '/runs', label: 'Runs' }) o.open({ type: 'runform', toolCallId: 'call_1', label: 'Run refund' }) const id = o.tabs[1].id // The label carries the script's summary, which is not what identifies the call. @@ -462,10 +461,10 @@ describe('SessionPreviewTabs.open', () => { 'focused' ) expect(o.tabs).toHaveLength(2) - o.retargetRunForm('call_1', `${base}/run/01a0?workspace=w`) + o.retargetRunForm('call_1', '/run/01a0?workspace=w') // Still second: a tab that followed its call to the run has not moved in the strip. expect(o.tabs[1].id).toBe(id) - expect(o.tabs[1].url).toBe(`${base}/run/01a0?workspace=w`) + expect(o.tabs[1].url).toBe('/run/01a0?workspace=w') }) it('re-points the same tab (no duplicate) when the artifact was renamed', () => { @@ -578,19 +577,22 @@ describe('SessionPreviewTabs.navigate', () => { expect(o.tabs.find((t) => t.id === pageTabId)?.url).toBe('/runs') }) - it('retargets the one pipeline tab instead of turning the active tab into a second', () => { + it('keeps one tab per pipeline folder, several folders side by side', () => { const o = owner() o.open(pipelineTarget) - const pipelineTabId = o.activeId - o.open(scriptTarget) // a second, non-pipeline tab is now active - const scriptTabId = o.activeId - o.navigate(pipelineTarget2) - // No second pipeline editor: the existing one is retargeted and focused. + const crmTabId = o.activeId + o.open(pipelineTarget2) + const salesTabId = o.activeId expect(o.tabs).toHaveLength(2) - expect(o.activeId).toBe(pipelineTabId) - expect(o.tabs.find((t) => t.id === pipelineTabId)?.url).toBe(`${base}/pipeline/sales`) - // The script tab is untouched. - expect(o.tabs.find((t) => t.id === scriptTabId)?.url).toBe('/scripts/edit/u/me/foo') + expect(salesTabId).not.toBe(crmTabId) + o.open(scriptTarget) + // A folder already open is focused, from open() and navigate() alike. + o.open(pipelineTarget) + expect(o.activeId).toBe(crmTabId) + o.navigate(pipelineTarget2) + expect(o.activeId).toBe(salesTabId) + expect(o.tabs).toHaveLength(3) + expect(o.tabs.find((t) => t.id === crmTabId)?.url).toBe('/pipeline/crm') }) it('retargets the active pipeline tab in place to a new folder', () => { @@ -600,7 +602,7 @@ describe('SessionPreviewTabs.navigate', () => { o.navigate(pipelineTarget2) expect(o.tabs).toHaveLength(1) expect(o.activeId).toBe(tabId) - expect(o.tabs[0].url).toBe(`${base}/pipeline/sales`) + expect(o.tabs[0].url).toBe('/pipeline/sales') }) it('focuses the tab already viewing the artifact instead of duplicating the viewer', () => { @@ -1115,12 +1117,12 @@ describe('SessionPreviewTabs.pulseFocus', () => { const o = owner() const run: PreviewTarget = { type: 'page', - href: `${base}/run/job-1?workspace=fork`, + href: '/run/job-1?workspace=fork', label: 'Run' } o.open(run) // What the frame reports back has the injected params stripped. - o.observeLocation(o.activeId, `${base}/run/job-1?workspace=fork&nomenubar=true`) + o.observeLocation(o.activeId, '/run/job-1?workspace=fork&nomenubar=true') expect(o.tabs.length).toBe(1) expect(o.open(run).status).toBe('focused') expect(o.tabs.length).toBe(1) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 10cc938fbe..b84bd74cbe 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1,10 +1,11 @@ import { SvelteMap } from 'svelte/reactivity' import { get } from 'svelte/store' -import { base } from '$lib/base' import { invalidate as invalidateWorkspaceItems } from '$lib/components/workspacePicker' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import { PipelineEditorState } from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte' +import { PIPELINE_DRAFT_KIND, pipelineBundlePath, pipelineLocalMirrorKey } from '$lib/pipelinePaths' +import type { DeployResult } from '$lib/utils_workspace_deploy' import { initFlow } from '$lib/components/flows/flowStore.svelte' import { ApiError, @@ -29,7 +30,12 @@ type SavedFlow = Omit & { draft?: Flow } import type { HiddenRunnable } from '$lib/components/apps/types' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' -import { react19Template, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' +import { + BUILDER_FRAMEWORK_TEMPLATES, + react19Template, + STARTER_RUNNABLES +} from '$lib/components/raw_apps/templates' +import { operatorBuilderAppsIn, workspaceListLoaded } from '$lib/operatorWriteRights' import { random_adj } from '$lib/components/random_positive_adjetive' import { copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' @@ -71,6 +77,7 @@ import { promptSafe, parseRunFormRoute, resolvePreviewTab, + slotShowsPage, type PreviewSlot } from './previewRouter' import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' @@ -102,6 +109,7 @@ import { type RawAppRunsProvider, type RawAppScreenshotRequester } from '$lib/components/raw_apps/utils' +import { resolveGlobalDraftStoragePathByKind } from '$lib/components/copilot/chat/global/userDraftAdapter' import type { RawAppDomQuery, RawAppDomRequester, @@ -135,6 +143,12 @@ export interface LoadSlot { export type SessionTargetKind = 'flow' | 'script' | 'raw_app' +/** What a mounted pipeline editor view offers the rest of the session. */ +export interface PipelineView { + /** Deploy all of the folder's drafts; failures stay drafts and are shown in the view. */ + deployAll(): Promise +} + // The live runtime value a raw-app editor cell binds. Legacy drag-and-drop apps // are intentionally NOT hosted in the session preview (only code-based raw apps). export interface RawAppRuntimeValue { @@ -207,9 +221,17 @@ export interface SessionRuntime { // (renderer) and the open_preview/get_preview_status tools cross it, so the // tab model has exactly one live copy. readonly previewTabs: SessionPreviewTabs - // Pipeline target state — persists across editor hide/show (the pane unmounts - // on hide, so this can't be component-local) and across session switches. - readonly pipelineEditorState: PipelineEditorState + // Pipeline editor state per folder — persists across editor remounts and + // session switches, so it can't be component-local. Created on first use. + pipelineEditor(folder: string): PipelineEditorState + // A mounted pipeline editor view offers its deploy here; returns the unregister. + registerPipelineView(folder: string, view: PipelineView): () => void + // Deploy a folder's pipeline drafts through its editor tab, opening the tab if + // needed: the tab hydrates the drafts and shows what deployed and what failed. + deployPipeline(folder: string): Promise + // Drop a folder's in-memory drafts once its draft bundle is discarded, so an + // open or later-mounted editor does not save them back. + forgetPipelineDrafts(folder: string): Promise // Per-(kind, path) editor cells (content/baseline stores + load slot), created // on demand. Each editable preview tab resolves its own cell, so several items // stay live at once. @@ -450,14 +472,7 @@ function createRuntime(session: Session): SessionRuntime { // point those at a page the user cannot see. const tab = owner?.displayedTab if (!tab) return undefined - const slotKind = resolvePreviewTab(tab.url).kind - if ( - slotKind !== 'iframe' && - slotKind !== 'pageitem' && - slotKind !== 'pagelist' && - slotKind !== 'viewer' - ) - return undefined + if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined return previewLocationContext(whereIs(tab)) } // Pre-flight: materialise the (still-transient) session, then commit @@ -577,13 +592,13 @@ function createRuntime(session: Session): SessionRuntime { onTabOpened: logTabUsage }) - // Let the jobs tray open a run in this session's preview panel (as an iframe - // tab over the run page). The global side-panel chat leaves this unset and + // Let the jobs tray open a run in this session's preview panel (as a tab on + // the run's page). The global side-panel chat leaves this unset and // falls back to a new browser tab. manager.openRunInPreview = ({ jobId, workspace, label }) => { previewTabs.open({ type: 'page', - href: `${base}/run/${jobId}?workspace=${workspace}`, + href: `/run/${jobId}?workspace=${workspace}`, label }) } @@ -596,7 +611,7 @@ function createRuntime(session: Session): SessionRuntime { } manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId) manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => { - previewTabs.retargetRunForm(toolCallId, `${base}/run/${jobId}?workspace=${workspace}`) + previewTabs.retargetRunForm(toolCallId, `/run/${jobId}?workspace=${workspace}`) } // Read off the tab list rather than the slot's lifecycle: a tab the user has switched // away from is unmounted but still open, and the card must keep its form hidden until it @@ -618,10 +633,78 @@ function createRuntime(session: Session): SessionRuntime { // picker lists, so changeMode's network refreshes would fire once per listing. manager.configureGlobalMode() - // Pipeline target state lives on the runtime (not the PipelineEditorView - // component) so the in-session drafts survive hide/show of the editor pane — - // the pane unmounts on hide, and a component-local store would be discarded. - const pipelineEditorState = new PipelineEditorState() + const pipelineEditors = new Map() + function pipelineEditor(folder: string): PipelineEditorState { + const key = normalizePipelineFolder(folder) + let editor = pipelineEditors.get(key) + if (!editor) { + editor = new PipelineEditorState() + editor.folder = key + pipelineEditors.set(key, editor) + } + return editor + } + + const pipelineViews = new Map() + const pipelineViewWaiters = new Map void>>() + function registerPipelineView(folder: string, view: PipelineView): () => void { + const key = normalizePipelineFolder(folder) + pipelineViews.set(key, view) + const waiters = pipelineViewWaiters.get(key) + pipelineViewWaiters.delete(key) + waiters?.forEach((notify) => notify(view)) + return () => { + if (pipelineViews.get(key) === view) pipelineViews.delete(key) + } + } + function pipelineViewFor(folder: string, timeoutMs = 15000): Promise { + const mounted = pipelineViews.get(folder) + if (mounted) return Promise.resolve(mounted) + return new Promise((resolve) => { + const waiters = pipelineViewWaiters.get(folder) ?? new Set() + pipelineViewWaiters.set(folder, waiters) + const notify = (view: PipelineView) => { + clearTimeout(timer) + resolve(view) + } + const timer = setTimeout(() => { + waiters.delete(notify) + resolve(undefined) + }, timeoutMs) + waiters.add(notify) + }) + } + manager.setPipelineReopener((folder) => { + const target = previewTargetForSessionTarget('pipeline', folder) + if (target) previewTabs.open(target) + }) + async function deployPipeline(folder: string): Promise { + const key = normalizePipelineFolder(folder) + const target = previewTargetForSessionTarget('pipeline', key) + if (target) previewTabs.open(target) + const view = await pipelineViewFor(key) + if (!view) return { success: false, error: `The pipeline editor for f/${key} did not open.` } + return view.deployAll() + } + async function forgetPipelineDrafts(folder: string): Promise { + const key = normalizePipelineFolder(folder) + const editor = pipelineEditors.get(key) + if (editor) { + // The open pane saves its edits back as a draft when it closes: let it, then + // drop that draft with the rest, or it would come back afterwards. + await editor.closePane() + editor.drafts = new Map() + editor.triggerDrafts = new Map() + editor.clearLiveOverlays() + } + // The editor's crash mirror, which its load falls back to when the DB has + // no draft: left behind, it would restore what was just discarded. + try { + localStorage.removeItem(pipelineLocalMirrorKey(key)) + } catch { + // Storage unavailable: nothing was mirrored either. + } + } const runtimeLogRequesters = new Map() // appPath → requester, one entry per mounted raw-app preview tab. @@ -635,7 +718,10 @@ function createRuntime(session: Session): SessionRuntime { sessionId: session.id, manager, previewTabs, - pipelineEditorState, + pipelineEditor, + registerPipelineView, + deployPipeline, + forgetPipelineDrafts, flowCell, loadedEditorPath, deployedRevision, @@ -965,10 +1051,13 @@ function createRuntime(session: Session): SessionRuntime { } catch (err) { if (seedIfMissing && !deployedOnly && err instanceof ApiError && err.status === 404) { const user = get(userStore) + // A builder can save no inline runnable, so the regular starter could never be saved. + await workspaceListLoaded() + const builder = operatorBuilderAppsIn(workspace) saved.val = undefined store.val = { - files: { ...react19Template }, - runnables: structuredClone(STARTER_RUNNABLES), + files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) }, + runnables: builder ? {} : structuredClone(STARTER_RUNNABLES), data: { ...DEFAULT_DATA }, policy: { on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`, @@ -1034,7 +1123,7 @@ function createRuntime(session: Session): SessionRuntime { }, async requestRuntimeLogs(limit, appPath) { const path = - appPath ?? + (appPath && draftStoragePath(session, 'raw_app', appPath)) ?? activeDomAppPath ?? (runtimeLogRequesters.size === 1 ? [...runtimeLogRequesters.keys()][0] : undefined) if (path === undefined) { @@ -1070,7 +1159,7 @@ function createRuntime(session: Session): SessionRuntime { // its element's app even when another tab is now visible), else the // active preview, else the only one open. const path = - query.appPath ?? + (query.appPath && draftStoragePath(session, 'raw_app', query.appPath)) ?? activeDomAppPath ?? (domRequesters.size === 1 ? [...domRequesters.keys()][0] : undefined) if (path === undefined) return undefined @@ -1167,6 +1256,18 @@ export function listRuntimes(): SessionRuntime[] { return Array.from(runtimes.values()) } +/** A pipeline deleted outside the session: every live session on its workspace + * drops what it holds of it. Its in-memory drafts would otherwise keep showing the + * pipeline in the preview, and autosave would write the deleted draft back. */ +export function forgetDeletedPipeline(workspace: string, folder: string): void { + const key = normalizePipelineFolder(folder) + for (const runtime of runtimes.values()) { + if (runtime.manager.operatingWorkspace !== workspace) continue + void runtime.forgetPipelineDrafts(key) + void runtime.manager.removeModifiedItem(PIPELINE_DRAFT_KIND, pipelineBundlePath(key)) + } +} + export function getRuntime(sessionId: string): SessionRuntime | undefined { return runtimes.get(sessionId) } @@ -1274,6 +1375,18 @@ export function removeSession(sessionId: string): void { deleteSessionState(sessionId) } +// The chat calls a new item by its chosen name while its draft is stored at +// `draft_`; resolve the name the way the chat's write tools do. +function draftStoragePath( + session: Session, + kind: 'script' | 'flow' | 'raw_app', + path: string +): string { + return session.workspace_id + ? resolveGlobalDraftStoragePathByKind(session.workspace_id, kind, path) + : path +} + // Register the global open_preview tool handler once at module load. It // dispatches to the *calling* session (sessionId threaded from the tool ctx), // falling back to the UI-active session only when none was passed — so a @@ -1289,7 +1402,10 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) = if (!session) { return 'Error: no active session to open the preview in.' } - const target = previewTargetForSessionTarget(kind, path) + const target = previewTargetForSessionTarget( + kind, + mode === 'view' || kind === 'pipeline' ? path : draftStoragePath(session, kind, path) + ) if (!target) { return `Error: ${kind} targets cannot be shown in the preview panel.` } @@ -1305,8 +1421,8 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) = // "Unknown tool call" error on the first node it tries to build. if (kind === 'pipeline') { const folder = normalizePipelineFolder(path) - const ready = await runtime.manager.waitForPipelineHelpers() - // A backgrounded session's preview tab does not mount, so its editor never + const ready = await runtime.manager.waitForPipelineHelpers(folder) + // A session whose page is not mounted never mounts the editor, so it never // registers — don't claim success, or the model calls build_pipeline_node // into the void. Tell it the tools aren't available and how to recover. if (!ready) { @@ -1316,7 +1432,7 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) = // off the owner path twice over) is rejected by build_pipeline_node. return `${ result.status === 'focused' ? 'Focused the' : 'Opened the' - } pipeline editor for folder "${folder}" in the side panel. Its nodes go at paths under \`f/${folder}/\` (e.g. \`f/${folder}/\`).` + } pipeline editor for folder "${folder}" in the side panel. build_pipeline_node, edit_pipeline_node and the other pipeline tools are now in your tool list: call them in your next step. Its nodes go at paths under \`f/${folder}/\` (e.g. \`f/${folder}/\`).` } return result.status === 'focused' ? `A preview tab is already showing ${kind} "${path}" — focused it.` @@ -1459,6 +1575,14 @@ setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit, appPath }) toolResult: report } } + if (previewLogs?.sdkConsentPending) { + return { + aiResult: + 'The app built, but the preview is waiting for the user to approve the permissions the app declares in policy.frontend_sdk_scopes, so its code is not running. This is not a bug in the app: do not change the code. Ask the user to approve the permissions in the preview, then call get_app_runtime_logs again.', + uiMessage: 'Waiting for permission approval', + toolResult: 'Waiting for permission approval' + } + } const entries = previewLogs?.entries if (entries === undefined) { return { diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index a0ef0e5696..30b7792487 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -15,7 +15,7 @@ import { type SessionTarget } from './sessionState.svelte' import { sessionTargetHref, withPreviewParams } from './sessionMode.svelte' -import { parsePreviewItemRoute, type PreviewItemRoute } from './previewPaths' +import { parsePipelineRoute, parsePreviewItemRoute } from './previewPaths' import { findMountedOpenInSessionSource } from './openInSessionContext' // Type-only: erased at compile time, so the component graph stays out of this // navigation seam (see the dynamic import in openEditorInSession). @@ -42,10 +42,28 @@ export function rememberNavRoute(pathnameWithSearch: string): void { } /** An item editor the user reached session mode from, as the in-app href a new - * session's preview can open, plus the item it edits (for naming it). */ -export type NewSessionSeed = { url: string; route: PreviewItemRoute } + * session's preview can open, plus what it edits (for naming it). */ +export type NewSessionSeed = { url: string; kind: string; path: string } -// The item (flow, script, app) the user came to session mode from, or undefined +/** What an editor route edits: a session target, or an unset target for an item + * with no in-session editor (a legacy app), which is opened by its page instead. */ +function editedAt( + url: string +): { target: SessionTarget | undefined; kind: string; path: string } | undefined { + const folder = parsePipelineRoute(url) + if (folder) + return { target: { kind: 'pipeline', path: folder }, kind: 'pipeline', path: `f/${folder}` } + const route = parsePreviewItemRoute(url) + if (!route) return undefined + const kind = route.kind === 'app' ? (route.raw_app ? 'raw_app' : undefined) : route.kind + return { + target: kind ? { kind, path: route.itemPath } : undefined, + kind: route.kind, + path: route.itemPath + } +} + +// The item (flow, script, app, pipeline) the user came to session mode from, or undefined // when they came from anywhere else. A "New session" asked for after arriving // from an item is usually a session about that item, but the arrival route // (`enterSessionMode`) resumes whatever session was open, so the picker offers @@ -56,8 +74,8 @@ export type NewSessionSeed = { url: string; route: PreviewItemRoute } export function takeNewSessionSeed(): NewSessionSeed | undefined { if (navRouteOffered) return undefined navRouteOffered = true - const route = parsePreviewItemRoute(lastNavRoute) - return route ? { url: lastNavRoute, route } : undefined + const edited = editedAt(lastNavRoute) + return edited ? { url: lastNavRoute, kind: edited.kind, path: edited.path } : undefined } // The session entering session mode resumes: the active one if selected, else @@ -102,8 +120,8 @@ const RESUME_IDLE_LIMIT_MS = 60 * 60 * 1000 // enterSessionMode does. Rejects when the editor could not persist its draft, // so the caller can stay on the page and say so. export async function enterSessionModeFromNav(): Promise { - const route = parsePreviewItemRoute(lastNavRoute) - if (route) { + const edited = editedAt(lastNavRoute) + if (edited) { const resumable = resumableSession() if (!resumable || Date.now() - sessionLastActivityAt(resumable) > RESUME_IDLE_LIMIT_MS) { // The editor's own hand-off is what its "Open in AI session" button uses: @@ -112,7 +130,7 @@ export async function enterSessionModeFromNav(): Promise { // workspace the item lives in. Only an item with no such editor on // screen (a legacy app, a detail page) is opened by route, as last // persisted, in the workspace the route was scoped to. - const source = findMountedOpenInSessionSource(route) + const source = edited.target && findMountedOpenInSessionSource(edited.target) if (source) await openSourceInSession(source) else await openPageInSession(lastNavRoute, workspaceParamOf(lastNavRoute)) return diff --git a/frontend/src/lib/components/sessions/sessionSwitch.test.ts b/frontend/src/lib/components/sessions/sessionSwitch.test.ts index 4eae32da6b..497e3cbad9 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.test.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.test.ts @@ -259,16 +259,21 @@ describe('takeNewSessionSeed', () => { rememberNavRoute('/flows/edit/u/me/my_flow?workspace=ws') expect(takeNewSessionSeed()).toEqual({ url: '/flows/edit/u/me/my_flow?workspace=ws', - route: { kind: 'flow', raw_app: false, itemPath: 'u/me/my_flow', mode: 'edit' } + kind: 'flow', + path: 'u/me/my_flow' }) // A dismissed or taken offer is not repeated until the user leaves again. expect(takeNewSessionSeed()).toBeUndefined() rememberNavRoute('/apps_raw/edit/f/team/dashboard?workspace=ws') - expect(takeNewSessionSeed()?.route).toEqual({ - kind: 'app', - raw_app: true, - itemPath: 'f/team/dashboard', - mode: 'edit' + expect(takeNewSessionSeed()).toMatchObject({ kind: 'app', path: 'f/team/dashboard' }) + }) + + it('offers the pipeline the user came from, named by its folder', () => { + rememberNavRoute('/pipeline/crm?workspace=ws') + expect(takeNewSessionSeed()).toEqual({ + url: '/pipeline/crm?workspace=ws', + kind: 'pipeline', + path: 'f/crm' }) }) diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 8ee5391443..56952eb584 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -245,6 +245,8 @@ // Undefined wherever the workspace is: `/api/mcp/w/undefined/mcp` reads like a real URL // and is copyable, so the OAuth panel withholds it rather than showing a broken one. The // token branch guards the same case by disabling its generate button. + // On the browsing origin, not the API base url: the OAuth metadata the server + // publishes for this URL is built from `base_url`, and a client rejects a mismatch. const mcpUrl = $derived( isAllWorkspaces ? `${window.location.origin}/api/mcp/gateway` diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index b96ab5e22b..06c490426a 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -1,7 +1,6 @@ - - - {#snippet children({ createMenu })} - (showExtraTriggers = false)} - > - {#snippet triggr({ trigger, pinned })} - {@const iconColor = getContrastTextColor($workspaceColor)} - -
- -
- {/snippet} - {#snippet children({ item, builders })} -
- {#each favoriteLinks ?? [] as favorite (favorite.href)} - - - {#if favorite.kind == 'script'} - - {:else if favorite.kind == 'flow'} - - {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} - - {:else if favorite.kind == 'asset'} - - {/if} - - - {favorite.label} - - - {/each} -
- - {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - -
-
- - - Account settings - - - - - Take the tour - -
- -
- { - if (!document.documentElement.classList.contains('dark')) { - document.documentElement.classList.add('dark') - window.localStorage.setItem('dark-mode', 'dark') - } else { - document.documentElement.classList.remove('dark') - window.localStorage.setItem('dark-mode', 'light') - } - }} - lightMode - class={twMerge( - 'w-full flex gap-3.5 px-2 py-2', - 'transition-colors', - sidebarClasses.text, - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - {#if darkMode} - - {:else} - - {/if} - Switch theme - - - - {#if $superadmin} - - - Instance settings - - {/if} - - logout()} - class={twMerge( - 'flex flex-row gap-3.5 items-center px-2 py-2 w-full', - 'text-primary text-xs', - 'cursor-pointer', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - - Sign out - -
-
- {#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])} - {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {menuLink.label} - - {/each} - {/snippet} - {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} -
- {#if secondMenuLinks.length}
{@render renderSecondMenuLinks(secondMenuLinks)}
{/if} - {#if secondMenuTriggerLinks.length}
{@render renderSecondMenuLinks(secondMenuTriggerLinks)}
{/if} - {#if extraTriggerLinks.length}
- -
{ - // This row expands the list below it instead of acting on the selection, and - // melt keeps the menu open only for a click it sees as defaultPrevented. - // Svelte delegates onclick to the root, which runs after melt's own listener, - // and a capture listener on the item itself would be ordered only by - // registration, so an ancestor's capture phase is what reliably wins. - e.preventDefault() - showExtraTriggers = !showExtraTriggers - }} - > - - - More triggers - -
- {#if showExtraTriggers} - {#each extraTriggerLinks as menuLink (menuLink.href)} - - {menuLink.label} - - {/each} - {/if} -
{/if} -
- {/if} - {#if $enterpriseLicense} - - {/if} -
-
- {/snippet} -
- {/snippet} -
- - diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index e0951f37cd..95dc03992c 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -18,9 +18,9 @@ Crown, Gauge, Trash2, - KeyRound + KeyRound, + GraduationCap } from 'lucide-svelte' - import { base } from '$app/paths' import { goto } from '$lib/navigation' import { type Item } from '$lib/utils' import { logout } from '$lib/logoutKit' @@ -38,6 +38,8 @@ import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings' import { accountSetup } from './accountSetup.svelte' import { EXECUTIONS_HINT } from './executionsHint' + import { sidebarPageAllowed } from './operatorRoutes' + import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import { userWorkspaces, workspaceStore, @@ -189,12 +191,12 @@ icon: Building, // The `workspace` query param switches the app to the target // workspace on arrival (full loads and client-side navs alike). - href: `${base}/workspace_settings?workspace=${workspaceSettingsTarget}` + href: `/workspace_settings?workspace=${workspaceSettingsTarget}` } : { displayName: `${currentWs?.name ?? $workspaceStore ?? 'Workspace'} settings`, icon: Building, - href: `${base}/workspace_settings` + href: '/workspace_settings' } ] : []), @@ -248,6 +250,15 @@ icon: Settings, action: () => goto(USER_SETTINGS_HASH) }, + ...($userStore?.operator + ? [ + { + displayName: 'Take the tour', + icon: GraduationCap, + action: () => goto(`/?${TOUR_PARAM}=${TOUR_PARAM_VALUE}`) + } + ] + : []), ...(cloudHosted && $isPremiumStore === false ? [ { @@ -265,9 +276,9 @@ ]) const logsItems = $derived([ - { displayName: 'Audit logs', icon: Eye, href: `${base}/audit_logs` }, + { displayName: 'Audit logs', icon: Eye, href: '/audit_logs' }, ...($devopsRole - ? [{ displayName: 'Service logs', icon: Logs, href: `${base}/service_logs` }] + ? [{ displayName: 'Service logs', icon: Logs, href: '/service_logs' }] : []), ...($enterpriseLicense ? [ @@ -305,8 +316,9 @@ w.id === $workspaceStore)) + function allows(key: OperatorPageKey) { + return sidebarPageAllowed($userStore?.operator, currentWs, key) + } + const itemClass = twMerge( 'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' ) - let mainMenuLinks = $derived( - [ - { - label: 'Home', - href: `${base}/`, - icon: Home, - aiId: 'sidebar-menu-link-home', - aiDescription: - "Button to navigate to home which contains all the user's scripts, flows and apps" - }, - { - label: 'Runs', - href: `${base}/runs`, - icon: Play, - aiId: 'sidebar-menu-link-runs', - aiDescription: 'Button to navigate to runs', - onclick: () => { - setTimeout(() => { - window.dispatchEvent(new Event('popstate')) - }, 100) - } - }, - { - label: 'Variables', - href: `${base}/variables`, - icon: DollarSign, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-variables', - aiDescription: 'Button to navigate to variables' - }, - { - label: 'Resources', - href: `${base}/resources`, - icon: Boxes, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-resources', - aiDescription: 'Button to navigate to resources' - }, - { - label: 'Assets', - href: `${base}/assets`, - icon: Pyramid, - aiId: 'sidebar-menu-link-assets', - aiDescription: 'Button to navigate to assets' - }, - { - label: 'Folders', - href: `${base}/folders`, - icon: FolderOpen, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-folders', - aiDescription: 'Button to navigate to folders' - }, - { - label: 'Groups', - href: `${base}/groups`, - icon: Users, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-groups', - aiDescription: 'Button to navigate to groups' + // Labels, paths and icons come from the shared page table, so the nav and the page header's + // breadcrumb always call a page the same thing. Everything else here is nav-only. + const MAIN_MENU_EXTRAS: Record< + string, + { aiId: string; aiDescription: string; onclick?: () => void } + > = { + Home: { + aiId: 'sidebar-menu-link-home', + aiDescription: + "Button to navigate to home which contains all the user's scripts, flows and apps" + }, + Runs: { + aiId: 'sidebar-menu-link-runs', + aiDescription: 'Button to navigate to runs', + onclick: () => { + setTimeout(() => { + window.dispatchEvent(new Event('popstate')) + }, 100) } - ].filter((l) => !excludeMainLabels.includes(l.label)) + }, + Variables: { + aiId: 'sidebar-menu-link-variables', + aiDescription: 'Button to navigate to variables' + }, + Resources: { + aiId: 'sidebar-menu-link-resources', + aiDescription: 'Button to navigate to resources' + }, + Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' }, + Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' }, + Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' } + } + let mainMenuLinks = $derived( + NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS) + .map((p) => ({ + label: p.label, + href: p.path === '/' ? `/` : `${p.path}`, + icon: p.icon, + disabled: p.operatorKey ? !allows(p.operatorKey) : false, + ...MAIN_MENU_EXTRAS[p.label] + })) + .filter((l) => !excludeMainLabels.includes(l.label)) ) let defaultExtraTriggerLinks = $derived([ { label: 'HTTP', href: '/routes', icon: Route, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'http', aiId: 'sidebar-menu-link-http', aiDescription: 'Button to navigate to HTTP routes' @@ -268,7 +247,7 @@ label: 'WebSockets', href: '/websocket_triggers', icon: Unplug, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'ws', aiId: 'sidebar-menu-link-ws', aiDescription: 'Button to navigate to websocket triggers' @@ -277,7 +256,7 @@ label: 'Postgres', href: '/postgres_triggers', icon: Database, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'postgres', aiId: 'sidebar-menu-link-postgres', aiDescription: 'Button to navigate to Postgres triggers' @@ -286,7 +265,7 @@ label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'), href: '/kafka_triggers', icon: KafkaIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'kafka', aiId: 'sidebar-menu-link-kafka', aiDescription: 'Button to navigate to Kafka triggers' @@ -295,7 +274,7 @@ label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/nats_triggers', icon: NatsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'nats', aiId: 'sidebar-menu-link-nats', aiDescription: 'Button to navigate to NATS triggers' @@ -304,7 +283,7 @@ label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/sqs_triggers', icon: AwsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'sqs', aiId: 'sidebar-menu-link-sqs', aiDescription: 'Button to navigate to SQS triggers' @@ -313,7 +292,7 @@ label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'), href: '/gcp_triggers', icon: GoogleCloudIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'gcp', aiId: 'sidebar-menu-link-gcp', aiDescription: 'Button to navigate to GCP Pub/Sub triggers' @@ -322,7 +301,7 @@ label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'), href: '/azure_triggers', icon: AzureIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'azure', aiId: 'sidebar-menu-link-azure', aiDescription: 'Button to navigate to Azure Event Grid triggers' @@ -331,7 +310,7 @@ label: 'MQTT', href: '/mqtt_triggers', icon: MqttIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'mqtt', aiId: 'sidebar-menu-link-mqtt', aiDescription: 'Button to navigate to MQTT triggers' @@ -340,7 +319,7 @@ label: 'AMQP', href: '/amqp_triggers', icon: AmqpIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'amqp', aiId: 'sidebar-menu-link-amqp', aiDescription: 'Button to navigate to AMQP triggers' @@ -349,7 +328,7 @@ label: 'Email', href: '/email_triggers', icon: MailIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'email', aiId: 'sidebar-menu-link-email', aiDescription: 'Button to navigate to Email triggers' @@ -373,9 +352,9 @@ let triggerMenuLinks = $derived([ { label: 'Schedules', - href: `${base}/schedules`, + href: '/schedules', icon: Calendar, - disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator, + disabled: !SIDEBAR_SHOW_SCHEDULES || !allows('schedules'), aiId: 'sidebar-menu-link-schedules', aiDescription: 'Button to navigate to schedules' }, @@ -388,9 +367,12 @@ return !page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind) }) ) + let showAddTriggerMenu = $derived(extraTriggerLinks.length > 0 && allows('triggers')) + let showTriggersGroup = $derived( + showAddTriggerMenu || triggerMenuLinks.some((link) => !link.disabled) + ) // Admins, superadmins, and fork creators reach workspace settings (the latter // for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user). - const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) const canManageWorkspace = $derived( $userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email) ) @@ -420,7 +402,7 @@ ? [ { label: 'Workspace', - href: `${base}/workspace_settings`, + href: '/workspace_settings', icon: FolderCog, aiId: 'sidebar-menu-link-workspace', aiDescription: 'Button to navigate to workspace settings', @@ -458,9 +440,9 @@ }, { label: 'Workers', - href: `${base}/workers`, + href: '/workers', icon: ServerCog, - disabled: $userStore?.operator, + disabled: !allows('workers'), aiId: 'sidebar-menu-link-workers', aiDescription: 'Button to navigate to workers' }, @@ -473,7 +455,7 @@ subItems: [ { label: 'Audit logs', - href: `${base}/audit_logs`, + href: '/audit_logs', icon: Eye, aiId: 'sidebar-menu-link-audit-logs', aiDescription: 'Button to navigate to audit logs' @@ -482,7 +464,7 @@ ? [ { label: 'Service logs', - href: `${base}/service_logs`, + href: '/service_logs', icon: Logs, aiId: 'sidebar-menu-link-service-logs', aiDescription: 'Button to navigate to service logs' @@ -507,9 +489,9 @@ } : { label: 'Audit logs', - href: `${base}/audit_logs`, + href: '/audit_logs', icon: Eye, - disabled: $userStore?.operator, + disabled: !allows('audit_logs'), aiId: 'sidebar-menu-link-audit-logs', aiDescription: 'Button to navigate to audit logs' } @@ -524,6 +506,8 @@ {/each}
+ {/if} + {#if showMain && showTriggersGroup}
{#if isCollapsed}
@@ -551,7 +535,7 @@ {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each} - {#if extraTriggerLinks.length > 0 && !$userStore?.operator} + {#if showAddTriggerMenu} {#snippet triggr({ trigger })} - import { untrack } from 'svelte' - import { melt } from '@melt-ui/svelte' - import type { MenubarMenuBuilders, MenubarMenuElements } from '@melt-ui/svelte' - import { Building, ChevronRight } from 'lucide-svelte' - import { twMerge } from 'tailwind-merge' - import { sidebarClasses } from './MenuButton.svelte' - import WorkspacePickerBody from './WorkspacePickerBody.svelte' - import { MenuItem } from '$lib/components/meltComponents' - import { clearWorkspaceFromStorage } from '$lib/stores' - import { base } from '$lib/base' - - interface Props { - // Submenu builders from the enclosing melt Menu — createSubmenu must be - // called against this specific menu instance (same pattern as DropdownSubmenuItem). - builders: MenubarMenuBuilders - // Melt uses the parent menu's item builder for submenu rows too, so the - // picker body's MenuItems work here unchanged. - item: MenubarMenuElements['item'] - } - - let { builders, item }: Props = $props() - - const { - elements: { subTrigger, subMenu }, - states: { subOpen } - } = untrack(() => builders).createSubmenu() - - - - -{#if $subOpen} -
- - -
- clearWorkspaceFromStorage()} - class={twMerge( - 'w-full flex flex-row gap-2 px-4 py-2 font-normal', - sidebarClasses.text, - 'hover:bg-surface-hover hover:text-primary', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - All workspaces - -
-
-{/if} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index ddcbd9ca95..3902715d5a 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -14,9 +14,19 @@ createMenu: MenubarBuilders['createMenu'] // When used outside of the side bar, where links to workspace settings and such don't make as much sense. strictWorkspaceSelect?: boolean + // Denser trigger for the page header bar: a smaller disc and a lighter name, so the + // workspace reads as the scope of the breadcrumb beside it rather than a heading. + compact?: boolean } - let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props() + let { + isCollapsed = false, + createMenu, + strictWorkspaceSelect = false, + compact = false + }: Props = $props() + + let menu: Menu | undefined = $state(undefined) // The active workspace's family root — shown in the trigger so a forked active workspace still // surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly @@ -29,7 +39,7 @@ const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces)) - + {#snippet triggr({ trigger })} @@ -41,7 +51,7 @@ name={currentFamily?.name ?? $workspaceStore ?? ''} id={currentFamily?.id ?? $workspaceStore ?? ''} disablePopup={isCollapsed} - class="block w-full" + class={compact ? 'block' : 'block w-full'} > @@ -63,6 +75,11 @@ {#snippet children({ item })} - + menu?.close()} + /> {/snippet} diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index fa4468fa6a..3719e35d35 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -1,5 +1,5 @@ -
+
{#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts new file mode 100644 index 0000000000..19568eaddc --- /dev/null +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -0,0 +1,28 @@ +import { fromStore } from 'svelte/store' +import { userStore } from '$lib/stores' +import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + +// One preference per role rather than one per account. The same person is both — an operator in +// one workspace, a developer in the next — and the two want different things from the sidebar, so +// a single key would carry whichever was pressed last into the other role and never give it back. +// Empty until that role presses detach or attach once. +const operatorPref = useLocalStorageValue('nav_detached_operator', '', 'string') +const devPref = useLocalStorageValue('nav_detached_dev', '', 'string') +const user = fromStore(userStore) + +/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */ +export const navDetached = { + get val(): boolean { + // Unasked, operators start detached: many of them navigate through a workspace's own app, + // and a docked sidebar would take its width. + const operator = !!user.current?.operator + const pref = operator ? operatorPref : devPref + if (pref.val === 'true') return true + if (pref.val === 'false') return false + return operator + }, + set val(detached: boolean) { + const pref = user.current?.operator ? operatorPref : devPref + pref.val = String(detached) + } +} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts new file mode 100644 index 0000000000..8648961ef6 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -0,0 +1,81 @@ +import { BAR_HEIGHT_PX } from '../pageHeaderRegistry.svelte' + +// Opening the detached sidebar's card, from the band's own handle or from the corner control that +// stands in for it on a page which hides the band. +let opener: (() => void) | undefined +// Closing it once the pointer is off both the handle and the card, which the layout owns because +// the card is its markup. +let hoverClose: { schedule: () => void; cancel: () => void } | undefined +type CardState = { + isOpen: () => boolean + isPinned: () => boolean + pin: () => void + close: () => void +} +let card: CardState | undefined + +// Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than +// the card starting at whatever height the control that opened it happens to have. Measured at open +// time rather than fixed, so the card follows the header's own height. On a page that hides the +// header, the header is still what the card will sit under — it comes down with it — so its height +// is read even while it is off-screen. The fallback matches the header's resting height. +const FALLBACK_CARD_TOP = BAR_HEIGHT_PX +const GAP_UNDER_HANDLE = 6 +let cardTop = $state(FALLBACK_CARD_TOP) + +export const navHandleSlot = { + /** Distance from the top of the viewport to the card's top edge, in px. */ + get cardTop(): number { + return cardTop + }, + /** The card is open and pinned, so the handle shows as selected. Reactive. */ + get pinned(): boolean { + return !!card?.isOpen() && card.isPinned() + }, + /** The root layout owns the card, so it registers how to open, pin, close and hover-close it. */ + setOpener( + open: () => void, + close?: { schedule: () => void; cancel: () => void }, + state?: CardState + ) { + opener = open + hoverClose = close + card = state + }, + /** + * A click on the handle pins the card, opening it first if it is closed: a pinned card stays + * when the pointer leaves, until a press on the page, Esc, or the next click here closes it. A + * card the pointer opened is not pinned yet, so the click that follows the hover pins it — and + * a touch tap, which sends that mouseenter just before its click, pins it too. + */ + toggle(anchor?: Element | null) { + if (card?.isOpen() && card.isPinned()) { + card.close() + return + } + if (!card?.isOpen()) this.open(anchor) + hoverClose?.cancel() + card?.pin() + }, + /** Pointer left the handle: close, unless it lands on the card before the delay is up. */ + scheduleClose() { + hoverClose?.schedule() + }, + /** + * Opens the card under the page header, or under `anchor` when there is no header to sit + * beneath — an embed, where whatever reached for the nav is all the card has to hang from. + */ + open(anchor?: Element | null) { + hoverClose?.cancel() + const band = document.querySelector('[data-page-header]') + const bandHeight = band?.getBoundingClientRect().height + if (bandHeight) { + cardTop = Math.round(bandHeight) + } else { + const bottom = anchor?.getBoundingClientRect().bottom + cardTop = + bottom != null && bottom > 0 ? Math.round(bottom) + GAP_UNDER_HANDLE : FALLBACK_CARD_TOP + } + opener?.() + } +} diff --git a/frontend/src/lib/components/sidebar/navPages.test.ts b/frontend/src/lib/components/sidebar/navPages.test.ts new file mode 100644 index 0000000000..e2e849ac6b --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { navPageFor } from './navPages' + +describe('navPageFor', () => { + it('keeps an item route on its page', () => { + expect(navPageFor('/runs/f/demo/x')?.label).toBe('Runs') + }) + + it('does not let Home swallow every route', () => { + expect(navPageFor('/audit_logs')?.label).toBe('Audit logs') + expect(navPageFor('/')?.label).toBe('Home') + }) + + it('falls back to the first segment for a route the table does not name', () => { + expect(navPageFor('/service_logs/x')).toMatchObject({ + label: 'Service logs', + path: '/service_logs' + }) + }) +}) diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts new file mode 100644 index 0000000000..385b93abcc --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -0,0 +1,67 @@ +import { + Boxes, + Calendar, + DollarSign, + Eye, + FolderOpen, + Home, + HardHat, + Play, + Pyramid, + Unplug, + Users +} from 'lucide-svelte' +import type { OperatorPageKey } from './operatorRoutes' + +/** + * The names the page header's breadcrumb gives the workspace's own pages, and the rows the + * sidebar's main group is built from. + * + * Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries + * `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, + * HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so + * for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by + * hand. + */ +export type NavPage = { + label: string + /** Path under `base`, matched against the current route by longest prefix. */ + path: string + icon: any + /** The `operator_settings` key that admits an operator, when the page has one. */ + operatorKey?: OperatorPageKey +} + +export const NAV_PAGES: NavPage[] = [ + { label: 'Home', path: '/', icon: Home }, + { label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' }, + { label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' }, + { label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' }, + { label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' }, + { label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' }, + { label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' }, + { label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' }, + // "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP. + { label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' }, + { label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' }, + { label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' } +] + +/** + * The page a route belongs to. Longest match wins, so `/runs/f/demo/x` is still Runs, and a route + * the table does not name falls back to its first segment (`/service_logs` → "Service logs"). + */ +export function navPageFor(path: string): NavPage | undefined { + const match = NAV_PAGES.filter( + (p) => p.path === path || (p.path !== '/' && path.startsWith(p.path + '/')) + ).sort((a, b) => b.path.length - a.path.length)[0] + if (match) return match + const first = path.split('/').filter(Boolean)[0] + if (!first) return undefined + const label = first.replace(/_/g, ' ') + return { + label: label.charAt(0).toUpperCase() + label.slice(1), + path: `/${first}`, + icon: undefined + } +} diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts new file mode 100644 index 0000000000..bf14926d00 --- /dev/null +++ b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { sidebarPageAllowed } from './operatorRoutes' +import type { UserWorkspace } from '$lib/stores' + +const ws = (operator_settings: any): UserWorkspace => + ({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace + +describe('sidebarPageAllowed', () => { + it('shows every page to anyone who is not an operator', () => { + expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true) + expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true) + }) + + it('admits an operator only where the workspace turned the page on', () => { + expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true) + expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false) + }) + + it('withholds a page the settings do not mention', () => { + // `=== true`, not truthiness: a right absent from the jsonb, or settings that were never + // written, must not read as granted. + expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts index 1abd7aeb93..6824c36c62 100644 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ b/frontend/src/lib/components/sidebar/operatorRoutes.ts @@ -1,61 +1,41 @@ -import { base } from '$lib/base' +import type { OperatorSettings } from '$lib/gen' import type { UserWorkspace } from '$lib/stores' /** - * The pages an operator can reach, each paired with the `operator_settings` key - * that admits them — the operator menu lists only the entries the active - * workspace permits. + * An `operator_settings` key that admits an operator to one page (`triggers` to all of them). + * + * Only the visibility flags: the same object also carries the write rights `builder_flows`, + * `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A + * write right is granted unless withdrawn, so its absence means "never configured" rather than + * "no" — see `docs/operator-write-rights.md`. */ -export type OperatorMenuLink = { - label: string - /** `operator_settings` key gating the page. */ - id: string - href: string +export type OperatorPageKey = Extract< + keyof NonNullable, + | 'runs' + | 'schedules' + | 'resources' + | 'variables' + | 'assets' + | 'audit_logs' + | 'triggers' + | 'groups' + | 'folders' + | 'workers' +> + +/** + * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page + * (other gates, such as admin-only entries, still apply); an operator sees only the pages the + * workspace's `operator_settings` turn on. + */ +export function sidebarPageAllowed( + isOperator: boolean | undefined, + workspace: UserWorkspace | undefined, + key: OperatorPageKey +): boolean { + return !isOperator || workspace?.operator_settings?.[key] === true } -export type OperatorTriggerLink = OperatorMenuLink & { kind: string } - -/** Home needs no setting — it is the one page every operator always has. */ -export const OPERATOR_MAIN_LINKS: OperatorMenuLink[] = [ - { label: 'Home', id: 'home', href: `${base}/` }, - { label: 'Runs', id: 'runs', href: `${base}/runs` }, - { label: 'Schedules', id: 'schedules', href: `${base}/schedules` } -] - -export const OPERATOR_SECONDARY_LINKS: OperatorMenuLink[] = [ - { label: 'Resources', id: 'resources', href: `${base}/resources` }, - { label: 'Variables', id: 'variables', href: `${base}/variables` }, - { label: 'Assets', id: 'assets', href: `${base}/assets` }, - { label: 'Groups', id: 'groups', href: `${base}/groups` }, - { label: 'Folders', id: 'folders', href: `${base}/folders` }, - { label: 'Workers', id: 'workers', href: `${base}/workers` }, - { label: 'Audit logs', id: 'audit_logs', href: `${base}/audit_logs` } -] - -export const OPERATOR_TRIGGER_LINKS: OperatorTriggerLink[] = [ - { label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' }, - { label: 'Websocket triggers', id: 'triggers', href: `${base}/websocket_triggers`, kind: 'ws' }, - { - label: 'Postgres triggers', - id: 'triggers', - href: `${base}/postgres_triggers`, - kind: 'postgres' - }, - { label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' }, - { label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' }, - { label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' }, - { label: 'GCP Pub/Sub triggers', id: 'triggers', href: `${base}/gcp_triggers`, kind: 'gcp' }, - { - label: 'Azure Event Grid triggers', - id: 'triggers', - href: `${base}/azure_triggers`, - kind: 'azure' - }, - { label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' }, - { label: 'AMQP triggers', id: 'triggers', href: `${base}/amqp_triggers`, kind: 'amqp' }, - { label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' } -] - /** * Whether the user is an operator in `workspace`. The server nulls * `operator_settings` for a non-operator, so a non-null value narrows an operator diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts index df8e668fba..24925f2198 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts @@ -46,6 +46,43 @@ describe('workspaceMenuHref', () => { ).toBe('/runs?workspace=w') }) + // The compare page reads the workspace it compares from `workspace_id`, so a switch that + // left it behind sent the page back to the workspace just left on the next reload. + it('swaps workspace_id alongside workspace', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + + it('does not add workspace_id to a page that had none', () => { + expect( + workspaceMenuHref({ + pathname: '/runs', + searchParams: new URLSearchParams('job=123'), + id: 'w', + sameFamily: true + }) + ).toBe('/runs?job=123&workspace=w') + }) + + // `target` is the compare page's one-off migration destination, picked for the workspace + // being left. Carried over it can point the new workspace at itself. + it('drops an arbitrary compare target', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&target=wm-fork-b&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + // An operator lands on home in the workspace they switch into. The href has to carry // that too, not just the click handler: this is what a middle-click opens. it('points at home, dropping the current page and its params, when landOnHome is set', () => { diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts index 57342f2b08..707104955d 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts @@ -1,5 +1,3 @@ -import { base } from '$lib/base' - // Href for a workspace-switch link in the sidebar WorkspaceMenu: stay on the // current path and just swap the `workspace` query param, so a modifier/middle // click (open in new tab, which bypasses the onClick fast-path) lands on the @@ -20,10 +18,21 @@ export function workspaceMenuHref(args: { landOnHome?: boolean }): string { if (args.landOnHome) { - return `${base}/?workspace=${encodeURIComponent(args.id)}` + return `/?workspace=${encodeURIComponent(args.id)}` } const params = new URLSearchParams(args.searchParams) params.set('workspace', args.id) + // `workspace_id` names the workspace a page acts on rather than the one the app is pointed at + // — the compare page's source. A switch moves it too, or the link lands on a page still + // working on the workspace just left. Only rewritten, never added: a page that does not use + // it has no business gaining it. + if (params.has('workspace_id')) { + params.set('workspace_id', args.id) + } + // The compare page's one-off migration destination, chosen for the workspace being left. Kept, + // it would survive into a workspace it was never picked for — including the target itself, + // leaving that page comparing a workspace with itself. + params.delete('target') if (!args.sameFamily) { params.delete('session') params.delete('session_name') diff --git a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts index 82130e3f95..c8bc6d070c 100644 --- a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts +++ b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts @@ -1,13 +1,20 @@ import { page } from '$app/state' -import { replaceState } from '$app/navigation' import { goto } from '$lib/navigation' // Post-workspace-switch URL fixup shared by the sidebar workspace pickers. // Item-scoped pages would show a wrong-workspace (or missing) item after a -// switch — go home instead. Otherwise, really rewrite a ?workspace= param in -// the address bar (mutating page.url is a no-op there): left stale, it gets -// re-applied on reload or when exiting session mode restores the route, +// switch — go home instead. Otherwise, really rewrite the params in the address +// bar that name a workspace (mutating page.url is a no-op there): left stale, +// they get re-applied on reload or when exiting session mode restores the route, // silently switching the workspace back. +// +// `workspace_id` names the workspace a page acts on (the compare page's pair) and +// `workspace` the one the app is pointed at; a switch moves both. `target`, the +// compare page's one-off migration destination, is dropped instead of rewritten: +// it was chosen for the workspace being left, and keeping it could point the new +// one at itself. +const WORKSPACE_PARAMS = ['workspace', 'workspace_id'] +const STALE_AFTER_SWITCH = ['target'] const EDIT_PAGES = [ '/scripts/edit/', '/flows/edit/', @@ -28,9 +35,32 @@ export async function fixupUrlAfterWorkspaceSwitch( ): Promise { if (EDIT_PAGES.some((p) => page.route.id?.includes(p) ?? false) || opts?.landOnHome) { await goto('/') - } else if (page.url.searchParams.get('workspace')) { - const url = new URL(window.location.href) - url.searchParams.set('workspace', id) - replaceState(url, page.state) + return } + // `window.location`, never `page.url`: a param written by a previous shallow update + // is in the address bar but not in `page.url`, so rebuilding from it would undo that + // write (see the note on `setQuery` in $lib/navigation). + const url = new URL(window.location.href) + let changed = false + for (const key of WORKSPACE_PARAMS) { + if (url.searchParams.get(key) === id) continue + if (!url.searchParams.has(key)) continue + url.searchParams.set(key, id) + changed = true + } + for (const key of STALE_AFTER_SWITCH) { + if (!url.searchParams.has(key)) continue + url.searchParams.delete(key) + changed = true + } + if (!changed) return + // `goto` rather than `replaceState`: a page reading these params through `page.url` + // (the compare page derives the workspace it compares from them) sees nothing when + // they are written shallowly, since SvelteKit refreshes `page.url` only on a + // router-driven navigation. + await goto(`${url.pathname}${url.search}${url.hash}`, { + replaceState: true, + noScroll: true, + keepFocus: true + }) } diff --git a/frontend/src/lib/components/triggers/CaptureSection.svelte b/frontend/src/lib/components/triggers/CaptureSection.svelte index 51382e1a62..4fa2fab5fb 100644 --- a/frontend/src/lib/components/triggers/CaptureSection.svelte +++ b/frontend/src/lib/components/triggers/CaptureSection.svelte @@ -31,7 +31,6 @@ import { formatDateShort } from '$lib/utils' import DisplayResult from '$lib/components/DisplayResult.svelte' import DisplayResultControlBar from '$lib/components/DisplayResultControlBar.svelte' - import { base } from '$lib/base' import Description from '$lib/components/Description.svelte' import { twMerge } from 'tailwind-merge' import { FlaskConical } from 'lucide-svelte' @@ -463,7 +462,6 @@ {#if displayResult && toolbarLocation === 'external'} { diff --git a/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte b/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte index ab8b850fa2..b59a925c84 100644 --- a/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte +++ b/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte @@ -10,7 +10,7 @@ import type { TriggerMode } from '$lib/gen' import TriggerModeToggle from './TriggerModeToggle.svelte' import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' - import { stripBase, TRIGGER_PAGES, SCHEDULES_PATH } from '$lib/components/sessions/previewPaths' + import { barePath, TRIGGER_PAGES, SCHEDULES_PATH } from '$lib/components/sessions/previewPaths' import { pageDrawerSessionSource } from '../sessions/pageDrawerSession' import { page } from '$app/state' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -40,6 +40,7 @@ * editor has to pass it: every other editor renders with a `trigger`, * whose `type` is the same value. */ triggerKind?: TriggerType + saveLabel?: string } let { @@ -60,7 +61,8 @@ suspendedJobsModal, disableSuspendedMode = false, triggerPath, - triggerKind + triggerKind, + saveLabel = 'Save' }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const wsId = $derived($operatingWorkspace) @@ -72,7 +74,7 @@ // when it renders in a script/flow editor's Triggers panel, which has the // editor's own button. A trigger being created has no path to deep-link at. const triggerPagePath = $derived.by(() => { - const route = stripBase(page.url.pathname) + const route = barePath(page.url.pathname) if (route === SCHEDULES_PATH) return route return Object.values(TRIGGER_PAGES).some((p) => p.path === route) ? route : undefined }) @@ -120,7 +122,7 @@ }} loading={isLoading} > - Save + {saveLabel} {/if} {:else} diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte index 3a529b7597..1ccbd21d0e 100644 --- a/frontend/src/lib/components/triggers/TriggerList.svelte +++ b/frontend/src/lib/components/triggers/TriggerList.svelte @@ -1,6 +1,5 @@ diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte index 7346e285f6..7295ca0c35 100644 --- a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte @@ -11,8 +11,8 @@ import Path from '$lib/components/Path.svelte' import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' @@ -22,7 +22,6 @@ type AmqpExchange, type AmqpOptions, type Retry, - type ErrorHandler, type TriggerMode } from '$lib/gen' import AmqpEditorConfigSection from './AmqpEditorConfigSection.svelte' @@ -30,7 +29,8 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveAmqpTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -46,7 +46,7 @@ useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() interface Props { useDrawer?: boolean @@ -112,8 +112,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) let showLoading = $state(false) @@ -126,9 +127,10 @@ let permissionedAs = $state(undefined) let selectedPermissionedAs = $state(undefined) let preservePermissionedAs = $state(false) - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let suspendedJobsModal = $state(null) let originalConfig = $state | undefined>(undefined) @@ -172,14 +174,20 @@ is_flow = itemKind === 'flow' }) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultConfig?: Record, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -212,8 +220,14 @@ export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - defaultValues?: Record + defaultValues?: Record, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft let loadingTimeout = setTimeout(() => { showLoading = true }, 100) @@ -235,14 +249,18 @@ edit = false dirtyPath = false mode = defaultValues?.mode ?? 'enabled' - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig) } finally { clearTimeout(loadingTimeout) drawerLoading = false @@ -266,10 +284,13 @@ mode = cfg?.mode ?? 'enabled' permsPath = cfg?.path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as preservePermissionedAs = !!cfg?.permissioned_as @@ -335,6 +356,11 @@ } async function updateTrigger(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return + drawer?.closeDrawer() + return + } deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() @@ -431,7 +457,9 @@ ? can_write ? `Edit AMQP trigger ${initialPath}` : `AMQP trigger ${initialPath}` - : 'New AMQP trigger'} + : saveDraftHandler + ? 'Draft AMQP trigger' + : 'New AMQP trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()} @@ -489,6 +517,7 @@ isLoading={deploymentLoading} {isDeployed} onUpdate={updateTrigger} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} onToggleMode={handleToggleMode} @@ -645,9 +674,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry /> {/if} diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 4394a5878f..e0c2008be8 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -9,18 +9,12 @@ import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import { - AzureTriggerService, - type AzureMode, - type Retry, - type ErrorHandler, - type TriggerMode - } from '$lib/gen' + import { AzureTriggerService, type AzureMode, type Retry, type TriggerMode } from '$lib/gen' import Section from '$lib/components/Section.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Required from '$lib/components/Required.svelte' @@ -29,13 +23,14 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveAzureTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' - import { base } from '$lib/base' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -45,7 +40,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() let drawer: Drawer | undefined = $state(undefined) let initialPath = $state('') @@ -65,8 +60,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) @@ -83,12 +79,13 @@ let permissionedAs = $state(undefined) let selectedPermissionedAs = $state(undefined) let preservePermissionedAs = $state(false) - let base_endpoint = $derived(`${window.location.origin}${base}`) + let base_endpoint = $derived(apiBaseUrl()) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let suspendedJobsModal = $state(null) let originalConfig = $state | undefined>(undefined) @@ -160,6 +157,7 @@ ) { // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() drawerLoading = true try { drawer?.openDrawer() @@ -209,10 +207,13 @@ edit = false dirtyPath = false mode = defaultValues?.mode ?? 'enabled' - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false @@ -265,10 +266,13 @@ mode = cfg?.mode ?? 'enabled' permsPath = cfg?.path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as preservePermissionedAs = !!cfg?.permissioned_as @@ -320,7 +324,9 @@ topic_name, subscription_name, event_type_filters, - base_endpoint, + // The capture URL shown to the user stays on the browsing origin, so the + // endpoint registered for it must too. + base_endpoint: window.location.origin, path } } @@ -566,9 +572,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry />
diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditor.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditor.svelte index 101b84f770..57ed70f6e7 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditor.svelte @@ -27,6 +27,21 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } + /** Edit a trigger that exists only as the caller's draft; Save hands the config back. */ + export async function openDraft( + scriptPath: string, + defaults: Record, + saved: Record | undefined, + onSaveDraft: (cfg: Record) => boolean + ) { + open = true + await tickPainted() + drawer?.openNew(false, scriptPath, defaults as any, { + onSaveDraft, + draftConfig: saved + }) + } + let drawer: EmailTriggerEditorInner | undefined = $state() diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 042b914b93..38a8243855 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -11,22 +11,22 @@ import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte' import { EmailTriggerService, - type ErrorHandler, type EmailTrigger, type NewEmailTrigger, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import EmailTriggerEditorConfigSection from './EmailTriggerEditorConfigSection.svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' - import { getHandlerType, handleConfigChange } from '../utils' + import { handleConfigChange } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import { untrack } from 'svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' @@ -43,7 +43,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() let { useDrawer = true, @@ -91,12 +91,15 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let extraPerms = $state | undefined>(undefined) - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let mode = $state('enabled') // Component references @@ -107,7 +110,6 @@ let selectedPermissionedAs = $state(undefined) let preservePermissionedAs = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') let suspendedJobsModal = $state(null) let originalConfig = $state(undefined) @@ -139,14 +141,20 @@ is_flow = itemKind === 'flow' }) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultConfig?: Partial, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() drawerLoading = true let loader = setTimeout(() => { showLoader = true @@ -188,8 +196,14 @@ export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - defaultValues?: Partial + defaultValues?: Partial, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft drawerLoading = true let loader = setTimeout(() => { showLoader = true @@ -209,15 +223,19 @@ dirtyPath = false workspaced_local_part = defaultValues?.workspaced_local_part ?? false - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = defaultValues?.mode ?? 'enabled' permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig as Partial) } finally { clearTimeout(loader) drawerLoading = false @@ -237,10 +255,13 @@ extraPerms = cfg?.extra_perms ?? undefined permsPath = path permsForWrite = cfg?.extra_perms ?? {} - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as @@ -276,6 +297,11 @@ } async function triggerScript(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getEmailTriggerConfig()))) return + drawer?.closeDrawer() + return + } if (customSaveBehavior) { customSaveBehavior(emailConfig) drawer?.closeDrawer() @@ -476,9 +502,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry />
@@ -502,6 +526,7 @@ {edit} isLoading={deploymentLoading} onUpdate={triggerScript} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} {isDeployed} @@ -521,7 +546,9 @@ ? can_write ? `Edit email trigger ${initialPath}` : `Email trigger ${initialPath}` - : 'New email trigger'} + : saveDraftHandler + ? 'Draft email trigger' + : 'New email trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()} diff --git a/frontend/src/lib/components/triggers/gcp/GcpCapture.svelte b/frontend/src/lib/components/triggers/gcp/GcpCapture.svelte index a77d12ebef..b6ba7bfa28 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpCapture.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpCapture.svelte @@ -3,7 +3,6 @@ import CaptureSection from '../CaptureSection.svelte' import { Url } from '$lib/components/common' import { fade } from 'svelte/transition' - import { base } from '$lib/base' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -32,7 +31,7 @@ if (!captureInfo) { return } - return `${window.location.origin}${base}/api/w/${$operatingWorkspace}/capture_u/gcp/${ + return `${window.location.origin}/api/w/${$operatingWorkspace}/capture_u/gcp/${ captureInfo.isFlow ? 'flow' : 'script' }/${captureInfo.path}` } diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte index f86706e8ab..9c3111b0fb 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditor.svelte @@ -22,6 +22,21 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } + /** Edit a trigger that exists only as the caller's draft; Save hands the config back. */ + export async function openDraft( + scriptPath: string, + defaults: Record, + saved: Record | undefined, + onSaveDraft: (cfg: Record) => boolean + ) { + open = true + await tickPainted() + drawer?.openNew(false, scriptPath, defaults as any, { + onSaveDraft, + draftConfig: saved + }) + } + let drawer: GcpTriggerEditorInner | undefined = $state() diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte index d7868a650a..a33573a1d1 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte @@ -13,7 +13,7 @@ } from '$lib/gen' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { base } from '$lib/base' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import Toggle from '$lib/components/Toggle.svelte' import { Button, Url } from '$lib/components/common' @@ -28,23 +28,17 @@ useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - // Declared before `DEFAULT_PUSH_CONFIG` / the `base_endpoint` prop default, - // which call `getBaseUrl()` (a `wsId` reader) during component init. const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) const wsId = $derived($operatingWorkspace) + const base_endpoint = $derived(`${apiBaseUrl()}/api/gcp/w/${wsId!}`) let topic_items: string[] = $state([]) let subscription_items: string[] = $state([]) let loadingTopic = $state(false) let loadingSubscription = $state(false) - const DEFAULT_PUSH_CONFIG: PushConfig = { - audience: getBaseUrl(), - authenticate: false - } - async function loadAllPubSubTopicsFromProject() { // Listing is admin-only under application default credentials, and a non-admin viewing an // inherited trigger cannot change the topic anyway, so asking would only raise a 403 toast. @@ -117,7 +111,6 @@ delivery_type?: DeliveryType | undefined delivery_config: PushConfig | undefined subscription_mode?: SubscriptionMode - base_endpoint?: string path?: string showTestingBadge?: boolean cloud_subscription_id?: string @@ -139,7 +132,6 @@ delivery_type = $bindable('pull'), delivery_config = $bindable(), subscription_mode = $bindable('create_update'), - base_endpoint = $bindable(getBaseUrl()), auto_acknowledge_msg = $bindable(true), ack_deadline = $bindable(), path = '', @@ -199,17 +191,9 @@ if (!delivery_type) { delivery_type = 'pull' } else if (delivery_type === 'push' && !delivery_config) { - delivery_config = DEFAULT_PUSH_CONFIG + delivery_config = { audience: base_endpoint, authenticate: false } } }) - function getBaseUrl() { - return `${window.location.origin}${base}/api/gcp/w/${wsId!}` - } - - $effect(() => { - !base_endpoint && (base_endpoint = getBaseUrl()) - }) - $effect(() => { if (emptyStringTrimmed(subscription_id) && !emptyStringTrimmed(path)) { subscription_id = `windmill-${wsId!}-${path.replaceAll('/', '_')}` diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 481395a138..486711bd6c 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -9,8 +9,8 @@ import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -20,7 +20,6 @@ type PushConfig, type SubscriptionMode, type Retry, - type ErrorHandler, type TriggerMode } from '$lib/gen' import Section from '$lib/components/Section.svelte' @@ -30,13 +29,14 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveGcpTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import { deepEqual } from 'fast-equals' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' - import { base } from '$lib/base' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -48,7 +48,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() let drawer: Drawer | undefined = $state(undefined) let initialPath = $state('') @@ -69,8 +69,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) let topic_id: string = $state('') @@ -88,13 +89,14 @@ let permissionedAs = $state(undefined) let selectedPermissionedAs = $state(undefined) let preservePermissionedAs = $state(false) - let base_endpoint = $derived(`${window.location.origin}${base}`) + let base_endpoint = $derived(apiBaseUrl()) let auto_acknowledge_msg = $state(true) let ack_deadline: number | undefined = $state() let optionTabSelected: 'settings' | 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let suspendedJobsModal = $state(null) let originalConfig = $state | undefined>(undefined) @@ -163,14 +165,20 @@ ) const captureConfig = $derived.by(untrack(() => isEditor) ? getGcpCaptureConfig : () => ({})) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultValues?: Record, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() drawerLoading = true try { drawer?.openDrawer() @@ -201,8 +209,14 @@ export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - defaultValues?: Record + defaultValues?: Record, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft drawerLoading = true try { drawer?.openDrawer() @@ -234,16 +248,20 @@ edit = false dirtyPath = false mode = defaultValues?.mode ?? 'enabled' - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined auto_acknowledge_msg = defaultValues?.auto_acknowledge_msg ?? true ack_deadline = defaultValues?.ack_deadline - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig) } finally { drawerLoading = false } @@ -295,18 +313,26 @@ topic_id = cfg?.topic_id ?? '' permsPath = cfg?.path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry auto_acknowledge_msg = cfg?.auto_acknowledge_msg ?? true ack_deadline = cfg?.ack_deadline - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as preservePermissionedAs = !!cfg?.permissioned_as } async function updateTrigger(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getGcpConfig()))) return + drawer?.closeDrawer() + return + } deploymentLoading = true const previousPath = initialPath const cfg = gcpConfig @@ -359,7 +385,9 @@ subscription_id, delivery_type, delivery_config, - base_endpoint, + // The capture URL shown to the user stays on the browsing origin, so the + // endpoint registered for it must too. + base_endpoint: window.location.origin, auto_acknowledge_msg, ack_deadline, topic_id, @@ -441,7 +469,9 @@ ? can_write ? `Edit GCP Pub/Sub trigger ${initialPath}` : `GCP Pub/Sub trigger ${initialPath}` - : 'New GCP Pub/Sub trigger'} + : saveDraftHandler + ? 'Draft GCP Pub/Sub trigger' + : 'New GCP Pub/Sub trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()} @@ -498,6 +528,7 @@ {allowDraft} {isDeployed} onUpdate={updateTrigger} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} onToggleMode={handleToggleMode} @@ -677,9 +708,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry /> {/if} diff --git a/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte b/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte index b0cc491393..024086552c 100644 --- a/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte @@ -15,7 +15,7 @@ import SimpleEditor from '$lib/components/SimpleEditor.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { base } from '$lib/base' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import Label from '$lib/components/Label.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { ClipboardCopy, Download, Trash } from 'lucide-svelte' @@ -136,7 +136,7 @@ obj = { openapi_spec_format: lang, info, - url: `${window.location.origin}${base}`, + url: apiBaseUrl(), http_route_filters: getHttpRouteFilters(), webhook_filters: getWebhookFilters() } @@ -155,7 +155,7 @@ requestBody: { openapi_spec_format, info, - url: `${window.location.origin}${base}`, + url: apiBaseUrl(), http_route_filters: getHttpRouteFilters(), webhook_filters: getWebhookFilters() } @@ -225,7 +225,7 @@ {/snippet} | undefined>(undefined) let summary: string | undefined = $state() let routeDescription: string | undefined = $state() - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() // Component references let s3FilePicker = $state(undefined) @@ -186,7 +183,6 @@ let selectedPermissionedAs = $state(undefined) let preservePermissionedAs = $state(false) let optionTabSelected: 'request_options' | 'error_handler' | 'retries' = $state('request_options') - let errorHandlerSelected: ErrorHandler = $state('slack') let suspendedJobsModal = $state(null) let originalConfig = $state(undefined) @@ -278,6 +274,7 @@ ) { // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() drawerLoading = true let loader = setTimeout(() => { showLoader = true @@ -353,10 +350,13 @@ allowed_origins = defaultValues?.allowed_origins ?? undefined summary = defaultValues?.summary ?? '' routeDescription = defaultValues?.description ?? '' - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false @@ -401,10 +401,13 @@ extraPerms = cfg?.extra_perms ?? undefined permsPath = path permsForWrite = cfg?.extra_perms ?? {} - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as preservePermissionedAs = !!cfg?.permissioned_as @@ -1040,9 +1043,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry /> {/if} diff --git a/frontend/src/lib/components/triggers/http/utils.ts b/frontend/src/lib/components/triggers/http/utils.ts index ed03ef86aa..cc4d366be4 100644 --- a/frontend/src/lib/components/triggers/http/utils.ts +++ b/frontend/src/lib/components/triggers/http/utils.ts @@ -1,4 +1,4 @@ -import { base } from '$lib/base' +import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import { isCloudHosted } from '$lib/cloud' import { random_adj } from '$lib/components/random_positive_adjetive' import type { HttpMethod, NewHttpTrigger } from '$lib/gen' @@ -33,8 +33,7 @@ export function allowedOriginRejection(origin: string): string | undefined { if (origin === '') return 'An origin must not be empty' if (origin.length > MAX_ALLOWED_ORIGIN_LEN) return `'${origin.slice(0, 40)}…' is longer than any origin a browser sends` - if (origin.includes(',')) - return `'${origin}' must not contain a comma, which separates entries` + if (origin.includes(',')) return `'${origin}' must not contain a comma, which separates entries` if (origin.toLowerCase() === 'null') return `'null' is what a sandboxed iframe sends, so it would allow any page that can open one` if (!/^[\x21-\x7e]+$/.test(origin)) @@ -85,8 +84,7 @@ export function allowedOriginWarning(origin: string): string | undefined { if (rest.startsWith(':')) return `'${origin}' has no host` // An unclosed bracket would otherwise leave `portStart` at zero, which reads // as "no port" and lets the entry through unremarked. - if (rest.startsWith('[') && !rest.includes(']')) - return `'${origin}' has an unclosed IPv6 host` + if (rest.startsWith('[') && !rest.includes(']')) return `'${origin}' has an unclosed IPv6 host` const portStart = rest.startsWith('[') ? rest.indexOf(']') + 1 : rest.indexOf(':') // A trailing colon is a port, an empty one — distinct from having none. const port = portStart > 0 && rest[portStart] === ':' ? rest.slice(portStart + 1) : undefined @@ -160,7 +158,7 @@ export function getHttpRoute( workspaced_route: boolean, workspace_id: string ) { - return `${location.origin}${base}/api/${route_prefix}/${ + return `${apiBaseUrl()}/api/${route_prefix}/${ isCloudHosted() || workspaced_route ? workspace_id + '/' : '' }${route_path ?? ''}` } diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte index 73d32c0d52..0769c8ed0e 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditor.svelte @@ -21,6 +21,21 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } + /** Edit a trigger that exists only as the caller's draft; Save hands the config back. */ + export async function openDraft( + scriptPath: string, + defaults: Record, + saved: Record | undefined, + onSaveDraft: (cfg: Record) => boolean + ) { + open = true + await tickPainted() + drawer?.openNew(false, scriptPath, defaults as any, { + onSaveDraft, + draftConfig: saved + }) + } + let drawer: KafkaTriggerEditorInner | undefined = $state() diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 3136f74c47..17a17f98e8 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -10,10 +10,10 @@ import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte' - import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen' + import { KafkaTriggerService, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2, RotateCcw } from 'lucide-svelte' @@ -23,7 +23,8 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveKafkaTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -42,7 +43,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() interface Props { useDrawer?: boolean @@ -108,8 +109,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) let showLoading = $state(false) @@ -126,9 +128,10 @@ let preservePermissionedAs = $state(false) let resetLoading = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let filters: FilterNode[] = $state([]) let filterLogic = $state<'and' | 'or'>('and') @@ -174,14 +177,20 @@ is_flow = itemKind === 'flow' }) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultConfig?: Record, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -217,8 +226,14 @@ export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - nDefaultValues?: Record + nDefaultValues?: Record, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -241,17 +256,21 @@ path = nDefaultValues?.path ?? '' initialPath = '' dirtyPath = false - error_handler_path = nDefaultValues?.error_handler_path ?? undefined - error_handler_args = nDefaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + nDefaultValues?.error_handler_path, + 'script', + nDefaultValues?.error_handler_args + ) retry = nDefaultValues?.retry ?? undefined filters = nDefaultValues?.filters ?? [] filterLogic = 'and' - errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = nDefaultValues?.mode ?? 'enabled' permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig) } finally { clearTimeout(loadingTimeout) drawerLoading = false @@ -277,12 +296,15 @@ extra_perms = cfg?.extra_perms permsPath = path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry filters = cfg?.filters ?? [] filterLogic = cfg?.filter_logic ?? 'and' - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as preservePermissionedAs = !!cfg?.permissioned_as @@ -334,6 +356,11 @@ } async function updateTrigger(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return + drawer?.closeDrawer() + return + } deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() @@ -453,7 +480,9 @@ ? can_write ? `Edit Kafka trigger ${initialPath}` : `Kafka trigger ${initialPath}` - : 'New Kafka trigger'} + : saveDraftHandler + ? 'Draft Kafka trigger' + : 'New Kafka trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()} @@ -511,6 +540,7 @@ {isDeployed} {saveDisabled} onUpdate={updateTrigger} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} onToggleMode={handleToggleMode} @@ -697,9 +727,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry />
diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte index fea56a3750..c8300ee81e 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte @@ -21,6 +21,21 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } + /** Edit a trigger that exists only as the caller's draft; Save hands the config back. */ + export async function openDraft( + scriptPath: string, + defaults: Record, + saved: Record | undefined, + onSaveDraft: (cfg: Record) => boolean + ) { + open = true + await tickPainted() + drawer?.openNew(false, scriptPath, defaults as any, { + onSaveDraft, + draftConfig: saved + }) + } + let drawer: MqttTriggerEditorInner | undefined = $state() diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index d21fb1ff44..5c1fb4aaf0 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -11,8 +11,8 @@ import Path from '$lib/components/Path.svelte' import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' @@ -24,7 +24,6 @@ type MqttV5Config, type MqttSubscribeTopic, type Retry, - type ErrorHandler, type TriggerMode } from '$lib/gen' import MqttEditorConfigSection from './MqttEditorConfigSection.svelte' @@ -32,7 +31,8 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveMqttTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -51,7 +51,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() interface Props { useDrawer?: boolean @@ -118,8 +118,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) let showLoading = $state(false) @@ -134,9 +135,10 @@ let permissionedAs = $state(undefined) let selectedPermissionedAs = $state(undefined) let preservePermissionedAs = $state(false) - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let suspendedJobsModal = $state(null) let originalConfig = $state | undefined>(undefined) @@ -169,14 +171,20 @@ is_flow = itemKind === 'flow' }) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultConfig?: Record, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -212,8 +220,14 @@ export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - defaultValues?: Record + defaultValues?: Record, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft let loadingTimeout = setTimeout(() => { showLoading = true }, 100) @@ -234,10 +248,13 @@ client_version = defaultValues?.client_version ?? 'v5' client_id = defaultValues?.client_id ?? '' mode = defaultValues?.mode ?? 'enabled' - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') v3_config = defaultValues?.v3_config ?? DEFAULT_V3_CONFIG v5_config = defaultValues?.v5_config ?? DEFAULT_V5_CONFIG activateV5Options.topic_alias_maximum = Boolean(defaultValues?.v5_config?.topic_alias_maximum) @@ -248,6 +265,7 @@ selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig) } finally { clearTimeout(loadingTimeout) drawerLoading = false @@ -272,10 +290,13 @@ client_id = cfg?.client_id ?? '' permsPath = cfg?.path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') activateV5Options.topic_alias_maximum = Boolean(v5_config.topic_alias_maximum) activateV5Options.session_expiry_interval = Boolean(v5_config.session_expiry_interval) permissionedAs = cfg?.permissioned_as @@ -347,6 +368,11 @@ } async function updateTrigger(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return + drawer?.closeDrawer() + return + } deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() @@ -427,7 +453,9 @@ ? can_write ? `Edit MQTT trigger ${initialPath}` : `MQTT trigger ${initialPath}` - : 'New MQTT trigger'} + : saveDraftHandler + ? 'Draft MQTT trigger' + : 'New MQTT trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()} @@ -485,6 +513,7 @@ isLoading={deploymentLoading} {isDeployed} onUpdate={updateTrigger} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} onToggleMode={handleToggleMode} @@ -728,9 +757,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry /> {/if} diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte index 3a4be41a04..9e8dc592e9 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -9,8 +9,8 @@ saveNativeTriggerFromCfg } from './utils' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { emptyString, sendUserToast } from '$lib/utils' + import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' import { Button } from '$lib/components/common' import TextInput from '$lib/components/text_input/TextInput.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' @@ -37,6 +37,7 @@ const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) + const editRights = useEditRights() interface Props { service: NativeServiceName @@ -119,9 +120,9 @@ // Derived, not snapshotted at load: the acting user's role arrives on its own schedule, and // a trigger that loaded first would otherwise stay read-only until reopened. const can_write = $derived( - (writeVerdict ?? - (permsScriptPath === undefined || canWrite(permsScriptPath, {}, actingUser))) && - !$triggerLock + writeVerdict === undefined && permsScriptPath !== undefined + ? editRights.canEditItem('trigger', permsScriptPath, {}) + : writeVerdict !== false && editRights.roleCanAuthor('trigger') ) let originalConfig = $state | undefined>(undefined) let initialConfig = $state | undefined>(undefined) diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte index e45f3f9eab..e6bf8928bc 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte @@ -3,9 +3,8 @@ import type { NativeServiceName, TriggerMode } from '$lib/gen/types.gen' import type { ExtendedNativeTrigger } from './utils' import { getServiceConfig } from './utils' - import { canWrite, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' - import { userStore } from '$lib/stores' + import { sendUserToast } from '$lib/utils' + import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import Button from '$lib/components/common/button/Button.svelte' @@ -19,6 +18,7 @@ import GoogleCalendarIcon from '$lib/components/icons/GoogleCalendarIcon.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() + const editRights = useEditRights() const operatingWorkspace = useOperatingWorkspace() @@ -111,7 +111,7 @@ {#each triggers as trigger (trigger.external_id)} {@const isFlow = trigger.is_flow} {@const href = `${isFlow ? '/flows/get' : '/scripts/get'}/${trigger.script_path}`} - {@const canEdit = canWrite(trigger.script_path, {}, $userStore) && !$triggerLock} + {@const canEdit = editRights.canEditItem('trigger', trigger.script_path, {})}
diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte index aa5585a7b6..add8b43d75 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte @@ -21,6 +21,21 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } + /** Edit a trigger that exists only as the caller's draft; Save hands the config back. */ + export async function openDraft( + scriptPath: string, + defaults: Record, + saved: Record | undefined, + onSaveDraft: (cfg: Record) => boolean + ) { + open = true + await tickPainted() + drawer?.openNew(false, scriptPath, defaults as any, { + onSaveDraft, + draftConfig: saved + }) + } + let drawer: NatsTriggerEditorInner | undefined = $state() diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 3fe3fbbc8a..e0ba992e12 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -9,10 +9,10 @@ import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte' - import { NatsTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen' + import { NatsTriggerService, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' @@ -22,7 +22,8 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveNatsTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -37,7 +38,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() interface Props { useDrawer?: boolean @@ -103,8 +104,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) let showLoading = $state(false) @@ -127,9 +129,10 @@ let preservePermissionedAs = $state(false) let isValid = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let mode = $state('enabled') @@ -157,14 +160,20 @@ is_flow = itemKind === 'flow' }) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultConfig?: Record, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -206,8 +215,14 @@ export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - nDefaultValues?: Record + nDefaultValues?: Record, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft let loadingTimeout = setTimeout(() => { showLoading = true }, 100) @@ -233,14 +248,18 @@ dirtyPath = false defaultValues = nDefaultValues mode = nDefaultValues?.mode ?? 'enabled' - error_handler_path = nDefaultValues?.error_handler_path ?? undefined - error_handler_args = nDefaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + nDefaultValues?.error_handler_path, + 'script', + nDefaultValues?.error_handler_args + ) retry = nDefaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig) } finally { clearTimeout(loadingTimeout) drawerLoading = false @@ -266,10 +285,13 @@ mode = cfg?.mode ?? 'enabled' permsPath = cfg?.path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as preservePermissionedAs = !!cfg?.permissioned_as @@ -326,6 +348,11 @@ } async function updateTrigger(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return + drawer?.closeDrawer() + return + } deploymentLoading = true const previousPath = initialPath const cfg = natsConfig @@ -424,7 +451,9 @@ ? can_write ? `Edit NATS trigger ${initialPath}` : `NATS trigger ${initialPath}` - : 'New NATS trigger'} + : saveDraftHandler + ? 'Draft NATS trigger' + : 'New NATS trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()} @@ -481,6 +510,7 @@ isLoading={deploymentLoading} {isDeployed} onUpdate={updateTrigger} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} {cloudDisabled} @@ -597,9 +627,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry />
diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte index 66702a54ab..c9ec98881c 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte @@ -21,6 +21,21 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } + /** Edit a trigger that exists only as the caller's draft; Save hands the config back. */ + export async function openDraft( + scriptPath: string, + defaults: Record, + saved: Record | undefined, + onSaveDraft: (cfg: Record) => boolean + ) { + open = true + await tickPainted() + drawer?.openNew(false, scriptPath, defaults as any, undefined, { + onSaveDraft, + draftConfig: saved + }) + } + let drawer: PostgresTriggerEditorInner | undefined = $state() diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 9025ed57a0..68ee8816b7 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -12,15 +12,14 @@ import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte' import { PostgresTriggerService, - type ErrorHandler, type Language, type Relations, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' @@ -37,12 +36,12 @@ import RelationPicker from './RelationPicker.svelte' import { invalidRelations, savePostgresTriggerFromCfg } from './utils' import CheckPostgresRequirement from './CheckPostgresRequirement.svelte' - import { base } from '$lib/base' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import TestingBadge from '../testingBadge.svelte' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { fade } from 'svelte/transition' @@ -59,7 +58,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() interface Props { useDrawer?: boolean @@ -124,8 +123,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading: boolean = $state(true) let showLoading: boolean = $state(false) @@ -154,9 +154,10 @@ let creatingPublication: boolean = $state(false) let pg14: boolean = $derived(postgresVersion.startsWith('14')) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let mode = $state('enabled') @@ -258,14 +259,20 @@ is_flow = itemKind === 'flow' }) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultConfig?: Record, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -307,8 +314,14 @@ nis_flow: boolean, fixedScriptPath_?: string, defaultValues?: Record, - newDraft?: boolean + newDraft?: boolean, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -343,15 +356,19 @@ table_to_track: [] } ] - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = defaultValues?.mode ?? 'enabled' permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig) } finally { clearTimeout(loadingTimeout) drawerLoading = false @@ -398,10 +415,13 @@ permsForWrite = cfg?.extra_perms transaction_to_track = [...cfg?.publication?.transaction_to_track] relations = cfg?.publication?.table_to_track ?? [] - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as @@ -477,6 +497,11 @@ } async function updateTrigger(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return + drawer?.closeDrawer() + return + } const cfg = postgresConfig if (!cfg) { return @@ -518,7 +543,7 @@ postgres_resource_path } }) - window.open(operatingHref(`${base}/scripts/add?id=${templateId}`)) + window.open(operatingHref(`/scripts/add?id=${templateId}`)) loading = false } catch (error) { loading = false @@ -608,7 +633,9 @@ ? can_write ? `Edit Postgres trigger ${initialPath}` : `Postgres trigger ${initialPath}` - : 'New Postgres trigger'} + : saveDraftHandler + ? 'Draft Postgres trigger' + : 'New Postgres trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()}{@render actionsSnippet()}{/snippet} @@ -663,6 +690,7 @@ isLoading={deploymentLoading} {isDeployed} onUpdate={updateTrigger} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} {mode} @@ -1007,9 +1035,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry />
diff --git a/frontend/src/lib/components/triggers/scheduled/ScheduledPollPanel.svelte b/frontend/src/lib/components/triggers/scheduled/ScheduledPollPanel.svelte index 8f582107e4..2865ba8fd5 100644 --- a/frontend/src/lib/components/triggers/scheduled/ScheduledPollPanel.svelte +++ b/frontend/src/lib/components/triggers/scheduled/ScheduledPollPanel.svelte @@ -1,6 +1,5 @@ @@ -17,7 +16,7 @@ 1. Choose a trigger script using the script picker that will poll for new events regularly script picker @@ -26,7 +25,7 @@ 2. Add logic to process each new event create action diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte new file mode 100644 index 0000000000..675d232629 --- /dev/null +++ b/frontend/src/lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte @@ -0,0 +1,598 @@ + + + + +
+ {#if !loading} + + + + + + + {#if itemKind === 'script'} + + {/if} + + {#if optionTabSelected === 'error_handler'} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ {/snippet} + {#snippet action()} +
+ saveAsDefaultErrorHandler(false) + }, + { + displayName: 'Override all existing', + type: 'delete', + action: () => saveAsDefaultErrorHandler(true) + } + ]} + > + {#snippet buttonReplacement()} + + Set as default + {/snippet} + +
+ {/snippet} +
+ +
+ + + {#snippet customTabTooltip()} + +
+
The following args will be passed to the error handler: +
    +
  • workspace_id: The ID of the workspace that the schedule belongs to.
  • +
  • job_id: The UUID of the job that errored.
  • +
  • path: The path of the script or flow that failed.
  • +
  • is_flow: Whether the runnable is a flow.
  • +
  • schedule_path: The path of the schedule.
  • +
  • error: The error details.
  • +
  • failed_times: Minimum number of times the schedule failed before + calling the error handler.
  • +
  • started_at: The start datetime of the latest job that failed.
  • +
+
+
+
+ {/snippet} +
+
+
+

+ Triggered when schedule failed

+ + +

time{failedTimes > 1 ? 's in a row' : ''}

+
+
+
+ {:else if optionTabSelected === 'recovery_handler'} + {@const disabled = !canWrite || emptyString($enterpriseLicense)} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ {/snippet} + {#snippet action()} +
+ defaults + saveAsDefaultRecoveryHandler(false) + }, + { + displayName: 'Override all existing', + type: 'delete', + action: () => saveAsDefaultRecoveryHandler(true) + } + ]} + > + {#snippet buttonReplacement()} + + Set as default + {/snippet} + +
+ {/snippet} + + {#snippet customTabTooltip()} + +
+
The following args will be passed to the recovery handler: +
    +
  • path: The path of the script or flow that recovered.
  • +
  • is_flow: Whether the runnable is a flow.
  • +
  • schedule_path: The path of the schedule.
  • +
  • error: The error of the last job that errored
  • +
  • error_started_at: The start datetime of the last job that errored
  • +
  • success_times: The number of times the schedule succeeded before + calling the recovery handler.
  • +
  • success_result: The result of the latest successful job
  • +
  • success_started_at: The start datetime of the latest successful job
  • +
+
+
+
+ {/snippet} +
+
+
+

Triggered when schedule recovered

+ +

time{recoveredTimes > 1 ? 's in a row' : ''}

+
+
+
+ {:else if optionTabSelected === 'success_handler'} + {@const disabled = !canWrite || emptyString($enterpriseLicense)} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ {/snippet} + {#snippet action()} +
+ defaults + saveAsDefaultSuccessHandler(false) + }, + { + displayName: 'Override all existing', + type: 'delete', + action: () => saveAsDefaultSuccessHandler(true) + } + ]} + > + {#snippet buttonReplacement()} + + Set as default + {/snippet} + +
+ {/snippet} + + {#snippet customTabTooltip()} + +
+
The following args will be passed to the success handler: +
    +
  • path: The path of the script or flow that succeeded.
  • +
  • is_flow: Whether the runnable is a flow.
  • +
  • schedule_path: The path of the schedule.
  • +
  • success_result: The result of the successful job
  • +
  • success_started_at: The start datetime of the successful job
  • +
+
+
+
+ {/snippet} +
+
+ {:else if optionTabSelected === 'retries'} + {@const disabled = !canWrite || emptyString($enterpriseLicense)} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ + If defined, upon error this schedule will be retried with a delay and a maximum number + of attempts as defined below. +
+ This is only available for individual script. For flows, retries can be set on each flow + step in the flow editor. +
+ {/snippet} + {#if itemKind !== 'script'} + + Error Handler and Retries are only available for scripts. For flows, use the built-in
error handler + and retries. + + {:else} + + {/if} +
+ {:else if optionTabSelected === 'dynamic_skip'} +
+ {#snippet header()} + + Optional script to filter scheduled dates. Receives the proposed datetime and returns + boolean. True = run on this date, False = skip to next occurrence. + + {/snippet} +
+ + + Handler must return a boolean value. Return true to execute the scheduled job, false to + skip. + +
+
+ {:else if optionTabSelected === 'tag'} +
+ +
+ {/if} + {:else} + + {/if} +
diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte index 78e569d3f3..295498f428 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte @@ -1,6 +1,7 @@ diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 1563407150..df09a81671 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -1,11 +1,19 @@ diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 4aee2a8581..5593dd121f 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -9,15 +9,14 @@ import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import Path from '$lib/components/Path.svelte' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { capitalize, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import { SqsTriggerService, type AwsAuthResourceType, - type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen' @@ -28,7 +27,8 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveSqsTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -43,7 +43,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() interface Props { useDrawer?: boolean @@ -109,8 +109,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) let showLoading = $state(false) @@ -125,9 +126,10 @@ let selectedPermissionedAs = $state(undefined) let preservePermissionedAs = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let suspendedJobsModal = $state(null) let originalConfig = $state | undefined>(undefined) @@ -152,14 +154,20 @@ is_flow = itemKind === 'flow' }) + // Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own + // draft, so Save hands it the config instead of writing it. + let saveDraftHandler: ((cfg: Record) => boolean) | undefined = $state(undefined) + export async function openEdit( ePath: string, isFlow: boolean, defaultConfig?: Record, fixedScriptPath_?: string ) { + saveDraftHandler = undefined // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -199,8 +207,14 @@ export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - defaultValues?: Record + defaultValues?: Record, + opts: { + onSaveDraft?: (cfg: Record) => boolean + /** A config this editor saved before, re-applied over the defaults. */ + draftConfig?: Record + } = {} ) { + saveDraftHandler = opts.onSaveDraft let loadingTimeout = setTimeout(() => { showLoading = true }, 100) @@ -221,14 +235,18 @@ edit = false dirtyPath = false mode = defaultValues?.mode ?? 'enabled' - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = undefined selectedPermissionedAs = undefined preservePermissionedAs = false originalConfig = undefined + if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig) } finally { initialConfig = structuredClone($state.snapshot(getSaveCfg())) clearTimeout(loadingTimeout) @@ -252,10 +270,13 @@ aws_auth_resource_type = cfg?.aws_auth_resource_type permsPath = cfg?.path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as preservePermissionedAs = !!cfg?.permissioned_as @@ -336,6 +357,11 @@ } async function updateTrigger(): Promise { + if (saveDraftHandler) { + if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return + drawer?.closeDrawer() + return + } deploymentLoading = true const previousPath = initialPath const cfg = getSaveCfg() @@ -406,7 +432,9 @@ ? can_write ? `Edit SQS trigger ${initialPath}` : `SQS trigger ${initialPath}` - : 'New SQS trigger'} + : saveDraftHandler + ? 'Draft SQS trigger' + : 'New SQS trigger'} on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())} > {#snippet actions()} @@ -463,6 +491,7 @@ isLoading={deploymentLoading} {isDeployed} onUpdate={updateTrigger} + saveLabel={saveDraftHandler ? 'Save draft' : undefined} {onReset} {onDelete} {mode} @@ -582,9 +611,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry />
diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts index cd25328ff6..7fe40d111d 100644 --- a/frontend/src/lib/components/triggers/triggers.svelte.ts +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -23,14 +23,15 @@ import { type NativeTrigger, type NativeServiceName } from '$lib/gen' -import { enterpriseLicense } from '$lib/stores' +import { enterpriseLicense, userWorkspaces } from '$lib/stores' import { getLightConfig, sortTriggers, updateTriggersCount, type Trigger } from './utils' import { get, type Writable } from 'svelte/store' import type { TriggerType } from './utils' import type { UserExt } from '$lib/stores' import type { ScheduleTrigger } from '../triggers' -import { canWrite, formatCron, generateRandomString } from '$lib/utils' +import { formatCron, generateRandomString } from '$lib/utils' +import { canEditItem } from '$lib/editRights' export class Triggers { #triggers = $state([]) @@ -130,9 +131,14 @@ export class Triggers { updateTriggers( remoteTriggers: any[], type: TriggerType, + workspaceId: string, user: UserExt | undefined = undefined ): number { const currentTriggers = this.#triggers + const operatorSettings = get(userWorkspaces).find( + (w) => w.id === workspaceId + )?.operator_settings + const kind = type === 'schedule' ? 'schedule' : 'trigger' // Identify triggers with draftConfig to preserve const configuredTriggers = currentTriggers.filter( (t) => t.type === type && !t.isDraft && t.draftConfig @@ -151,7 +157,7 @@ export class Triggers { path: trigger.path, isPrimary: type === 'schedule' && trigger.path === trigger.script_path, isDraft: false, - canWrite: canWrite(trigger.path, trigger.extra_perms, user), + canWrite: canEditItem(kind, trigger.path, trigger.extra_perms, user, operatorSettings), draftConfig: draftConfig, lightConfig: getLightConfig(type, trigger) } @@ -199,7 +205,7 @@ export class Triggers { isFlow }) - const scheduleCount = this.updateTriggers(allDeployedSchedules, 'schedule', user) + const scheduleCount = this.updateTriggers(allDeployedSchedules, 'schedule', workspaceId, user) const updatedPrimarySchedule = this.#triggers.find((s) => s.isPrimary) triggersCountStore.update((triggersCount) => { return { @@ -234,7 +240,7 @@ export class Triggers { path, isFlow }) - const wsCount = this.updateTriggers(wsTriggers, 'websocket', user) + const wsCount = this.updateTriggers(wsTriggers, 'websocket', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -260,7 +266,7 @@ export class Triggers { path, isFlow }) - const pgCount = this.updateTriggers(pgTriggers, 'postgres', user) + const pgCount = this.updateTriggers(pgTriggers, 'postgres', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -286,7 +292,7 @@ export class Triggers { path, isFlow }) - const kafkaCount = this.updateTriggers(kafkaTriggers, 'kafka', user) + const kafkaCount = this.updateTriggers(kafkaTriggers, 'kafka', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -312,7 +318,7 @@ export class Triggers { path, isFlow }) - const natsCount = this.updateTriggers(natsTriggers, 'nats', user) + const natsCount = this.updateTriggers(natsTriggers, 'nats', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -338,7 +344,7 @@ export class Triggers { path, isFlow }) - const mqttCount = this.updateTriggers(mqttTriggers, 'mqtt', user) + const mqttCount = this.updateTriggers(mqttTriggers, 'mqtt', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -364,7 +370,7 @@ export class Triggers { path, isFlow }) - const amqpCount = this.updateTriggers(amqpTriggers, 'amqp', user) + const amqpCount = this.updateTriggers(amqpTriggers, 'amqp', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -390,7 +396,7 @@ export class Triggers { path, isFlow }) - const sqsCount = this.updateTriggers(sqsTriggers, 'sqs', user) + const sqsCount = this.updateTriggers(sqsTriggers, 'sqs', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -416,7 +422,7 @@ export class Triggers { path, isFlow }) - const gcpCount = this.updateTriggers(gcpTriggers, 'gcp', user) + const gcpCount = this.updateTriggers(gcpTriggers, 'gcp', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -442,7 +448,7 @@ export class Triggers { path, isFlow }) - const azureCount = this.updateTriggers(azureTriggers, 'azure', user) + const azureCount = this.updateTriggers(azureTriggers, 'azure', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -468,7 +474,7 @@ export class Triggers { path, isFlow }) - const httpCount = this.updateTriggers(httpTriggers, 'http', user) + const httpCount = this.updateTriggers(httpTriggers, 'http', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -494,7 +500,7 @@ export class Triggers { path, isFlow }) - const emailCount = this.updateTriggers(emailTriggers, 'email', user) + const emailCount = this.updateTriggers(emailTriggers, 'email', workspaceId, user) triggersCountStore.update((triggersCount) => { return { ...(triggersCount ?? {}), @@ -530,7 +536,7 @@ export class Triggers { extra_perms: {}, service_config: t.service_config })) - const count = this.updateTriggers(triggerData, serviceName, user) + const count = this.updateTriggers(triggerData, serviceName, workspaceId, user) const countProperty = `${serviceName}_count` triggersCountStore.update((triggersCount) => { return { diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index 979791cba1..3eb67cb933 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -30,7 +30,6 @@ import GoogleMonoIcon from '$lib/components/icons/triggers/GoogleIcon.svelte' import GithubMonoIcon from '$lib/components/icons/triggers/GithubIcon.svelte' import type { CaptureTriggerKind, - ErrorHandler, Flow, JobTriggerKind, NewScript, @@ -732,17 +731,3 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] { export type FlowWithDraftAndDraftTriggers = Flow export type NewScriptWithDraftAndDraftTriggers = NewScript & { hash?: string } - -export function getHandlerType(scriptPath: string): ErrorHandler { - const handlerMap = { - teams: '/workspace-or-schedule-error-handler-teams', - slack: '/workspace-or-schedule-error-handler-slack', - email: '/workspace-or-error-handler-email' - } - for (const [type, suffix] of Object.entries(handlerMap)) { - if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) { - return type as ErrorHandler - } - } - return 'custom' -} diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte index 9033aab3cf..218430316a 100644 --- a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte +++ b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte @@ -16,7 +16,7 @@ import ClipboardPanel from '../../details/ClipboardPanel.svelte' import { copyToClipboard, isObject, readFieldsRecursively } from '$lib/utils' // import { page } from '$app/state' - import { base } from '$lib/base' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import TriggerTokens from '../TriggerTokens.svelte' import { userStore } from '$lib/stores' import UserSettings from '../../UserSettings.svelte' @@ -46,7 +46,7 @@ const operatingWorkspace = useOperatingWorkspace() const wsId = $derived($operatingWorkspace) - const WEBHOOK_BASE_URL = $derived(`${location.origin}${base}/api/w/${wsId}/jobs`) + const WEBHOOK_BASE_URL = $derived(`${apiBaseUrl()}/api/w/${wsId}/jobs`) let baseWebhookUrl = $derived.by(() => { let webhookUrlPath: string @@ -223,9 +223,7 @@ async function triggerJob() { function waitForJobCompletion(UUID) { return new Promise(async (resolve, reject) => { try { - const endpoint = \`${ - location.origin - }/api/w/${wsId}/jobs_u/completed/get_result_maybe/\${UUID}\`; + const endpoint = \`${apiBaseUrl()}/api/w/${wsId}/jobs_u/completed/get_result_maybe/\${UUID}\`; const checkResponse = await fetch(endpoint, { method: 'GET', headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t\t\t')} @@ -268,7 +266,7 @@ ${ ? 'echo -E $RESULT | jq' : requestType === 'async' ? ` -URL="${location.origin}/api/w/${wsId}/jobs_u/completed/get_result_maybe/$UUID" +URL="${apiBaseUrl()}/api/w/${wsId}/jobs_u/completed/get_result_maybe/$UUID" while true; do curl -s -H "Authorization: Bearer $TOKEN" $URL -o res.json COMPLETED=$(cat res.json | jq .completed) diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index 007bc39e47..b011d6fee1 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -21,12 +21,11 @@ type WebsocketTriggerInitialMessage, type WebsocketHeartbeat, type Retry, - type ErrorHandler, type TriggerMode } from '$lib/gen' import { usedTriggerKinds } from '$lib/stores' - import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { emptySchema, emptyString, sendUserToast } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2, X, Plus } from 'lucide-svelte' @@ -43,7 +42,8 @@ import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveWebsocketTriggerFromCfg } from './utils' - import { getHandlerType, handleConfigChange, type Trigger } from '../utils' + import { handleConfigChange, type Trigger } from '../utils' + import { handlerDraftFromStored, handlerDraftToStored } from '../../handlerDraft.svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -59,7 +59,7 @@ useOperatingWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' - const triggerLock = useTriggerLock() + const editRights = useEditRights() interface Props { useDrawer?: boolean @@ -137,8 +137,9 @@ // The acting user in the operating workspace arrives asynchronously, and an unknown user // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. const can_write = $derived( - (permsPath === undefined || canWrite(permsPath, permsForWrite ?? {}, actingUser)) && - !$triggerLock + permsPath === undefined + ? editRights.roleCanAuthor('trigger') + : editRights.canEditItem('trigger', permsPath, permsForWrite) ) let drawerLoading = $state(true) let showLoading = $state(false) @@ -149,9 +150,10 @@ let preservePermissionedAs = $state(false) let isValid = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') - let errorHandlerSelected: ErrorHandler = $state('slack') - let error_handler_path: string | undefined = $state() - let error_handler_args: Record = $state({}) + let errorHandler = $state(handlerDraftFromStored('error', undefined)) + const storedErrorHandler = $derived(handlerDraftToStored(errorHandler)) + const error_handler_path = $derived(storedErrorHandler.path) + const error_handler_args = $derived(storedErrorHandler.args) let retry: Retry | undefined = $state() let mode = $state('enabled') @@ -206,6 +208,7 @@ ) { // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() + editRights.forgetFailures() let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -266,10 +269,13 @@ dirtyPath = false can_return_message = false can_return_error_result = false - error_handler_path = defaultValues?.error_handler_path ?? undefined - error_handler_args = defaultValues?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + defaultValues?.error_handler_path, + 'script', + defaultValues?.error_handler_args + ) retry = defaultValues?.retry ?? undefined - errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = defaultValues?.mode ?? 'enabled' permissionedAs = undefined selectedPermissionedAs = undefined @@ -303,10 +309,13 @@ heartbeat_state_field = hb?.state_field ?? '' permsPath = path permsForWrite = cfg?.extra_perms - error_handler_path = cfg?.error_handler_path - error_handler_args = cfg?.error_handler_args ?? {} + errorHandler = handlerDraftFromStored( + 'error', + cfg?.error_handler_path, + 'script', + cfg?.error_handler_args + ) retry = cfg?.retry - errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' permissionedAs = cfg?.permissioned_as selectedPermissionedAs = cfg?.permissioned_as @@ -914,9 +923,7 @@ {optionTabSelected} {itemKind} {can_write} - bind:errorHandlerSelected - bind:error_handler_path - bind:error_handler_args + bind:errorHandler bind:retry />
diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte index cc7f4ab4a5..8c414cac7a 100644 --- a/frontend/src/lib/components/tutorials/OperatorTour.svelte +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -1,8 +1,7 @@ - + + +{#snippet sidebarToggle()} + {@const hidden = navDetached.val} + {#if hidden || $userStore?.operator} + + + {#snippet text()}{hidden ? 'Show sidebar' : 'Hide sidebar'}{/snippet} + + {/if} +{/snippet} + {#snippet brandMark(collapsed: boolean)}
@@ -1014,6 +1194,164 @@
{/snippet} + +{#snippet sidebarColumn(panel: boolean)} + {@const collapsed = panel ? false : isCollapsed} +
+ {#if !panel} + + + {/if} + + {#if !embedded && sessionsSwitchShown} +
+
+ (preserveMenuOnNextNav = true) : undefined} + /> +
+
+ {/if} + + {#if sessionMode} + +
+ +
+ {@render settingsMenu(collapsed)} + {:else} + + + +
+ {@render quickLinks(collapsed)} + + {#snippet children({ createMenu })} + + {/snippet} + + openSearchModal()} + isCollapsed={collapsed} + icon={Search} + label="Search" + class="!text-xs" + shortcut={`${getModifierKey()}k`} + /> + {#if askAiShown} + + aiChatManager.toggleOpen()} + isCollapsed={collapsed} + icon={WandSparkles} + iconProps={{ forceDarkMode: true }} + label="Ask AI" + class="!text-xs" + iconClasses="!text-ai" + shortcut={`${getModifierKey()}L`} + /> + {/if} +
+ + + + + +
+ {@render settingsMenu(collapsed)} +
+
+ {/if} + +
+ {@render accountSetupBanner(collapsed)} + +
+ +
+ {@render brandMark(collapsed)} +
+ {#if !panel || navDetached.val} + {@render sidebarToggle()} + {/if} + + {#if !panel && !$userStore?.operator} + + {/if} +
+
+
+{/snippet} + @@ -1053,431 +1391,110 @@
{/if} {#if !menuHidden} - {#if !$userStore?.operator} - {#if innerWidth < 768} + {#if navDetached.val && hoverPointer && !menuOpen && !devOnly} + + + {/if} + {#if useDrawer} + + {:else} -
- + {/if} - -
-
-
-
- -
-
-
- - {#if !isCollapsed}{#if $whitelabelNameStore}{capitalize( - $whitelabelNameStore - )}{:else}Windmill{/if}{/if} -
- -
- - {#snippet children({ createMenu })} - - - {/snippet} - - openSearchModal()} - {isCollapsed} - icon={Search} - label="Search" - class="!text-xs" - shortcut={`${getModifierKey()}k`} - /> - {#if !$copilotInfo.workspaceDisabled} - aiChatManager.toggleOpen()} - {isCollapsed} - icon={WandSparkles} - iconProps={{ - forceDarkMode: true - }} - label="Ask AI" - class="!text-xs" - iconClasses="!text-ai" - shortcut={`${getModifierKey()}L`} - /> - {/if} -
- - -
-
-
-
{/if} -
+
+ + {#if !devOnly && (!menuHidden || pageHeader.actions.length > 0)} + {@const rightInset = pageHeader.content?.barRightInset} + {@const leftInset = useDrawer || menuHidden ? 0 : railWidth} + +
+ +
+ {/if} {#if $enterpriseLicense && !menuHidden} { - menuOpen = true - }} + isMobile={useDrawer} />
diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index d6c29c53e3..c20698fae0 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -12,18 +12,19 @@ import HighlightCode from '$lib/components/HighlightCode.svelte' import { ExternalLink, GitFork, Globe2, Loader2, Code, LayoutDashboard } from 'lucide-svelte' import { hubBaseUrlStore } from '$lib/stores' - import { base } from '$lib/base' import ItemsList from '$lib/components/home/ItemsList.svelte' import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte' import { writable } from 'svelte/store' import type { EditorBreakpoint } from '$lib/components/apps/types' import { setQuery } from '$lib/navigation' + import { useReducedMotion } from '$lib/svelte5Utils.svelte' import { page } from '$app/state' import { goto, replaceState } from '$app/navigation' import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte' import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte' import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { useSearchParams } from '$lib/svelte5UtilsKit.svelte' import { z } from 'zod' import HomeAIChat from '$lib/components/home/HomeAIChat.svelte' @@ -92,6 +93,22 @@ let showCreateButtons = $state(false) + // A workspace switch keeps this page mounted and swaps its contents under the reader, which + // the mount animation below never covers. Replay it by hand instead of keying the page on the + // workspace: a remount would drop the list's own state and refetch what it is already loading. + let pageEl: HTMLDivElement | undefined = $state(undefined) + const reducedMotion = useReducedMotion() + let animatedWorkspace = $workspaceStore + $effect(() => { + const ws = $workspaceStore + if (ws === animatedWorkspace) return + animatedWorkspace = ws + untrack(() => { + if (!pageEl || reducedMotion.val) return + pageEl.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: 'ease-out' }) + }) + }) + let operatorTour: OperatorTour | undefined = $state(undefined) // Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is @@ -145,7 +162,7 @@
-
- {/snippet} -

-					
-			
- - -{/if} -{#if !job || (job?.job_kind != 'flow' && job?.job_kind != 'flownode' && job?.job_kind != 'flowpreview')} - -{/if} - -{#snippet flowRestartButton( - state: ReturnType, - step: string, - triggerStyle: 'button' | 'link' -)} - {#if job} - { - goto('/run/' + newJobId + '?workspace=' + $workspaceStore) - }} - flowPath={job.script_path} - flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined} - disabled={!$enterpriseLicense} - enterpriseOnly={!$enterpriseLicense} - {triggerStyle} - unifiedSize="sm" - /> - {/if} -{/snippet} - - - - - -{#if loadError?.status === 403} -
-
- -
-

- This run exists in {$workspaceStore}, but you don't - have permission to view it. -

-

- Ask a colleague who can see it to open the run and use the - Share button to send you a read-only link. Opening that - link will grant you access to this run (and its steps). -

-
-
-
- -
-
-
-{:else if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)} - {@const currentWorkspace = $userWorkspaces.find((w) => w.id === $workspaceStore)} - {@const otherWorkspaces = $userWorkspaces.filter((w) => w.id !== $workspaceStore)} -
-
-
- -
-

Run not found

-

- No run with ID - {page.params.run} - exists in - {currentWorkspace?.name ?? $workspaceStore}. It may belong to another workspace, or it may have been deleted. -

-
- - {#if otherWorkspaces.length > 0} -
-

Look in another workspace

-
- {#each otherWorkspaces as workspace (workspace.id)} - - {/each} -
-
- {/if} - -
- -
-
-{:else} - - - {#snippet left()} -

run/{page.params.run}

- {/snippet} - {#snippet right()} - {@const isScript = job?.job_kind === 'script'} - {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} - {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} - {#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))} - { - job?.id && deleteCompletedJob(job.id) - }, - type: 'delete' - } - ]} - > - {#snippet buttonReplacement()} - - {/if} - {/if} - {#if job} - job && shareReadLink(job.id) - }, - { - displayName: 'Copy public link', - icon: Globe, - disabled: !canSharePublicly, - tooltip: canSharePublicly - ? "Read-only link that anyone on the internet can open, without logging in. It shows a minimal version of this page: this run's inputs, result and logs, and for a flow its graph plus every step's inputs, result, logs and code. The link cannot be revoked." - : 'Sharing a run publicly is restricted in this workspace. Ask an admin to share it, or to grant you a bypass on the ruleset.', - action: () => job && sharePublicLink(job.id) - } - ]} - > - {#snippet buttonReplacement()} - - {/snippet} - - {/if} - {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} - {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} - {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} -
- { - debugInfo() - } - } - ]} - class="h-auto" - > - {#snippet buttonReplacement()} - - {/snippet} - -
- {/if} - {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} - - {/if} - {#if persistentScriptDefinition !== undefined} - - {/if} - {#if canRunNow} - - {/if} - {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} - {#if !forceCancel} - - {:else} - - {/if} - {/if} - {#if job?.schedule_path} - - {/if} - {#if restartStep !== undefined && canRestart(restart)} - {@render flowRestartButton(restart, restartStep, 'button')} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} - {#if !$userStore?.operator} - {#if canWrite(job?.script_path ?? '', {}, $userStore)} - - {#if showEditButton} - - - {/if} - {/if} - {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} - - {/if} - {/if} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - - {/if} - {/snippet} -
-
- -
- - {#if job} - { - persistentScriptDrawer?.open?.(persistentScriptDefinition) - }} - {concurrencyKey} - /> - {/if} -
- {#if job?.['deleted']} -
- - The content of this run was deleted (by an admin, no less) - -
-
- {/if} - - - {#if job?.job_kind === 'flow' || job?.job_kind === 'flowpreview'} -
- - {#snippet errorAction()} - {#if failedTopLevelStep} - at step {failedTopLevelStep} - {#if $enterpriseLicense && canRestart(failedRestart)} - · - {@render flowRestartButton(failedRestart, failedTopLevelStep, 'link')} - on this flow version, or on a new one after you fix it - {/if} - {/if} - {/snippet} - - {#if suspendStatus} - - {/if} -
- {/if} - - -
-
Inputs
-
- - {#if job && currentJobIsLongRunning && showExplicitProgressTip && !scriptProgress && 'running' in job} - - - - {/if} -
-
- - {#if job} - - {/if} - - {#if isNotFlow(job?.job_kind)} - {#if ['python3', 'bun', 'deno'].includes(job?.language ?? '') && (job?.job_kind == 'script' || isScriptPreview(job?.job_kind))} - - {/if} -
- {#if job?.workflow_as_code_status && job.job_kind !== 'aiagent'} -
-

Workflow Timeline

-
- -
-
- {/if} - {#if scriptProgress} - - {/if} - - - {#if job?.language === 'dbt' && job?.script_path} -
-

Models

- -
- {/if} - - - {#if job} -
-

Result

-
- {#if job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined)} - - {:else} -
No output is available yet
- {/if} -
-
- {#if job.id && job.workspace_id} - - - {/if} - {/if} - - -
- - - - - - {#if isScriptPreview(job?.job_kind)} - - {/if} - - - - {#if job} -
- {#if viewTab == 'logs'} -
- -
- {:else if viewTab == 'assets'} -
- -
- {:else if viewTab == 'traces'} -
- -
- {:else if viewTab == 'code'} - {#if job && 'raw_code' in job && job.raw_code} -
- -
- {:else if job} - No code available - {:else} - - {/if} - {:else if viewTab == 'stats'} -
- -
- {:else} -
Select a tab to view content
- {/if} -
- {/if} -
-
- {:else if !job?.['deleted']} -
- -
- {#if job?.id} - { - job = newJob - }} - onDone={({ job: newJob }) => { - job = newJob - }} - initialJob={job} - workspaceId={$workspaceStore} - bind:selectedJobStep - bind:suspendStatus - bind:isOwner - bind:localModuleStates={graphModuleStates} - bind:expandedSubflows - /> - {:else} - - {/if} -
- {/if} -
-{/if} - - diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 49ad076d23..6110af0fef 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -541,6 +541,7 @@ {#if draftSync.draft && renderEditor} -{#snippet title()} -
-

Service logs

- - Logs written by the servers, workers and indexers of this instance, grouped by host. - -
-{/snippet} - -
+ +
{#if $devopsRole == false}
- {@render title()} Ask one of them to look at the logs, or to grant you the devops role.
{:else} - + {/if}
diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 1662c15d79..a1b2856323 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -1,21 +1,25 @@ + + { diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/accept_invite/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/accept_invite/+page.svelte index e14a2113cb..6b731a4833 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/accept_invite/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/accept_invite/+page.svelte @@ -2,7 +2,6 @@ import { run } from 'svelte/legacy'; import { goto } from '$lib/navigation' - import { base } from '$lib/base' import { SettingService, UserService, WorkspaceService } from '$lib/gen' import { validateUsername } from '$lib/utils' @@ -84,7 +83,7 @@ {/if}
- -

- Fetches the latest resource types directly from the Windmill Hub (requires internet - access). -

- {#if hubSyncStatus === 'success'} - - {hubSyncMessage} - - {:else if hubSyncStatus === 'error'} - - {hubSyncMessage} - - {/if}

- The daily schedule synchronizes resource types from the Hub every day at midnight UTC. + The server updates them from the Hub once a day, overwriting local edits to the types the + Hub also defines. You can change this later in the instance settings.

@@ -539,7 +496,7 @@
diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte index 240eee8473..8bcd94ce24 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte @@ -1,5 +1,4 @@ - + diff --git a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte index 010abe2bd8..9e9226d133 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte @@ -2,7 +2,6 @@ import { untrack } from 'svelte' import { UserService } from '$lib/gen' import { page } from '$app/state' - import { base } from '$lib/base' import CenteredModal from '$lib/components/CenteredModal.svelte' import { Button } from '$lib/components/common' import { userStore, workspaceStore } from '$lib/stores' @@ -30,12 +29,12 @@ } - +

Token will be posted to {host == 'localhost' ? 'your local machine' : host} to port {port}

- +
diff --git a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte index 2362b7f116..90705bbdd1 100644 --- a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte @@ -1,6 +1,5 @@ - + {#if framed} This page cannot be used inside another page. Open it in its own tab. @@ -126,7 +125,7 @@ {/if} {#if framed || invalid}
- +
{/if}
diff --git a/frontend/src/routes/(root)/(logged)/workers/+page.svelte b/frontend/src/routes/(root)/(logged)/workers/+page.svelte index f7e0f07a54..6c568b5b2d 100644 --- a/frontend/src/routes/(root)/(logged)/workers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workers/+page.svelte @@ -8,6 +8,7 @@ import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import QueueMetricsDrawer from '$lib/components/QueueMetricsDrawer.svelte' + import HeaviestScriptsDrawer from '$lib/components/HeaviestScriptsDrawer.svelte' import ManageTagsDrawer from '$lib/components/ManageTagsDrawer.svelte' import SimpleEditor from '$lib/components/SimpleEditor.svelte' import Cell from '$lib/components/table/Cell.svelte' @@ -34,6 +35,7 @@ import { displayDate, groupBy, pluralize, retrieveCommonWorkerPrefix, truncate } from '$lib/utils' import { ExternalLink, + Gauge, LineChart, Loader2, Plus, @@ -493,6 +495,7 @@ } let queueMetricsDrawer: QueueMetricsDrawer | undefined = $state(undefined) + let heaviestScriptsDrawer: HeaviestScriptsDrawer | undefined = $state(undefined) let manageTagsDrawer: ManageTagsDrawer | undefined = $state(undefined) let selectedTab: string = $state('default') @@ -604,6 +607,10 @@ {#if $superadmin || $devopsRole} + name)} + /> + +
{#if !config || !config.worker_tags || config.worker_tags.length === 0} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index f055e92ca3..d8e3d05d35 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -3,17 +3,21 @@ import { page } from '$app/stores' import { isCloudHosted } from '$lib/cloud' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Button, CopyButton, Section, Skeleton, Tab, Tabs } from '$lib/components/common' + import { Alert, Button, Section, Skeleton, Tab, Tabs } from '$lib/components/common' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import DeployToSetting from '$lib/components/DeployToSetting.svelte' import RemoteDeployTargetSetting from '$lib/components/RemoteDeployTargetSetting.svelte' import DevWorkspaceSetting from '$lib/components/DevWorkspaceSetting.svelte' - import ErrorOrRecoveryHandler, { - handlerFullPath - } from '$lib/components/ErrorOrRecoveryHandler.svelte' - import PageHeader from '$lib/components/PageHeader.svelte' + import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte' + import { + cloneHandlerDraft, + handlerDraftFromStored, + handlerDraftToStored, + type HandlerDraft + } from '$lib/components/handlerDraft.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -26,7 +30,6 @@ WorkspaceService, SettingService, type AIConfig, - type ErrorHandler, type GetCopilotSettingsStateResponse, type InstanceAISummary, type GetSettingsResponse @@ -59,7 +62,6 @@ convertBackendSettingsToFrontendSettings, type S3ResourceSettings } from '$lib/workspace_settings' - import { base } from '$lib/base' import ConnectionSection from '$lib/components/ConnectionSection.svelte' import AISettings from '$lib/components/workspaceSettings/AISettings.svelte' import SharedUiSettings from '$lib/components/workspaceSettings/SharedUiSettings.svelte' @@ -68,7 +70,6 @@ import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte' import Trashbin from '$lib/components/settings/Trashbin.svelte' import { untrack } from 'svelte' - import { getHandlerType } from '$lib/components/triggers/utils' import DbtSettings, { convertDbtSettingsFromBackend, type DbtSettingsType @@ -181,10 +182,16 @@ let plan: string | undefined = $state(undefined) let customer_id: string | undefined = $state(undefined) let webhook: string | undefined = $state(undefined) - let errorHandlerSelected: ErrorHandler = $state('slack') - let errorHandlerScriptPath: string | undefined = $state(undefined) - let errorHandlerItemKind: 'flow' | 'script' = $state('script') - let errorHandlerExtraArgs: Record = $state({}) + const noErrorHandler = handlerDraftFromStored( + 'error', + undefined, + 'script', + {}, + { + emptySelected: 'slack' + } + ) + let errorHandlerDraft: HandlerDraft = $state(cloneHandlerDraft(noErrorHandler)) let errorHandlerMutedOnCancel: boolean | undefined = $state(undefined) let errorHandlerMutedOnUserPath: boolean | undefined = $state(undefined) let successHandlerScriptPath: string | undefined = $state(undefined) @@ -250,10 +257,7 @@ let initialEditedWorkspaceEncryptionKey: string | undefined = $state(undefined) // Track initial error handler settings for unsaved changes detection - let initialErrorHandlerSelected: ErrorHandler = $state('slack') - let initialErrorHandlerScriptPath: string | undefined = $state(undefined) - let initialErrorHandlerItemKind: 'flow' | 'script' = $state('script') - let initialErrorHandlerExtraArgs: Record = $state({}) + let initialErrorHandlerDraft: HandlerDraft = $state(cloneHandlerDraft(noErrorHandler)) let initialErrorHandlerMutedOnCancel: boolean | undefined = $state(undefined) let initialErrorHandlerMutedOnUserPath: boolean | undefined = $state(undefined) @@ -301,6 +305,13 @@ return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) }) + let errorHandlerChannelMissing = $derived.by(() => { + const { selected } = errorHandlerDraft + if (selected !== 'slack' && selected !== 'teams') return false + const slot = errorHandlerDraft[selected] + return !!slot.path && emptyString(slot.args['channel']) + }) + // Derived state for checking unsaved changes in success handler let hasSuccessHandlerChanges = $derived.by(() => { if (tab !== 'error_handler') return false @@ -687,10 +698,6 @@ | { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } | undefined const errorHandlerPath = errorHandler?.path ?? '' - errorHandlerItemKind = errorHandlerPath - ? (errorHandlerPath.split('/')[0] as 'flow' | 'script') - : 'script' - errorHandlerScriptPath = errorHandlerPath.split('/').slice(1).join('/') errorHandlerMutedOnCancel = errorHandler?.muted_on_cancel errorHandlerMutedOnUserPath = errorHandler?.muted_on_user_path criticalAlertUIMuted = settings.mute_critical_alerts @@ -707,18 +714,22 @@ WorkspaceService.getGuestUsage({ workspace: $workspaceStore! }) .then((u) => (guestUsage = u)) .catch(() => (guestUsage = undefined)) - if (emptyString($enterpriseLicense)) { - errorHandlerSelected = 'custom' - } else if ( + const [errorHandlerKind, ...errorHandlerScriptPath] = errorHandlerPath.split('/') + errorHandlerDraft = handlerDraftFromStored( + 'error', + errorHandlerScriptPath.join('/'), + errorHandlerKind === 'flow' ? 'flow' : 'script', + errorHandler?.extra_args, + { asCustom: emptyString($enterpriseLicense) } + ) + if ( + !emptyString($enterpriseLicense) && canUseInstanceAlerts && !errorHandlerPath && settings.error_handler_fallback_to_instance_alerts ) { - errorHandlerSelected = 'instance_alerts' - } else { - errorHandlerSelected = getHandlerType(errorHandlerScriptPath) + errorHandlerDraft.selected = 'instance_alerts' } - errorHandlerExtraArgs = errorHandler?.extra_args ?? {} const successHandler = settings.success_handler as | { path?: string; extra_args?: any } | undefined @@ -765,10 +776,7 @@ initialEditedWorkspaceEncryptionKey = editedWorkspaceEncryptionKey // Store initial error handler state for unsaved changes detection - initialErrorHandlerSelected = errorHandlerSelected - initialErrorHandlerScriptPath = errorHandlerScriptPath - initialErrorHandlerItemKind = errorHandlerItemKind - initialErrorHandlerExtraArgs = clone(errorHandlerExtraArgs) + initialErrorHandlerDraft = cloneHandlerDraft(errorHandlerDraft) initialErrorHandlerMutedOnCancel = errorHandlerMutedOnCancel initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath @@ -827,7 +835,7 @@ }) // Redirect to OAuth flow - window.location.href = `${base}/api/oauth/connect_slack?workspace=${$workspaceStore}` + window.location.href = `/api/oauth/connect_slack?workspace=${$workspaceStore}` } catch (e) { sendUserToast('Failed to save Slack OAuth configuration', true) console.error(e) @@ -905,18 +913,19 @@ }) async function editErrorHandler() { - if (errorHandlerScriptPath) { + const handler = handlerDraftToStored(errorHandlerDraft) + if (handler.path) { await WorkspaceService.editErrorHandler({ workspace: $workspaceStore!, requestBody: { - path: handlerFullPath(errorHandlerSelected, errorHandlerItemKind, errorHandlerScriptPath), - extra_args: errorHandlerExtraArgs, + path: `${handler.kind}/${handler.path}`, + extra_args: handler.args, muted_on_cancel: errorHandlerMutedOnCancel, muted_on_user_path: errorHandlerMutedOnUserPath, fallback_to_instance_alerts: false } }) - sendUserToast(`workspace error handler set to ${errorHandlerScriptPath}`) + sendUserToast(`workspace error handler set to ${handler.path}`) } else { await WorkspaceService.editErrorHandler({ workspace: $workspaceStore!, @@ -925,23 +934,28 @@ extra_args: undefined, muted_on_cancel: undefined, muted_on_user_path: undefined, - fallback_to_instance_alerts: errorHandlerSelected === 'instance_alerts' + fallback_to_instance_alerts: errorHandlerDraft.selected === 'instance_alerts' } }) sendUserToast( - errorHandlerSelected === 'instance_alerts' + errorHandlerDraft.selected === 'instance_alerts' ? `failed jobs will be reported to the instance critical alert channels` - : initialErrorHandlerScriptPath + : handlerDraftToStored(initialErrorHandlerDraft).path ? `workspace error handler removed` : `error handler settings saved` ) } - // Update initial values for dirty detection - initialErrorHandlerSelected = errorHandlerSelected - initialErrorHandlerScriptPath = errorHandlerScriptPath - initialErrorHandlerItemKind = errorHandlerItemKind - initialErrorHandlerExtraArgs = clone(errorHandlerExtraArgs) + // Rebuilt from what was stored, so Discard also drops the other tabs' unsaved edits. + initialErrorHandlerDraft = handlerDraftFromStored( + 'error', + handler.path, + handler.kind, + handler.args, + { + emptySelected: errorHandlerDraft.selected + } + ) initialErrorHandlerMutedOnCancel = errorHandlerMutedOnCancel initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath } @@ -1133,43 +1147,40 @@ guestJwtKeyType = initialGuestJwtJwksUrl ? 'jwks' : 'pem' } - // Strip keys from extraArgs that are auto-managed by child components: - // - 'slack': computed by ErrorOrRecoveryHandler's $effect based on handler type - // - 'channel_name': display metadata stripped by SchemaForm's removeExtraKey() - function normalizeHandlerExtraArgs(args: Record): Record { - const { slack: _, channel_name: __, ...rest } = args - return rest + // Compares what Save would send, so visiting another tab and coming back is not a change. + // The stored handler is nested because `hasUnsavedChanges` drops a top-level `path`. + function errorHandlerSettingsValue( + draft: HandlerDraft, + mutedOnCancel: boolean | undefined, + mutedOnUserPath: boolean | undefined + ) { + return { + errorHandler: handlerDraftToStored(draft), + fallbackToInstanceAlerts: draft.selected === 'instance_alerts', + errorHandlerMutedOnCancel: mutedOnCancel, + errorHandlerMutedOnUserPath: mutedOnUserPath + } } // Function to check if there are unsaved changes in error handler settings function getErrorHandlerSettingsInitialAndModifiedValues() { - const savedValue = { - errorHandlerSelected: initialErrorHandlerSelected, - errorHandlerScriptPath: initialErrorHandlerScriptPath, - errorHandlerItemKind: initialErrorHandlerItemKind, - errorHandlerExtraArgs: normalizeHandlerExtraArgs(initialErrorHandlerExtraArgs), - errorHandlerMutedOnCancel: initialErrorHandlerMutedOnCancel, - errorHandlerMutedOnUserPath: initialErrorHandlerMutedOnUserPath + return { + savedValue: errorHandlerSettingsValue( + initialErrorHandlerDraft, + initialErrorHandlerMutedOnCancel, + initialErrorHandlerMutedOnUserPath + ), + modifiedValue: errorHandlerSettingsValue( + errorHandlerDraft, + errorHandlerMutedOnCancel, + errorHandlerMutedOnUserPath + ) } - - const modifiedValue = { - errorHandlerSelected: errorHandlerSelected, - errorHandlerScriptPath: errorHandlerScriptPath, - errorHandlerItemKind: errorHandlerItemKind, - errorHandlerExtraArgs: normalizeHandlerExtraArgs(errorHandlerExtraArgs), - errorHandlerMutedOnCancel: errorHandlerMutedOnCancel, - errorHandlerMutedOnUserPath: errorHandlerMutedOnUserPath - } - - return { savedValue, modifiedValue } } // Function to discard unsaved error handler settings changes function discardErrorHandlerSettingsChanges() { - errorHandlerSelected = initialErrorHandlerSelected - errorHandlerScriptPath = initialErrorHandlerScriptPath - errorHandlerItemKind = initialErrorHandlerItemKind - errorHandlerExtraArgs = clone(initialErrorHandlerExtraArgs) + errorHandlerDraft = cloneHandlerDraft(initialErrorHandlerDraft) errorHandlerMutedOnCancel = initialErrorHandlerMutedOnCancel errorHandlerMutedOnUserPath = initialErrorHandlerMutedOnUserPath } @@ -1457,26 +1468,31 @@ } ] ) + + // The label of the entry the sidebar highlights, so the breadcrumb and the sidebar name the + // same pane: both read `tab`, which resolves the URL's aliases (`teams`, `deploy_to`, …). + const tabLabel = $derived( + navigationGroups.flatMap((g) => g.items).find((i) => i.id === tab)?.label + ) - + {#if canAdmin || isForkOwner} - - {#snippet titleActions()} - {#if $workspaceStore} - - {/if} - {/snippet} - {#if $superadmin} - - {/if} + +
-
+ +
-
+ @@ -1776,8 +1792,8 @@ }} onSelect={editTeamsCommand} connectHref={undefined} - createScriptHref="{base}/scripts/add?hub=hub%2F11591%2Fteams%2FExample%20of%20responding%20to%20a%20Microsoft%20Teams%20command" - createFlowHref="{base}/flows/add?hub=58" + createScriptHref="/scripts/add?hub=hub%2F11591%2Fteams%2FExample%20of%20responding%20to%20a%20Microsoft%20Teams%20command" + createFlowHref="/flows/add?hub=58" documentationLink="https://www.windmill.dev/docs/integrations/teams" onLoadSettings={loadSettings} display_name={teams_team_name} @@ -1830,7 +1846,7 @@ {:else} + {/if} +{/snippet} + diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte index b25b8db5c8..62c25fc6b3 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte @@ -31,7 +31,7 @@ - + {#if !success}
diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 06d7d570b2..f14f9e1043 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -1,5 +1,6 @@ + +{@render children?.()} diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte similarity index 81% rename from frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte rename to frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte index ee83869ee8..9b3a9c1e41 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte @@ -78,23 +78,29 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte b/frontend/src/routes/(styled)/approve/[workspace]/[job]/+page.svelte similarity index 99% rename from frontend/src/routes/approve/[workspace]/[job]/+page.svelte rename to frontend/src/routes/(styled)/approve/[workspace]/[job]/+page.svelte index e2b6b88c3f..a31fe76670 100644 --- a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte +++ b/frontend/src/routes/(styled)/approve/[workspace]/[job]/+page.svelte @@ -1,6 +1,5 @@ + - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte index b2a29b6316..fb32a8ac9b 100644 --- a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte +++ b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte @@ -1,18 +1,22 @@ - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index 15634551e6..64b39b5d7d 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -35,9 +35,6 @@ const config = { // revisions (SvelteKit only full-page reloads on a missing chunk if this changed). version: { name: process.env.WM_BUILD_VERSION || pkg.version }, prerender: { entries: [] }, - paths: { - base: process.env.VITE_BASE_URL ?? '' - }, alias: { $system_prompts: '../system_prompts/auto-generated', $oauth_connect_registry: '../backend/oauth_connect.json', diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5ae1e390e3..a07034d2c1 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -108,25 +108,34 @@ const isClientBuild = (bundle) => Object.keys(bundle).some((file) => file.startsWith('_app/immutable/')) /** - * Fail the build if a public app URL statically loads the low-code runtime or monaco. + * Fail the build if a public app URL statically loads more than a raw app needs. * * These pages also serve raw apps, which only need a small shell around their bundle's - * iframe. One static import of AppPreview (or of anything reaching monaco) makes every - * public app page preload ~650 chunks instead of ~55. See loadAppPreview.ts. + * iframe, and the page shows nothing until its static imports have loaded. One static + * import of AppPreview (or of anything reaching monaco) makes every public app page + * preload ~650 chunks; one of the generated client, `$lib/stores` or `$lib/utils` drags + * in most of the app shell. The root layout wraps these pages, so it must not import + * app.css either. See loadAppPreview.ts and publicAppApi.ts. */ function assertLeanPublicAppRoutes() { // Route directories, so +page.js counts as well as +page.svelte. const routes = ['/src/routes/public/[workspace]/[...secret]/', '/src/routes/a/[...path]/'] const forbidden = [ '/src/lib/components/apps/editor/AppPreview.svelte', - '/node_modules/monaco-editor/' + '/node_modules/monaco-editor/', + '/src/lib/assets/app.css', + '/src/lib/gen/services.gen.ts', + '/src/lib/stores.ts', + '/src/lib/utils.ts', + '/src/lib/components/Login.svelte', + '/node_modules/@melt-ui/' ] return { name: 'wm-assert-lean-public-app-routes', generateBundle(_options, bundle) { if (!isClientBuild(bundle)) return - for (const route of routes) { - assertStaticClosureExcludes(this, bundle, route, forbidden, 'loadAppPreview.ts') + for (const route of [...routes, '/src/routes/+layout.svelte']) { + assertStaticClosureExcludes(this, bundle, route, forbidden, 'publicAppApi.ts') } } } @@ -372,7 +381,19 @@ const config = { // which makes the *chunk* graph cyclic where the module graph is not, and // modules then evaluate against uninitialized bindings. See // docs/frontend-import-cycles.md. - advancedChunks: { groups: [{ name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ }] } + // The client's config is split out for the public app routes, which set the + // credential without loading the client. It imports nothing at runtime, so + // its chunk cannot be part of a cycle. + advancedChunks: { + groups: [ + { + name: 'gen-config', + test: /[\\/]src[\\/]lib[\\/]gen[\\/]core[\\/]OpenAPI\.ts$/, + priority: 1 + }, + { name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ } + ] + } } } }, diff --git a/integration_tests/test/identity_script_test.py b/integration_tests/test/identity_script_test.py index dc56c4956c..638919fb22 100644 --- a/integration_tests/test/identity_script_test.py +++ b/integration_tests/test/identity_script_test.py @@ -14,11 +14,6 @@ echo ${x} export async function main(x: number) { return x; } -""", - "deno": """ -export async function main(x: number) { - return x; -} """, "go": """ package inner @@ -72,11 +67,6 @@ class TestIdentityScript(unittest.TestCase): result = self._client.run_sync(path, {"x": 5}) self.assertEqual(result, 5) - def test_deno(self): - path = PATH_TEMPLATE.format(lang="deno") - result = self._client.run_sync(path, {"x": 5}) - self.assertEqual(result, 5) - def test_go(self): path = PATH_TEMPLATE.format(lang="go") result = self._client.run_sync(path, {"x": 5}) diff --git a/integration_tests/test/increment_flow_test.py b/integration_tests/test/increment_flow_test.py index c4682cb8b3..2a2f5b88d9 100644 --- a/integration_tests/test/increment_flow_test.py +++ b/integration_tests/test/increment_flow_test.py @@ -38,10 +38,10 @@ FLOW_VALUE = """ "value": { "tag": "", - "lock": "{\\n \\"version\\": \\"3\\",\\n \\"remote\\": {}\\n}\\n", + "lock": "{\\n \\"dependencies\\": {}\\n}\\n//bun.lockb\\n", "type": "rawscript", "content": "export async function main(x: number) {\\n return x + 1\\n}\\n", - "language": "deno", + "language": "bun", "input_transforms": { "x": diff --git a/integration_tests/test/windmill_sdk_test.py b/integration_tests/test/windmill_sdk_test.py index 6b5dcefbe9..11a4fa7886 100644 --- a/integration_tests/test/windmill_sdk_test.py +++ b/integration_tests/test/windmill_sdk_test.py @@ -8,15 +8,6 @@ from .wmill_integration_test_utils import WindmillClient VARIABLE_PATH = "u/admin/test_variable" VARIABLE_VALUE = "Hello world!" -DENO_SCRIPT_PATH = "u/admin/deno_sdk_test_script" -DENO_SCRIPT_VALUE = """ -import * as wmill from "npm:windmill-client@{version}" -export async function main() {{ - const val = await wmill.getVariable('u/admin/test_variable') - return val; -}} -""" - BUN_SCRIPT_PATH = "u/admin/bun_sdk_test_script" BUN_SCRIPT_VALUE = """ import * as wmill from "windmill-client@{version}" @@ -63,11 +54,6 @@ class TestWindmillSdk(unittest.TestCase): path=VARIABLE_PATH, value=VARIABLE_VALUE, ) - cls._client.create_script( - path=DENO_SCRIPT_PATH, - content=DENO_SCRIPT_VALUE.format(version=cls._dev_version), - language="deno", - ) # TODO: See skipped annotations below # cls._client.create_script( # path=BUN_SCRIPT_PATH, @@ -89,7 +75,6 @@ class TestWindmillSdk(unittest.TestCase): def tearDownClass(cls) -> None: if cls._running_latest: cls._client.set_npm_config_registry("") - cls._client.delete_script(path=DENO_SCRIPT_PATH) # TODO: See skipped annotations below # cls._client.delete_script(path=BUN_SCRIPT_PATH) # cls._client.delete_script(path=PYTHON_SCRIPT_PATH) @@ -97,13 +82,6 @@ class TestWindmillSdk(unittest.TestCase): cls._client.delete_variable(path=VARIABLE_PATH) cls._client.set_npm_config_registry("") - @unittest.skipUnless( - os.environ.get("WMILL_RUNNING_DEV", "0") == "1", "Runs on dev version only" - ) - def test_deno_sdk_usable(self): - result = self._client.run_sync(DENO_SCRIPT_PATH, {}) - self.assertEqual(result, VARIABLE_VALUE) - @unittest.skipUnless( # os.environ.get("WMILL_RUNNING_DEV", "0") == "1", "Runs on dev version only" False, diff --git a/lsp/Pipfile b/lsp/Pipfile index c4bb35dca2..80c03f06c6 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.826.0" +wmill = ">=1.833.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/lsp/pyls_launcher.py b/lsp/pyls_launcher.py index 9e522d8ce5..b3fbe7fd9a 100644 --- a/lsp/pyls_launcher.py +++ b/lsp/pyls_launcher.py @@ -1,6 +1,9 @@ +import asyncio import logging +import shutil import subprocess import threading +import time import os import urllib.request import urllib.error @@ -149,8 +152,221 @@ class LanguageServerWebSocketHandler(websocket.WebSocketHandler): class PyrightLS(LanguageServerWebSocketHandler): procargs = ["pipenv", "run", "pyright-langserver", "--stdio"] -class DiagnosticLS(LanguageServerWebSocketHandler): - procargs = ["diagnostic-languageserver", "--stdio", "--log-level", "4"] +SHELLCHECK_BIN = shutil.which("shellcheck") +# The linter is configured here rather than by the editor's initializationOptions. +SHELLCHECK_ARGS = ["--norc", "--format=json1", "-"] +SHELLCHECK_TIMEOUT_SECS = float(os.environ.get("SHELLCHECK_TIMEOUT_SECS", "10")) +SHELLCHECK_MAX_DOC_CHARS = int(os.environ.get("SHELLCHECK_MAX_DOC_CHARS", "200000")) +SHELLCHECK_MAX_DOCS_PER_CONN = 8 +# A document whose checks finish faster than this is checked on every change +# with no pause; see ShellcheckLS._changed. +SHELLCHECK_INSTANT_SECS = 0.05 +SHELLCHECK_MAX_DEBOUNCE_SECS = 1.0 +# Shared by every connection: shellcheck is CPU-bound and single-threaded, so +# more concurrent runs than cores only makes all of them slower. +_shellcheck_slots = asyncio.Semaphore(os.cpu_count() or 2) + +_SHELLCHECK_SEVERITIES = {"error": 1, "warning": 2, "info": 3, "style": 3} + + +def _utf16_col(line, col): + """shellcheck counts code points, LSP positions count UTF-16 code units.""" + col = max(col - 1, 0) + if line.isascii(): + return col + return len(line[:col].encode("utf-16-le")) // 2 + + +def shellcheck_diagnostics(text, output): + lines = text.split("\n") + + def position(line, col): + line = max(line - 1, 0) + return { + "line": line, + "character": _utf16_col(lines[line], col) if line < len(lines) else 0, + } + + diagnostics = [] + for c in json.loads(output).get("comments", []): + code = "SC%d" % c["code"] + diagnostics.append( + { + "range": { + "start": position(c["line"], c["column"]), + "end": position(c["endLine"], c["endColumn"]), + }, + "severity": _SHELLCHECK_SEVERITIES.get(c["level"], 3), + "code": code, + "codeDescription": {"href": "https://www.shellcheck.net/wiki/" + code}, + "source": "shellcheck", + "message": c["message"], + } + ) + return diagnostics + + +class ShellcheckLS(websocket.WebSocketHandler): + """Lints shell documents by running shellcheck itself, speaking only the + slice of LSP that takes: document sync in, diagnostics out.""" + + def open(self): + self.docs = {} + self.tasks = {} + self.cost = {} + self.linted = {} + + def check_origin(self, origin): + return True + + def on_message(self, message): + try: + msg = json.loads(message) + method = msg.get("method") + params = msg.get("params") or {} + if method == "initialize": + self._send( + { + "jsonrpc": "2.0", + "id": msg.get("id"), + "result": { + "capabilities": { + "textDocumentSync": {"openClose": True, "change": 1} + }, + "serverInfo": {"name": "windmill-shellcheck"}, + }, + } + ) + elif method == "textDocument/didOpen": + doc = params["textDocument"] + self._changed(doc["uri"], doc.get("version"), doc["text"]) + elif method == "textDocument/didChange": + doc = params["textDocument"] + self._changed( + doc["uri"], doc.get("version"), params["contentChanges"][-1]["text"] + ) + elif method == "textDocument/didClose": + self._forget(params["textDocument"]["uri"]) + elif method == "shutdown": + self._send({"jsonrpc": "2.0", "id": msg.get("id"), "result": None}) + elif method is not None and "id" in msg: + self._send( + { + "jsonrpc": "2.0", + "id": msg["id"], + "error": {"code": -32601, "message": "Method not found"}, + } + ) + except (ValueError, KeyError, IndexError, TypeError, AttributeError): + log.warning("shellcheck: dropping malformed message") + + def on_close(self): + for uri in list(self.docs): + self._forget(uri) + + def _send(self, msg): + try: + self.write_message(json.dumps(msg)) + except websocket.WebSocketClosedError: + pass + + def _forget(self, uri): + task = self.tasks.pop(uri, None) + if task: + task.cancel() + self.docs.pop(uri, None) + self.cost.pop(uri, None) + self.linted.pop(uri, None) + + def _changed(self, uri, version, text): + if not isinstance(uri, str) or not isinstance(text, str): + raise TypeError + if uri not in self.docs and len(self.docs) >= SHELLCHECK_MAX_DOCS_PER_CONN: + return + if len(text) > SHELLCHECK_MAX_DOC_CHARS: + # Not checked, so not kept either: reported as having no findings. + text = None + self.docs[uri] = (version, text) + if uri in self.linted and self.linted[uri] == text: + return + running = self.tasks.pop(uri, None) + # The pause before a check is what that document's checks cost: one that + # is cheap is checked at once, one that is not waits out a typing burst + # rather than burning a core on results the next keystroke discards. + delay = self.cost.get(uri, 0) + if delay < SHELLCHECK_INSTANT_SECS: + delay = 0 + if running and not running.done(): + running.cancel() + delay = max(delay, SHELLCHECK_INSTANT_SECS) + self.tasks[uri] = asyncio.ensure_future(self._lint(uri, version, text, delay)) + + async def _lint(self, uri, version, text, delay): + if delay: + await asyncio.sleep(delay) + diagnostics = [] + if SHELLCHECK_BIN and text is not None: + async with _shellcheck_slots: + started = time.monotonic() + try: + output = await self._run(text) + self.cost[uri] = 0 + finally: + # A killed check counts too, as a lower bound: it is the only + # measure a document edited faster than it can be checked gets. + if uri in self.docs: + self.cost[uri] = min( + max(time.monotonic() - started, self.cost.get(uri, 0)), + SHELLCHECK_MAX_DEBOUNCE_SECS, + ) + if output is None: + return + try: + diagnostics = shellcheck_diagnostics(text, output) + except (ValueError, KeyError, TypeError) as e: + log.warning("shellcheck: unreadable output: %s", e) + return + if self.docs.get(uri) != (version, text): + return + self.linted[uri] = text + params = {"uri": uri, "diagnostics": diagnostics} + if version is not None: + params["version"] = version + self._send( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": params, + } + ) + + async def _run(self, text): + try: + proc = await asyncio.create_subprocess_exec( + SHELLCHECK_BIN, + *SHELLCHECK_ARGS, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except OSError as e: + log.warning("shellcheck: could not start: %s", e) + return None + try: + output, _ = await asyncio.wait_for( + proc.communicate(text.encode("utf-8", "replace")), + SHELLCHECK_TIMEOUT_SECS, + ) + return output + except asyncio.TimeoutError: + log.warning("shellcheck: timed out on a %d char document", len(text)) + return None + finally: + # Also reached when a newer change cancels this check mid-run. + if proc.returncode is None: + proc.kill() + await proc.wait() + class RuffLS(LanguageServerWebSocketHandler): procargs = ["ruff", "server"] @@ -201,11 +417,14 @@ if __name__ == "__main__": # spawned `ruff server` picks it up. start_ruff_config_poller() + if not SHELLCHECK_BIN: + log.warning("shellcheck is not on PATH: shell scripts will not be linted") + port = int(os.environ.get("PORT", "3001")) app = web.Application( [ (r"/ws/pyright", PyrightLS), - (r"/ws/diagnostic", DiagnosticLS), + (r"/ws/diagnostic", ShellcheckLS), (r"/ws/ruff", RuffLS), (r"/ws/deno", DenoLS), (r"/ws/go", GoLS), diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index b3c6faacf4..0242dc7f04 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.826.0 + version: 1.833.0 title: OpenFlow Spec contact: name: Ruben Fiszel @@ -145,7 +145,7 @@ components: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: "Retry with exponential backoff: the n-th retry waits multiplier × seconds^n" properties: attempts: type: integer @@ -156,7 +156,7 @@ components: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index efec616858..86fc4553ff 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.826.0' + ModuleVersion = '1.833.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 3aa64f361c..a9ab5835de 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.826.0" +version = "1.833.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill/tests/test_workflow.py b/python-client/wmill/tests/test_workflow.py index ceed39bfeb..d32ae1458b 100644 --- a/python-client/wmill/tests/test_workflow.py +++ b/python-client/wmill/tests/test_workflow.py @@ -7,7 +7,7 @@ import pytest from datetime import datetime, timezone -from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async +from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, task_script, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async class _FakeItems(dict): @@ -752,6 +752,84 @@ class TestChildModeParallel: assert result["result"] == 8 +class TestSdkCallsInsideTaskBody: + """A task body runs as one child job, so the SDK calls it makes cannot be + dispatched as steps of their own.""" + + def _run_child(self, wf, key): + async def bounded(): + return await asyncio.wait_for( + _run_workflow_async(wf, {"completed_steps": {}, "_executing_key": key}, {}), + timeout=2, + ) + + return asyncio.run(bounded()) + + def test_nested_task_and_step_run_in_the_tasks_own_job(self): + @task + async def inner(x: str): + return f"inner:{x}" + + @task + def sync_leaf(x: str): + return f"sync:{x}" + + # A sync body calls its tasks without awaiting, whatever called it. + @task + def sync_inner(x: str): + return sync_leaf(x).upper().lower() + + @task + async def outer(x: str): + via_task = await inner(x) + via_sync = await sync_inner(x) + via_step = await step("s", lambda: "stepped") + return f"{via_task}+{via_sync}+{via_step}" + + @workflow + async def wf(): + return await outer("a") + + result = self._run_child(wf, "outer") + assert result == {"type": "complete", "result": "inner:a+sync:a+stepped"} + + def test_sibling_task_of_the_workflow_body_is_not_run(self): + # The workflow body keeps replaying beside the task body, and its own + # calls must stay parked: running them would execute a sibling task in + # this job. + ran = [] + + @task + async def inner(x: str): + ran.append(x) + return x + + @task + async def outer(x: str): + await asyncio.sleep(0) + return await inner("nested") + + @workflow + async def wf(): + return await asyncio.gather(outer("a"), inner("sibling")) + + result = self._run_child(wf, "outer") + assert result == {"type": "complete", "result": "nested"} + assert ran == ["nested"] + + def test_step_that_would_need_a_dispatch_fails_instead_of_hanging(self): + @task + async def dispatching(): + return await task_script("f/other/script")() + + @workflow + async def wf(): + return await dispatching() + + with pytest.raises(RuntimeError, match="cannot be called from inside a task"): + self._run_child(wf, "dispatching") + + class TestKeyDeterminism: def test_keys_consistent_across_replays(self): r1 = _run_workflow(det_wf, {}, {"n": 3}) diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index 81e5b34e16..824db9741f 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -196,12 +196,15 @@ class Windmill: args: dict = None, scheduled_in_secs: int = None, tag: str = None, + retry: dict = None, ) -> str: """Internal helper for running scripts asynchronously.""" args = args or {} params = {"scheduled_in_secs": scheduled_in_secs} if scheduled_in_secs else {} if tag: params["tag"] = tag + if retry: + params["retry"] = json.dumps(retry) if os.environ.get("WM_JOB_ID"): params["parent_job"] = os.environ.get("WM_JOB_ID") if os.environ.get("WM_ROOT_FLOW_JOB_ID"): @@ -222,9 +225,15 @@ class Windmill: args: dict = None, scheduled_in_secs: int = None, tag: str = None, + retry: dict = None, ) -> str: - """Create a script job by path and return its job id.""" - return self._run_script_async_internal(path=path, args=args, scheduled_in_secs=scheduled_in_secs, tag=tag) + """Create a script job by path and return its job id. + + ``retry`` re-runs the job when it fails, with the policy a schedule takes, + e.g. ``{"constant": {"attempts": 3, "seconds": 10}}``. The returned id is + the first attempt's; each retry is a job of its own, recorded as a child of it. + """ + return self._run_script_async_internal(path=path, args=args, scheduled_in_secs=scheduled_in_secs, tag=tag, retry=retry) def run_script_by_hash_async( self, @@ -232,9 +241,10 @@ class Windmill: args: dict = None, scheduled_in_secs: int = None, tag: str = None, + retry: dict = None, ) -> str: - """Create a script job by hash and return its job id.""" - return self._run_script_async_internal(hash_=hash_, args=args, scheduled_in_secs=scheduled_in_secs, tag=tag) + """Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``.""" + return self._run_script_async_internal(hash_=hash_, args=args, scheduled_in_secs=scheduled_in_secs, tag=tag, retry=retry) def run_flow_async( self, @@ -813,6 +823,22 @@ class Windmill: else: return r.json() + def worker_is_draining(self, job_id: Optional[str] = None) -> bool: + """Whether the worker running the job has received its shutdown signal. + + A draining worker waits for its current job to finish before exiting. A long-running + script can poll this and return early so the worker can be replaced. + + Args: + job_id: Job ID (defaults to current WM_JOB_ID) + + Returns: + True if the worker running the job is draining + """ + job_id = job_id or os.environ.get("WM_JOB_ID") + + return self.get(f"/w/{self.workspace}/jobs/worker_is_draining/{job_id}").json() + def set_flow_user_state(self, key: str, value: Any) -> None: """Set the user state of a flow at a given key""" flow_id = self.get_root_job_id() @@ -1618,6 +1644,7 @@ def run_script_by_path_async( args: Dict[str, Any] = None, scheduled_in_secs: Union[None, int] = None, tag: str = None, + retry: Dict[str, Any] = None, ) -> str: """Create a script job by path and return its job ID. @@ -1626,6 +1653,8 @@ def run_script_by_path_async( args: Script arguments scheduled_in_secs: Delay before execution in seconds tag: Override the worker tag the job runs on + retry: Re-run the job when it fails, with the policy a schedule takes, + e.g. ``{"constant": {"attempts": 3, "seconds": 10}}`` Returns: Job ID string @@ -1635,6 +1664,7 @@ def run_script_by_path_async( args=args, scheduled_in_secs=scheduled_in_secs, tag=tag, + retry=retry, ) @@ -1644,6 +1674,7 @@ def run_script_by_hash_async( args: Dict[str, Any] = None, scheduled_in_secs: Union[None, int] = None, tag: str = None, + retry: Dict[str, Any] = None, ) -> str: """Create a script job by hash and return its job ID. @@ -1652,6 +1683,7 @@ def run_script_by_hash_async( args: Script arguments scheduled_in_secs: Delay before execution in seconds tag: Override the worker tag the job runs on + retry: Re-run the job when it fails, see ``run_script_by_path_async`` Returns: Job ID string @@ -1661,6 +1693,7 @@ def run_script_by_hash_async( args=args, scheduled_in_secs=scheduled_in_secs, tag=tag, + retry=retry, ) @@ -2010,6 +2043,17 @@ def get_progress(job_id: Optional[str] = None) -> Any: return _client.get_progress(job_id) +@init_global_client +def worker_is_draining(job_id: Optional[str] = None) -> bool: + """ + Whether the worker running this job has received its shutdown signal. + + A draining worker waits for its current job to finish before exiting. A long-running + script can poll this and return early so the worker can be replaced. + """ + return _client.worker_is_draining(job_id) + + def set_shared_state_pickle(value: Any, path="state.pickle") -> None: """ Set the state in the shared folder using pickle @@ -2722,6 +2766,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]: import asyncio as _asyncio import contextvars as _contextvars +import inspect as _inspect import sys as _sys import traceback as _traceback @@ -2949,6 +2994,64 @@ _workflow_ctx: _contextvars.ContextVar["WorkflowCtx"] = _contextvars.ContextVar( "_workflow_ctx" ) +# Set while a task body runs in its child job: ``"async"`` or ``"sync"`` after +# the kind of function the body is. A flag on the ctx cannot stand in: under +# ``asyncio.gather`` the workflow body keeps replaying beside the task body, and +# its calls must still park rather than run a sibling task's body in this job. +_in_task_body: _contextvars.ContextVar[Optional[str]] = _contextvars.ContextVar( + "_in_task_body", default=None +) + + +def _dispatch_ctx() -> "WorkflowCtx | None": + """The ctx SDK calls dispatch through, or None where they run in place: + outside a workflow, and inside a task body, which is one job and has no + rounds of its own to dispatch or checkpoint a step in.""" + if _in_task_body.get(): + return None + return _workflow_ctx.get(None) + + +async def _resolved(value): + return value + + +def _call_in_task_body(func, caller: str, args, kwargs): + """Run a task called from inside a task body in place, round-tripping its + result as a dispatched one would be. + + The workflow body awaits every task, sync ones included, so an async caller + gets an awaitable back either way; a sync caller gets the value. The scope + is re-marked for the nested body, which may be of the other kind.""" + + async def run_async(coro): + token = _in_task_body.set("async") + try: + return _json_round_trip(await coro) + finally: + _in_task_body.reset(token) + + if _inspect.iscoroutinefunction(func): + return run_async(func(*args, **kwargs)) + token = _in_task_body.set("sync") + try: + result = func(*args, **kwargs) + finally: + _in_task_body.reset(token) + if _asyncio.iscoroutine(result): + return run_async(result) + result = _json_round_trip(result) + return _resolved(result) if caller == "async" else result + + +def _assert_not_inside_task_body(what: str) -> None: + if _in_task_body.get(): + raise RuntimeError( + f"{what} cannot be called from inside a task: a task runs as a single " + "job and cannot suspend or dispatch steps. Call it from the @workflow " + "body instead." + ) + class WorkflowCtx: """Internal context for workflow replay/suspension. @@ -3073,15 +3176,20 @@ class WorkflowCtx: raise _StepSuspend({"mode": "sleep", "key": key, "seconds": seconds, "steps": []}) async def _resolved(self, value): - return value + return await _resolved(value) async def _execute_directly(self, func, **kwargs): + token = _in_task_body.set( + "async" if _inspect.iscoroutinefunction(func) else "sync" + ) try: result = func(**kwargs) if _asyncio.iscoroutine(result): result = await result except Exception as exc: raise _StepFailure(exc) from exc + finally: + _in_task_body.reset(token) raise _StepSuspend({"mode": "step_complete", "steps": [], "result": result}) async def _never_resolve(self): @@ -3323,7 +3431,9 @@ def task( - **v2 (inside @workflow)**: dispatches as a checkpoint step. - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. - - **Standalone**: executes the function body directly. + - **Standalone, or called from inside another v2 task**: executes the + function body directly. A task runs as a single job, so the tasks it + calls run in that same job, without their own options. A task runs as its own job, so its result is always encoded as JSON and decoded back before the caller sees it: a ``datetime`` comes back as a @@ -3403,12 +3513,16 @@ def task( @functools.wraps(func) def wrapper(*args, **kwargs): # WAC v2: inside a @workflow context - ctx = _workflow_ctx.get(None) + ctx = _dispatch_ctx() if ctx is not None: script = task_path if task_path else task_name merged = _merge_args(args, kwargs) return ctx._next_step(task_name, script, func, _task_options=_task_opts, **merged) + caller = _in_task_body.get() + if caller is not None: + return _call_in_task_body(func, caller, args, kwargs) + # WAC v1: running inside a Windmill job but not in a @workflow if ( os.environ.get("WM_JOB_ID") is not None @@ -3487,6 +3601,7 @@ def task_script( _opts = {k: v for k, v in {"timeout": timeout, "tag": tag, "cache_ttl": cache_ttl, "priority": priority, "concurrent_limit": concurrency_limit, "concurrency_key": concurrency_key, "concurrency_time_window_s": concurrency_time_window_s, "retry": _checked_retry(retry)}.items() if v is not None} or None def wrapper(**kwargs): + _assert_not_inside_task_body(f'task_script("{path}")') ctx = _workflow_ctx.get(None) if ctx is not None: return ctx._next_step(name, path, dispatch_type="script", _task_options=_opts, **kwargs) @@ -3526,6 +3641,7 @@ def task_flow( _opts = {k: v for k, v in {"timeout": timeout, "tag": tag, "cache_ttl": cache_ttl, "priority": priority, "concurrent_limit": concurrency_limit, "concurrency_key": concurrency_key, "concurrency_time_window_s": concurrency_time_window_s, "retry": _checked_retry(retry)}.items() if v is not None} or None def wrapper(**kwargs): + _assert_not_inside_task_body(f'task_flow("{path}")') ctx = _workflow_ctx.get(None) if ctx is not None: return ctx._next_step(name, path, dispatch_type="flow", _task_options=_opts, **kwargs) @@ -3560,8 +3676,11 @@ async def step(name: str, fn): ``fn``'s result is encoded as JSON and decoded back before it is returned, so the round that runs the body sees the same types every replay sees: a ``datetime`` comes back as a string, a tuple as a list. + + Called from inside a task body, ``fn`` runs in place and nothing is + checkpointed: a task is a single job and is never replayed. """ - ctx: WorkflowCtx | None = _workflow_ctx.get(None) + ctx = _dispatch_ctx() if ctx is not None: return await ctx._run_inline_step(name, fn) result = fn() @@ -3576,9 +3695,9 @@ async def sleep(seconds: int): """Server-side sleep — suspend the workflow for the given duration without holding a worker. Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. - Outside a workflow, falls back to ``asyncio.sleep``. + Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. """ - ctx: WorkflowCtx | None = _workflow_ctx.get(None) + ctx = _dispatch_ctx() if ctx is not None: return await ctx._sleep(seconds) await _asyncio.sleep(seconds) @@ -3616,6 +3735,7 @@ async def wait_for_approval( await step("notify", lambda: send_email(urls["resume"], urls["cancel"])) result = await wait_for_approval(key="manager", timeout=3600) """ + _assert_not_inside_task_body("wait_for_approval()") ctx: WorkflowCtx | None = _workflow_ctx.get(None) if ctx is not None: return await ctx._wait_for_approval( diff --git a/rust-client/src/client.rs b/rust-client/src/client.rs index 451dc58d85..bf47e7e95a 100644 --- a/rust-client/src/client.rs +++ b/rust-client/src/client.rs @@ -785,6 +785,7 @@ impl Windmill { None, None, None, + None, ) .await? } else { @@ -808,6 +809,7 @@ impl Windmill { None, None, None, + None, ) .await? }; diff --git a/system_prompts/README.md b/system_prompts/README.md index cc16473c6c..a651c48692 100644 --- a/system_prompts/README.md +++ b/system_prompts/README.md @@ -46,6 +46,7 @@ a CLI-only workflow file (`flow-cli.md`, `script-cli.md`, `raw-app-cli.md`). | resources | `getResourcePrompt()` | `resources` | `resources.md` | | workflow-as-code | `getWorkflowAsCodePrompt(lang)` | `write-workflow-as-code` | `workflow-as-code.md`, the WAC SDK | | pipeline | `getPipelinePrompt()` | `write-pipeline` | `pipeline-base.md` | +| data tables | `getDatatablePrompt()` | `datatables` | `datatable.md` | | triggers, schedules, preview, CLI commands | — | same names | CLI only | The app chat embeds `RAW_APP_BASE` directly (`chat/app/core.ts`). diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 7b13702da6..a1daf47e32 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -30,7 +30,7 @@ app related commands - `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement - `--port ` - Port to run the dev server on (will find next available port if occupied) - `--host ` - Host to bind the dev server to - - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) + - `--entry ` - Entry point file (default: index.ts for Svelte, index.tsx otherwise) - `--no-open` - Don't automatically open the browser - `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability @@ -41,7 +41,7 @@ app related commands - `app new` - create a new raw app from a template - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. - - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode. + - `--framework ` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode. - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured. - `--schema ` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it. - `--overwrite` - Overwrite the target directory if it already exists, without prompting. @@ -727,6 +727,8 @@ user related commands **Subcommands:** +- `user list` - List the users of the workspace + - `--json` - Output as JSON (for piping to jq) - `user add [password:string]` - Create a user - `--superadmin` - Specify to make the new user superadmin. - `--company ` - Specify to set the company of the new user. diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index 7a965a17d8..e5dd862a3b 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -284,6 +284,10 @@ can call one, not an agent used as a tool. - `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope - `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`) - `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`) +- After a suspend step (approval), the next step's javascript expressions also get: + - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: "SuspendedDisapproved" | "SuspendedTimedOut", message } }` instead + - `resumes` - every resume payload, in arrival order + - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `"anonymous"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined ## Loop Structure Rules @@ -594,4 +598,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { : answer },\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults..output..choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"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 +{"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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { : answer },\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults..output..choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"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/system_prompts/auto-generated/index.d.ts b/system_prompts/auto-generated/index.d.ts index d77f699a33..8ba83f7f74 100644 --- a/system_prompts/auto-generated/index.d.ts +++ b/system_prompts/auto-generated/index.d.ts @@ -4,5 +4,6 @@ export declare function getFlowPrompt(): string; export declare function getResourcePrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; +export declare function getDatatablePrompt(): string; export declare function getDatatableSdkReference(language?: string): string; export declare function getWorkflowAsCodePrompt(language?: string): string; diff --git a/system_prompts/auto-generated/index.ts b/system_prompts/auto-generated/index.ts index ade95efe9e..60bb79b031 100644 --- a/system_prompts/auto-generated/index.ts +++ b/system_prompts/auto-generated/index.ts @@ -73,6 +73,13 @@ export function getPipelinePrompt(): string { ].filter(Boolean).join('\n\n'); } +// Helper for data table schema changes, migrations and permissions (chat consumers) +export function getDatatablePrompt(): string { + return [ + prompts.DATATABLE_BASE + ].filter(Boolean).join('\n\n'); +} + // Helper to get the datatable SQL SDK reference (wmill.datatable()). // Pass a language to get only that SDK; omit it to get both. export function getDatatableSdkReference(language?: string): string { diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index e74555504e..cfdde52495 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -5,6 +5,7 @@ export declare const FLOW_BASE: string; export declare const RESOURCES_BASE: string; export declare const RAW_APP_BASE: string; export declare const PIPELINE_BASE: string; +export declare const DATATABLE_BASE: string; export declare const WORKFLOW_AS_CODE_BASE: string; export declare const FLOW_CHAT_SPECIAL_MODULES: string; export declare const SDK_TYPESCRIPT: string; diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 8f6a2cc2c1..226e1d89c0 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -311,6 +311,10 @@ can call one, not an agent used as a tool. - \`results.step_id.property\` - Access specific property from a previous step output only when that step result is in scope - \`flow_input.iter.value\` - Current iteration value inside a \`forloopflow\`; in a \`whileloopflow\` it is just the iteration index (a plain number, same as \`flow_input.iter.index\`) - \`flow_input.iter.index\` - Current loop index when inside a loop (\`forloopflow\` or \`whileloopflow\`) +- After a suspend step (approval), the next step's javascript expressions also get: + - \`resume\` - the last resume payload (the resume form values); with \`continue_on_disapprove_timeout\`, a disapproval or timeout yields \`{ error: { name: "SuspendedDisapproved" | "SuspendedTimedOut", message } }\` instead + - \`resumes\` - every resume payload, in arrival order + - \`approvers\` - a plain array of strings, one per resume: for a resume through the \`resume\`/\`cancel\` URL from \`getResumeUrls(approver)\`, the \`approver\` passed there if any; otherwise (including the \`approvalPage\` link) the resuming user's username, else \`"anonymous"\`, e.g. \`approvers[0]\`. It is NOT the \`{ resume_id, approver }\` objects stored in the flow status, so \`approvers[0].approver\` is always undefined ## Loop Structure Rules @@ -886,7 +890,7 @@ def main(db: postgresql): export const RAW_APP_BASE = `# Windmill Raw Apps -Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. +Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables. ## App shape @@ -902,7 +906,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t ### Framework -Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for. +Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead. ### Data setup @@ -935,7 +939,7 @@ An app the user just started from the new-app dialog is already set up: its file ### Entrypoint -The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`
\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` / \`App.vue\` and keep the entrypoint as the mount shim. +The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`
\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` and keep the entrypoint as the mount shim. React (\`index.tsx\`): @@ -947,10 +951,12 @@ 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')\`. +Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`. **Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`. +**Set the browser tab title with \`document.title = '...'\`** (or a \`\` the framework renders into \`<head>\`). The app has no \`index.html\` of its own, so a \`<title>\` written there is never served; the deployed app's page shows whatever \`document.title\` holds, and follows it when it changes. + **Always begin every React file (\`.tsx\`/\`.jsx\`) that uses JSX with \`import React from 'react'\`.** esbuild uses the classic JSX transform, so \`React\` must be in scope wherever JSX appears — a missing import compiles fine but throws \`React is not defined\` at runtime, leaving a blank screen. ### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`) @@ -1020,16 +1026,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`windmill-chat\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`@windmill-labs/chat-sdk\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. \`\`\`tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() \`\`\` -\`windmill-chat/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`windmill-chat/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. +\`@windmill-labs/chat-sdk/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`@windmill-labs/chat-sdk/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -1112,6 +1118,15 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. When a deploy widens who may open the app, tell the user in plain words. +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. + +## Reading data as the viewer + +A backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features. +Set it with \`viewerDataAccess: true\` on the backend runnable. + ## Data Tables 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. @@ -1123,6 +1138,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 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\`. +6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see "Reading data as the viewer"). Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no \`data.roles\` entry. Data table roles are an Enterprise Edition feature, self-hosted only. ### Querying in TypeScript (Bun/Deno) @@ -1174,7 +1190,7 @@ def main(user_id: str): 7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. `; export const PIPELINE_BASE = `# Data pipeline authoring @@ -1186,10 +1202,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step 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://<name>/<table>\` (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. +- \`bun\` / \`python3\` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — 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. +## Ingesting from Python / TypeScript + +A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with \`wmill.ducklake()\`, or into a data table with \`wmill.datatable()\`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table. + +Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema: + +\`\`\`python +# pipeline +# on schedule +import wmill + +def main(): + rows = fetch_charges() # list[dict] + lake = wmill.ducklake() + lake.query(""" + CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true) + """, rows=rows).fetch() +\`\`\` + +\`\`\`ts +// pipeline +// on schedule +import * as wmill from "windmill-client" + +export async function main() { + const rows = await fetchCharges() + const lake = wmill.ducklake() + await lake\`CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json(\${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)\`.fetch() +} +\`\`\` + +- Use \`INSERT INTO <table> SELECT ...\` instead of \`CREATE OR REPLACE\` to append (an incremental pull), with a one-time \`CREATE TABLE IF NOT EXISTS\` first. +- Assign the client to a variable (\`lake = wmill.ducklake()\`) and keep the SQL a **string literal** (Python) or a template without \`sql.raw\` (TS): that is what lets the graph record the node's write to \`ducklake://main/<table>\`. A chained \`wmill.ducklake().query(...)\` or SQL built in a variable records no output, so the downstream edge never forms. +- \`wmill.ducklake("<name>")\` targets a non-default catalog; \`wmill.datatable("<name>")\` writes a Postgres data table the same way (Python \`db.query(sql, *args)\` with \`$1\` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above). +- The downstream node triggers on the table itself: \`-- on ducklake://main/stripe_charges\`. + ## 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. @@ -1232,7 +1286,7 @@ The key must be a **string literal** — the graph parser is static and cannot f ## Materialize (the managed output) -> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). 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. +> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). 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 table itself with \`wmill.ducklake()\` (see "Ingesting from Python / TypeScript") and let the output 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://<warehouse>/<schema>/<name>\`, where \`<warehouse>\` 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://<warehouse>/<schema>/<name>\` fires when the ingestion node completes. @@ -1291,6 +1345,47 @@ FROM ducklake.main.orders GROUP BY 1 \`\`\` `; +export const DATATABLE_BASE = `# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, \`main\` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an \`up\` and an optional \`down\`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in \`BEGIN; ... END;\` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as \`CREATE INDEX CONCURRENTLY\`: such a migration can fail half applied. +- Give it a \`down\` that undoes the \`up\` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (\`CREATE SCHEMA IF NOT EXISTS\`), and point what you are building at it. A raw app names its schema in its \`data\` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. \`admin\` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (\`wmill.datatable('main', { role: 'analyst' })\`, \`role='analyst'\` in Python, \`datatable://main?role=analyst\`) and holds that role alone, or names none and is resolved from the data table's default role; +- \`permission denied\` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to \`admin\` to get past it; +- a migration runs as the role its SQL declares on its first line, \`-- role <name>\`, the same one in its \`down\`. One that declares none runs as \`admin\`, which only those who may use \`admin\` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. +`; + export const WORKFLOW_AS_CODE_BASE = `# Windmill Workflow-as-Code Writing Guide ## Scope @@ -1385,6 +1480,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or \`step()\` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. \`sleep()\` there waits in the job, and \`taskScript()\` / \`taskFlow()\` / \`waitForApproval()\` (\`task_script()\` / \`task_flow()\` / \`wait_for_approval()\`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: \`\`\`typescript @@ -1648,21 +1745,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * \`{ scheduledInSeconds?, tag?, retry? }\`, e.g. \`{ retry: { constant: { attempts: 3, seconds: 10 } } }\` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a flow asynchronously by its path @@ -1718,6 +1817,14 @@ async setProgress(percent: number, jobId?: any): Promise<void> */ async getProgress(jobId?: any): Promise<number | null> +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise<boolean> + /** * Set a flow user state * @param key key of the state @@ -1977,6 +2084,9 @@ async sleep(seconds: number): Promise<void> * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> @@ -2210,10 +2320,14 @@ def post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response def create_token(duration = dt.timedelta(days=1)) -> str # Create a script job by path and return its job id. -def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# +# \`\`retry\`\` re-runs the job when it fails, with the policy a schedule takes, +# e.g. \`\`{"constant": {"attempts": 3, "seconds": 10}}\`\`. The returned id is +# the first attempt's; each retry is a job of its own, recorded as a child of it. +def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str -# Create a script job by hash and return its job id. -def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# Create a script job by hash and return its job id. See \`\`run_script_by_path_async\`\` for \`\`retry\`\`. +def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str # Create a flow job and return its job id. def run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str @@ -2386,6 +2500,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None @@ -2803,7 +2929,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -2889,12 +3017,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. @@ -3016,8 +3147,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a \`workflow()\`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a \`Date\` comes back as a string, a @@ -3061,6 +3193,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> @@ -3159,7 +3294,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -3245,12 +3382,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. @@ -3475,7 +3615,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (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.<step_id>'"}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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/<flow_path>-<step_id>"},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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.<its_own_id>' 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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"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-<type>' (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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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.<step_id>'"}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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/<flow_path>-<step_id>"},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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.<its_own_id>' 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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"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"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands @@ -3509,7 +3649,7 @@ app related commands - \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement - \`--port <port:number>\` - Port to run the dev server on (will find next available port if occupied) - \`--host <host:string>\` - Host to bind the dev server to - - \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) + - \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte, index.tsx otherwise) - \`--no-open\` - Don't automatically open the browser - \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability @@ -3520,7 +3660,7 @@ app related commands - \`app new\` - create a new raw app from a template - \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. - - \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode. + - \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode. - \`--datatable <datatable:string>\` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured. - \`--schema <schema:string>\` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it. - \`--overwrite\` - Overwrite the target directory if it already exists, without prompting. @@ -4206,6 +4346,8 @@ user related commands **Subcommands:** +- \`user list\` - List the users of the workspace + - \`--json\` - Output as JSON (for piping to jq) - \`user add <email:string> [password:string]\` - Create a user - \`--superadmin\` - Specify to make the new user superadmin. - \`--company <company:string>\` - Specify to set the company of the new user. @@ -5726,6 +5868,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` +## Array parameters + +Declare a list with a \`[]\` suffix on the element type (\`(varchar[])\`, +\`(int[])\`), or \`(array)\` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +\`PARSE_JSON(?)\`, and cast to \`ARRAY\` where a function expects one. + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +\`\`\` + +Or join on the elements with \`FLATTEN\`: + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +\`\`\` + ## Receiving an S3Object as a script parameter Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for diff --git a/system_prompts/auto-generated/schemas/amqp_trigger.schema.yaml b/system_prompts/auto-generated/schemas/amqp_trigger.schema.yaml index 70a955d16d..5d5b0f8a0d 100644 --- a/system_prompts/auto-generated/schemas/amqp_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/amqp_trigger.schema.yaml @@ -84,7 +84,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -95,7 +96,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/azure_trigger.schema.yaml b/system_prompts/auto-generated/schemas/azure_trigger.schema.yaml index d21d006e8d..e57c92ebc3 100644 --- a/system_prompts/auto-generated/schemas/azure_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/azure_trigger.schema.yaml @@ -71,7 +71,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -82,7 +83,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/email_trigger.schema.yaml b/system_prompts/auto-generated/schemas/email_trigger.schema.yaml index 1d4e81ac2c..79786fc8db 100644 --- a/system_prompts/auto-generated/schemas/email_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/email_trigger.schema.yaml @@ -54,7 +54,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -65,7 +66,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/gcp_trigger.schema.yaml b/system_prompts/auto-generated/schemas/gcp_trigger.schema.yaml index f35b33ad74..6755d6545c 100644 --- a/system_prompts/auto-generated/schemas/gcp_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/gcp_trigger.schema.yaml @@ -94,7 +94,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -105,7 +106,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/http_trigger.schema.yaml b/system_prompts/auto-generated/schemas/http_trigger.schema.yaml index f484366783..b428fbf390 100644 --- a/system_prompts/auto-generated/schemas/http_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/http_trigger.schema.yaml @@ -133,7 +133,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -144,7 +145,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml b/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml index 09217692e5..6549ce1b10 100644 --- a/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/kafka_trigger.schema.yaml @@ -135,7 +135,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -146,7 +147,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/mqtt_trigger.schema.yaml b/system_prompts/auto-generated/schemas/mqtt_trigger.schema.yaml index 2801cc9a73..f167e2965f 100644 --- a/system_prompts/auto-generated/schemas/mqtt_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/mqtt_trigger.schema.yaml @@ -96,7 +96,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -107,7 +108,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/nats_trigger.schema.yaml b/system_prompts/auto-generated/schemas/nats_trigger.schema.yaml index 239d568fcc..e7620683e6 100644 --- a/system_prompts/auto-generated/schemas/nats_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/nats_trigger.schema.yaml @@ -70,7 +70,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -81,7 +82,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/postgres_trigger.schema.yaml b/system_prompts/auto-generated/schemas/postgres_trigger.schema.yaml index be196a7bc3..79cca28595 100644 --- a/system_prompts/auto-generated/schemas/postgres_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/postgres_trigger.schema.yaml @@ -63,7 +63,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -74,7 +75,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/schedule.schema.yaml b/system_prompts/auto-generated/schemas/schedule.schema.yaml index d0ea85d6fe..7097be7d25 100644 --- a/system_prompts/auto-generated/schemas/schedule.schema.yaml +++ b/system_prompts/auto-generated/schemas/schedule.schema.yaml @@ -69,7 +69,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -80,7 +81,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/sqs_trigger.schema.yaml b/system_prompts/auto-generated/schemas/sqs_trigger.schema.yaml index 4f41c32421..32d6400973 100644 --- a/system_prompts/auto-generated/schemas/sqs_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/sqs_trigger.schema.yaml @@ -69,7 +69,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -80,7 +81,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/schemas/websocket_trigger.schema.yaml b/system_prompts/auto-generated/schemas/websocket_trigger.schema.yaml index 780b0f2245..8184d3815a 100644 --- a/system_prompts/auto-generated/schemas/websocket_trigger.schema.yaml +++ b/system_prompts/auto-generated/schemas/websocket_trigger.schema.yaml @@ -172,7 +172,8 @@ properties: description: Seconds to wait between retries exponential: type: object - description: Retry with exponential backoff (delay doubles each time) + description: 'Retry with exponential backoff: the n-th retry waits multiplier + × seconds^n' properties: attempts: type: integer @@ -183,7 +184,8 @@ properties: seconds: type: integer minimum: 1 - description: Initial delay in seconds + description: Base of the exponential (seconds); the n-th retry waits multiplier + × seconds^n, where n counts constant retries too random_factor: type: integer minimum: 0 diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 0517cf1897..d178ddb6c0 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1409,6 +1409,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Array parameters + +Declare a list with a `[]` suffix on the element type (`(varchar[])`, +`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one. + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +``` + +Or join on the elements with `FLATTEN`: + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +``` + ## Receiving an S3Object as a script parameter Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for @@ -1567,21 +1588,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a flow asynchronously by its path @@ -1637,6 +1660,14 @@ async setProgress(percent: number, jobId?: any): Promise<void> */ async getProgress(jobId?: any): Promise<number | null> +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise<boolean> + /** * Set a flow user state * @param key key of the state @@ -1896,6 +1927,9 @@ async sleep(seconds: number): Promise<void> * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> @@ -2129,10 +2163,14 @@ def post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response def create_token(duration = dt.timedelta(days=1)) -> str # Create a script job by path and return its job id. -def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# +# ``retry`` re-runs the job when it fails, with the policy a schedule takes, +# e.g. ``{"constant": {"attempts": 3, "seconds": 10}}``. The returned id is +# the first attempt's; each retry is a job of its own, recorded as a child of it. +def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str -# Create a script job by hash and return its job id. -def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``. +def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str # Create a flow job and return its job id. def run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str @@ -2305,6 +2343,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None @@ -2722,7 +2772,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -2808,12 +2860,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 97db78b3d4..1964c88685 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -60,10 +60,14 @@ def post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response def create_token(duration = dt.timedelta(days=1)) -> str # Create a script job by path and return its job id. -def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# +# ``retry`` re-runs the job when it fails, with the policy a schedule takes, +# e.g. ``{"constant": {"attempts": 3, "seconds": 10}}``. The returned id is +# the first attempt's; each retry is a job of its own, recorded as a child of it. +def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str -# Create a script job by hash and return its job id. -def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``. +def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str # Create a flow job and return its job id. def run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str @@ -236,6 +240,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None @@ -653,7 +669,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -739,12 +757,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 55f59b1f36..da9e79ec94 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -124,21 +124,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a flow asynchronously by its path @@ -194,6 +196,14 @@ async setProgress(percent: number, jobId?: any): Promise<void> */ async getProgress(jobId?: any): Promise<number | null> +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise<boolean> + /** * Set a flow user state * @param key key of the state @@ -453,6 +463,9 @@ async sleep(seconds: number): Promise<void> * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> diff --git a/system_prompts/auto-generated/sdks/wac-python.md b/system_prompts/auto-generated/sdks/wac-python.md index a8e98a9d4f..6dd307dd24 100644 --- a/system_prompts/auto-generated/sdks/wac-python.md +++ b/system_prompts/auto-generated/sdks/wac-python.md @@ -36,7 +36,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -122,12 +124,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/sdks/wac-typescript.md b/system_prompts/auto-generated/sdks/wac-typescript.md index 608d75d18c..dd04a64de0 100644 --- a/system_prompts/auto-generated/sdks/wac-typescript.md +++ b/system_prompts/auto-generated/sdks/wac-typescript.md @@ -67,8 +67,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a `workflow()`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a `Date` comes back as a string, a @@ -112,6 +113,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 48ea832a31..95bbbc657b 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -35,7 +35,7 @@ app related commands - `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement - `--port <port:number>` - Port to run the dev server on (will find next available port if occupied) - `--host <host:string>` - Host to bind the dev server to - - `--entry <entry:string>` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) + - `--entry <entry:string>` - Entry point file (default: index.ts for Svelte, index.tsx otherwise) - `--no-open` - Don't automatically open the browser - `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability @@ -46,7 +46,7 @@ app related commands - `app new` - create a new raw app from a template - `--summary <summary:string>` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - `--path <path:string>` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. - - `--framework <framework:string>` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode. + - `--framework <framework:string>` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode. - `--datatable <datatable:string>` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured. - `--schema <schema:string>` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it. - `--overwrite` - Overwrite the target directory if it already exists, without prompting. @@ -732,6 +732,8 @@ user related commands **Subcommands:** +- `user list` - List the users of the workspace + - `--json` - Output as JSON (for piping to jq) - `user add <email:string> [password:string]` - Create a user - `--superadmin` - Specify to make the new user superadmin. - `--company <company:string>` - Specify to set the company of the new user. diff --git a/system_prompts/auto-generated/skills/datatables/SKILL.md b/system_prompts/auto-generated/skills/datatables/SKILL.md new file mode 100644 index 0000000000..1c1cb9eede --- /dev/null +++ b/system_prompts/auto-generated/skills/datatables/SKILL.md @@ -0,0 +1,46 @@ +--- +name: datatables +description: MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions. +--- + +# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied. +- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + +`wmill datatable migrate new <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d <datatable>` and default to `main`. + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role; +- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it; +- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 2f7cd52d5f..f4dce90269 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -17,7 +17,7 @@ You need three values to run the command: 1. **summary** — a short description of the app 2. **path** — the windmill path, e.g. `f/folder/my_app` or `u/username/my_app` -3. **framework** — one of `react19` (recommended), `react18`, `svelte5`, `vue` +3. **framework** — one of `react19` (recommended), `react18`, `svelte5` **summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths. @@ -89,7 +89,7 @@ my_app__raw_app/ ├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh) ├── raw_app.yaml # App configuration (summary, path, data settings) ├── index.tsx # Frontend entry point -├── App.tsx # Main React/Svelte/Vue component +├── App.tsx # Main React/Svelte component ├── index.css # Styles ├── package.json # Frontend dependencies ├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT) @@ -152,6 +152,8 @@ fields: value: "default_user" ``` +`viewer_data_access: true` makes an inline runnable read data tables and object storage as the person using the app instead of as the app's identity (see "Reading data as the viewer"). + ### Referencing existing scripts To use an existing Windmill script instead of inline code: @@ -188,7 +190,7 @@ data: - `<datatable>/<table>` — Specific table in public schema - `<datatable>/<schema>:<table>` — Table in specific schema -**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of the app's identity, or of whoever is using the app for a runnable with `viewer_data_access: true`, unless that default is `admin`. ## SQL Migrations (sql_to_apply/) @@ -247,7 +249,7 @@ For the rest, tell the user which command fits their intent and let them run it # Windmill Raw Apps -Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. +Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables. ## App shape @@ -263,7 +265,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t ### Framework -Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for. +Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead. ### Data setup @@ -293,7 +295,7 @@ Then build what was chosen: the data table and schema become the app's `data.dat ### Entrypoint -The entrypoint is `index.tsx` for React and `index.ts` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` / `App.vue` and keep the entrypoint as the mount shim. +The entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim. React (`index.tsx`): @@ -305,10 +307,12 @@ import App from './App' createRoot(document.getElementById('root')!).render(<App />) ``` -Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`. Vue (`index.ts`): `createApp(App).mount('#root')`. +Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`. **Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`. +**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes. + **Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen. ### Generated bindings (`wmill.d.ts` / `wmill.ts`) @@ -378,16 +382,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `windmill-chat` to `package.json` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential. ```tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() ``` -`windmill-chat/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `windmill-chat/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. +`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -471,6 +475,23 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen 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. +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +In `raw_app.yaml`: + +```yaml +sandbox: true +frontend_api: [jobs:run, users:read] +``` + +A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. + +## Reading data as the viewer + +A backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features. +Set it with `viewer_data_access: true` in the runnable's `backend/<id>.yaml`. + ## Data Tables 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. @@ -482,6 +503,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 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`. +6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see "Reading data as the viewer"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only. `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`. @@ -535,4 +557,4 @@ def main(user_id: str): 7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use `windmill-chat` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 352fe2ed48..4b0889c3d8 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -371,6 +371,10 @@ can call one, not an agent used as a tool. - `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope - `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`) - `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`) +- After a suspend step (approval), the next step's javascript expressions also get: + - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: "SuspendedDisapproved" | "SuspendedTimedOut", message } }` instead + - `resumes` - every resume payload, in arrival order + - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `"anonymous"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined ## Loop Structure Rules @@ -682,4 +686,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (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.<step_id>'"}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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/<flow_path>-<step_id>"},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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.<its_own_id>' 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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"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-<type>' (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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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.<step_id>'"}},"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"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"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/<flow_path>-<step_id>"},"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","typesafe","cloudflare"]},"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"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"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/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","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"},{"$ref":"#/components/schemas/AiDecision"}],"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","aidecision":"#/components/schemas/AiDecision"}}},"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.<its_own_id>' 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."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"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` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `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"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"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"]}} diff --git a/system_prompts/auto-generated/skills/write-pipeline/SKILL.md b/system_prompts/auto-generated/skills/write-pipeline/SKILL.md index 064dc600ac..4c339e6222 100644 --- a/system_prompts/auto-generated/skills/write-pipeline/SKILL.md +++ b/system_prompts/auto-generated/skills/write-pipeline/SKILL.md @@ -12,10 +12,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step 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://<name>/<table>` (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. +- `bun` / `python3` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — 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. +## Ingesting from Python / TypeScript + +A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with `wmill.ducklake()`, or into a data table with `wmill.datatable()`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table. + +Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema: + +```python +# pipeline +# on schedule +import wmill + +def main(): + rows = fetch_charges() # list[dict] + lake = wmill.ducklake() + lake.query(""" + CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true) + """, rows=rows).fetch() +``` + +```ts +// pipeline +// on schedule +import * as wmill from "windmill-client" + +export async function main() { + const rows = await fetchCharges() + const lake = wmill.ducklake() + await lake`CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json(${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)`.fetch() +} +``` + +- Use `INSERT INTO <table> SELECT ...` instead of `CREATE OR REPLACE` to append (an incremental pull), with a one-time `CREATE TABLE IF NOT EXISTS` first. +- Assign the client to a variable (`lake = wmill.ducklake()`) and keep the SQL a **string literal** (Python) or a template without `sql.raw` (TS): that is what lets the graph record the node's write to `ducklake://main/<table>`. A chained `wmill.ducklake().query(...)` or SQL built in a variable records no output, so the downstream edge never forms. +- `wmill.ducklake("<name>")` targets a non-default catalog; `wmill.datatable("<name>")` writes a Postgres data table the same way (Python `db.query(sql, *args)` with `$1` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above). +- The downstream node triggers on the table itself: `-- on ducklake://main/stripe_charges`. + ## 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. @@ -59,7 +97,7 @@ The key must be a **string literal** — the graph parser is static and cannot f ## Materialize (the managed output) -> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake://<name>/<table>`). 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. +> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake://<name>/<table>`). 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 table itself with `wmill.ducklake()` (see "Ingesting from Python / TypeScript") and let the output 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://<warehouse>/<schema>/<name>`, where `<warehouse>` 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://<warehouse>/<schema>/<name>` fires when the ingestion node completes. diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 4b340c0db1..055b1613b2 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -320,21 +320,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a flow asynchronously by its path @@ -390,6 +392,14 @@ async setProgress(percent: number, jobId?: any): Promise<void> */ async getProgress(jobId?: any): Promise<number | null> +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise<boolean> + /** * Set a flow user state * @param key key of the state @@ -649,6 +659,9 @@ async sleep(seconds: number): Promise<void> * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 03d40ab044..beaf7817bb 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -332,21 +332,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a flow asynchronously by its path @@ -402,6 +404,14 @@ async setProgress(percent: number, jobId?: any): Promise<void> */ async getProgress(jobId?: any): Promise<number | null> +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise<boolean> + /** * Set a flow user state * @param key key of the state @@ -661,6 +671,9 @@ async sleep(seconds: number): Promise<void> * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index b15655a6da..afefb7e987 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -322,21 +322,23 @@ async cancelJob(jobId: string, reason: string | undefined = undefined): Promise< * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ -async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, tag: string | null = null): Promise<string> +async runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string> /** * Run a flow asynchronously by its path @@ -392,6 +394,14 @@ async setProgress(percent: number, jobId?: any): Promise<void> */ async getProgress(jobId?: any): Promise<number | null> +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise<boolean> + /** * Set a flow user state * @param key key of the state @@ -651,6 +661,9 @@ async sleep(seconds: number): Promise<void> * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index b9c2793f47..ee50b6d62d 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -268,10 +268,14 @@ def post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response def create_token(duration = dt.timedelta(days=1)) -> str # Create a script job by path and return its job id. -def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# +# ``retry`` re-runs the job when it fails, with the policy a schedule takes, +# e.g. ``{"constant": {"attempts": 3, "seconds": 10}}``. The returned id is +# the first attempt's; each retry is a job of its own, recorded as a child of it. +def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str -# Create a script job by hash and return its job id. -def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None) -> str +# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``. +def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str # Create a flow job and return its job id. def run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str @@ -444,6 +448,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None @@ -861,7 +877,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -947,12 +965,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md index 8c438e7038..cd79a1f5d4 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -84,6 +84,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Array parameters + +Declare a list with a `[]` suffix on the element type (`(varchar[])`, +`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one. + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +``` + +Or join on the elements with `FLATTEN`: + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +``` + ## Receiving an S3Object as a script parameter Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for diff --git a/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md b/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md index 13d305c74e..09789b388a 100644 --- a/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md +++ b/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md @@ -143,6 +143,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or `step()` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. `sleep()` there waits in the job, and `taskScript()` / `taskFlow()` / `waitForApproval()` (`task_script()` / `task_flow()` / `wait_for_approval()`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: ```typescript @@ -308,8 +310,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a `workflow()`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a `Date` comes back as a string, a @@ -353,6 +356,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>> @@ -451,7 +457,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -537,12 +545,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/base/datatable.md b/system_prompts/base/datatable.md new file mode 100644 index 0000000000..c90fb5e349 --- /dev/null +++ b/system_prompts/base/datatable.md @@ -0,0 +1,43 @@ +# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied. +- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. +<!-- cli-only --> + +`wmill datatable migrate new <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d <datatable>` and default to `main`. +<!-- /cli-only --> + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role; +- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it; +- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index ddb10fd0fa..ff10f38062 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -289,6 +289,10 @@ can call one, not an agent used as a tool. - `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope - `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`) - `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`) +- After a suspend step (approval), the next step's javascript expressions also get: + - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: "SuspendedDisapproved" | "SuspendedTimedOut", message } }` instead + - `resumes` - every resume payload, in arrival order + - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `"anonymous"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined ## Loop Structure Rules diff --git a/system_prompts/base/pipeline-base.md b/system_prompts/base/pipeline-base.md index 01e9797e32..59924faf41 100644 --- a/system_prompts/base/pipeline-base.md +++ b/system_prompts/base/pipeline-base.md @@ -7,10 +7,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step 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://<name>/<table>` (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. +- `bun` / `python3` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — 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. +## Ingesting from Python / TypeScript + +A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with `wmill.ducklake()`, or into a data table with `wmill.datatable()`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table. + +Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema: + +```python +# pipeline +# on schedule +import wmill + +def main(): + rows = fetch_charges() # list[dict] + lake = wmill.ducklake() + lake.query(""" + CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true) + """, rows=rows).fetch() +``` + +```ts +// pipeline +// on schedule +import * as wmill from "windmill-client" + +export async function main() { + const rows = await fetchCharges() + const lake = wmill.ducklake() + await lake`CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json(${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)`.fetch() +} +``` + +- Use `INSERT INTO <table> SELECT ...` instead of `CREATE OR REPLACE` to append (an incremental pull), with a one-time `CREATE TABLE IF NOT EXISTS` first. +- Assign the client to a variable (`lake = wmill.ducklake()`) and keep the SQL a **string literal** (Python) or a template without `sql.raw` (TS): that is what lets the graph record the node's write to `ducklake://main/<table>`. A chained `wmill.ducklake().query(...)` or SQL built in a variable records no output, so the downstream edge never forms. +- `wmill.ducklake("<name>")` targets a non-default catalog; `wmill.datatable("<name>")` writes a Postgres data table the same way (Python `db.query(sql, *args)` with `$1` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above). +- The downstream node triggers on the table itself: `-- on ducklake://main/stripe_charges`. + ## 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. @@ -56,7 +94,7 @@ The key must be a **string literal** — the graph parser is static and cannot f ## Materialize (the managed output) -> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake://<name>/<table>`). 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. +> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake://<name>/<table>`). 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 table itself with `wmill.ducklake()` (see "Ingesting from Python / TypeScript") and let the output 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://<warehouse>/<schema>/<name>`, where `<warehouse>` 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://<warehouse>/<schema>/<name>` fires when the ingestion node completes. diff --git a/system_prompts/base/raw-app-cli.md b/system_prompts/base/raw-app-cli.md index 59ef0acb58..8bd1a23cf0 100644 --- a/system_prompts/base/raw-app-cli.md +++ b/system_prompts/base/raw-app-cli.md @@ -12,7 +12,7 @@ You need three values to run the command: 1. **summary** — a short description of the app 2. **path** — the windmill path, e.g. `f/folder/my_app` or `u/username/my_app` -3. **framework** — one of `react19` (recommended), `react18`, `svelte5`, `vue` +3. **framework** — one of `react19` (recommended), `react18`, `svelte5` **summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths. @@ -84,7 +84,7 @@ my_app__raw_app/ ├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh) ├── raw_app.yaml # App configuration (summary, path, data settings) ├── index.tsx # Frontend entry point -├── App.tsx # Main React/Svelte/Vue component +├── App.tsx # Main React/Svelte component ├── index.css # Styles ├── package.json # Frontend dependencies ├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT) @@ -147,6 +147,8 @@ fields: value: "default_user" ``` +`viewer_data_access: true` makes an inline runnable read data tables and object storage as the person using the app instead of as the app's identity (see "Reading data as the viewer"). + ### Referencing existing scripts To use an existing Windmill script instead of inline code: @@ -183,7 +185,7 @@ data: - `<datatable>/<table>` — Specific table in public schema - `<datatable>/<schema>:<table>` — Table in specific schema -**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of the app's identity, or of whoever is using the app for a runnable with `viewer_data_access: true`, unless that default is `admin`. ## SQL Migrations (sql_to_apply/) diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 03bde6ec71..efe05f2c93 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -1,6 +1,6 @@ # Windmill Raw Apps -Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. +Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables. ## App shape @@ -16,7 +16,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t ### Framework -Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for. +Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead. ### Data setup @@ -54,7 +54,7 @@ An app the user just started from the new-app dialog is already set up: its file ### Entrypoint -The entrypoint is `index.tsx` for React and `index.ts` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` / `App.vue` and keep the entrypoint as the mount shim. +The entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim. React (`index.tsx`): @@ -66,10 +66,12 @@ import App from './App' createRoot(document.getElementById('root')!).render(<App />) ``` -Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`. Vue (`index.ts`): `createApp(App).mount('#root')`. +Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`. **Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`. +**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes. + **Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen. ### Generated bindings (`wmill.d.ts` / `wmill.ts`) @@ -139,16 +141,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `windmill-chat` to `package.json` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential. ```tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() ``` -`windmill-chat/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `windmill-chat/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. +`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -239,6 +241,30 @@ Either one lets those people run the app's backend runnables, so never open an a When a deploy widens who may open the app, tell the user in plain words. <!-- /chat-only --> +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +<!-- cli-only --> +In `raw_app.yaml`: + +```yaml +sandbox: true +frontend_api: [jobs:run, users:read] +``` + +A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. +<!-- /cli-only --> + +## Reading data as the viewer + +A backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features. +<!-- chat-only --> +Set it with `viewerDataAccess: true` on the backend runnable. +<!-- /chat-only --> +<!-- cli-only --> +Set it with `viewer_data_access: true` in the runnable's `backend/<id>.yaml`. +<!-- /cli-only --> + ## Data Tables 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. @@ -250,6 +276,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 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`. +6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see "Reading data as the viewer"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only. <!-- cli-only --> `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`. @@ -305,4 +332,4 @@ def main(user_id: str): 7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use `windmill-chat` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. diff --git a/system_prompts/base/workflow-as-code.md b/system_prompts/base/workflow-as-code.md index 0e529f6803..efc29df360 100644 --- a/system_prompts/base/workflow-as-code.md +++ b/system_prompts/base/workflow-as-code.md @@ -95,6 +95,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or `step()` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. `sleep()` there waits in the job, and `taskScript()` / `taskFlow()` / `waitForApproval()` (`task_script()` / `task_flow()` / `wait_for_approval()`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: ```typescript diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 918deef0fb..055f03d543 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -1696,6 +1696,12 @@ TOPICS: dict[str, dict] = { 'chat_helper': 'getPipelinePrompt', 'parts': ['pipeline-base.md'], }, + 'datatable': { + 'skill': 'datatables', + 'description': "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions.", + 'chat_helper': 'getDatatablePrompt', + 'parts': ['datatable.md'], + }, 'cli_commands': { 'skill': 'cli-commands', 'description': 'MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`.', @@ -1715,6 +1721,7 @@ CHAT_CONSTANTS = { 'resources.md': 'RESOURCES_BASE', 'raw-app.md': 'RAW_APP_BASE', 'pipeline-base.md': 'PIPELINE_BASE', + 'datatable.md': 'DATATABLE_BASE', 'workflow-as-code.md': 'WORKFLOW_AS_CODE_BASE', 'flow-chat-special-modules.md': 'FLOW_CHAT_SPECIAL_MODULES', } @@ -2650,6 +2657,11 @@ export function getPipelinePrompt(): string { __RETURN_pipeline__ } +// Helper for data table schema changes, migrations and permissions (chat consumers) +export function getDatatablePrompt(): string { + __RETURN_datatable__ +} + // Helper to get the datatable SQL SDK reference (wmill.datatable()). // Pass a language to get only that SDK; omit it to get both. export function getDatatableSdkReference(language?: string): string { @@ -2711,6 +2723,7 @@ export declare function getFlowPrompt(): string; export declare function getResourcePrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; +export declare function getDatatablePrompt(): string; export declare function getDatatableSdkReference(language?: string): string; export declare function getWorkflowAsCodePrompt(language?: string): string; """ diff --git a/system_prompts/languages/snowflake.md b/system_prompts/languages/snowflake.md index 8ded76eb3c..b7798e3461 100644 --- a/system_prompts/languages/snowflake.md +++ b/system_prompts/languages/snowflake.md @@ -10,6 +10,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Array parameters + +Declare a list with a `[]` suffix on the element type (`(varchar[])`, +`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one. + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +``` + +Or join on the elements with `FLATTEN`: + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +``` + ## Receiving an S3Object as a script parameter Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index e1ad956dc4..41a8eac37e 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -22,6 +22,6 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, workerIsDraining, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index 8b51f75dfc..f927acd26e 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -46,7 +46,7 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type TaskRetry, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, workerIsDraining, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type TaskRetry, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" # Build default export by combining client utilities + services # This preserves backward compatibility for `import wmill from "windmill-client"` @@ -63,6 +63,7 @@ import { setState, setProgress, getProgress, + workerIsDraining, getState, getIdToken, denoS3LightClientSettings, @@ -153,6 +154,7 @@ const wmill = { setState, setProgress, getProgress, + workerIsDraining, getState, getIdToken, denoS3LightClientSettings, diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 7e84320346..da957239cf 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -11,6 +11,7 @@ import { UserService, KafkaTriggerService, } from "./services.gen"; +import type { Retry } from "./types.gen"; import { OpenAPI } from "./core/OpenAPI"; import { isSuspendSignal, stepErrorMarker, taskErrorFromMarker } from "./wacError"; // import type { DenoS3LightClientSettings } from "./index"; @@ -420,12 +421,26 @@ export async function runScriptAsync( return _runScriptAsyncInternal(path, hash_, args, scheduledInSeconds, tag); } +/** Options for {@link runScriptByPathAsync} and {@link runScriptByHashAsync}, passed in + * place of `scheduledInSeconds`; a positional `tag` is then ignored. */ +export interface RunScriptAsyncOptions { + /** Schedule execution for a future time (in seconds) */ + scheduledInSeconds?: number; + /** Override the worker tag the job runs on */ + tag?: string; + /** Re-run the job when it fails, with the same policy a schedule takes, e.g. + * `{ constant: { attempts: 3, seconds: 10 } }`. The returned id is the first + * attempt's; each retry is a job of its own, recorded as a child of it. */ + retry?: Retry; +} + async function _runScriptAsyncInternal( path: string | null = null, hash_: string | null = null, args: Record<string, any> | null = null, scheduledInSeconds: number | null = null, - tag: string | null = null + tag: string | null = null, + retry: Retry | null = null ): Promise<string> { // Create a script job and return its job id. args = args || {}; @@ -439,6 +454,10 @@ async function _runScriptAsyncInternal( params["tag"] = tag; } + if (retry) { + params["retry"] = JSON.stringify(retry); + } + let parentJobId = getEnv("WM_JOB_ID"); if (parentJobId !== undefined) { params["parent_job"] = parentJobId; @@ -475,34 +494,47 @@ async function _runScriptAsyncInternal( * Run a script asynchronously by its path * @param path - Script path in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ export async function runScriptByPathAsync( path: string, args: Record<string, any> | null = null, - scheduledInSeconds: number | null = null, + scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null ): Promise<string> { - return _runScriptAsyncInternal(path, null, args, scheduledInSeconds, tag); + const o = asyncRunOptions(scheduledInSeconds, tag); + return _runScriptAsyncInternal(path, null, args, o.scheduledInSeconds, o.tag, o.retry); } /** * Run a script asynchronously by its hash * @param hash_ - Script hash in Windmill * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions} + * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }` * @param tag - Override the worker tag the job runs on * @returns Job ID of the created job */ export async function runScriptByHashAsync( hash_: string, args: Record<string, any> | null = null, - scheduledInSeconds: number | null = null, + scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null ): Promise<string> { - return _runScriptAsyncInternal(null, hash_, args, scheduledInSeconds, tag); + const o = asyncRunOptions(scheduledInSeconds, tag); + return _runScriptAsyncInternal(null, hash_, args, o.scheduledInSeconds, o.tag, o.retry); +} + +function asyncRunOptions( + scheduledInSeconds: number | null | RunScriptAsyncOptions, + tag: string | null +): RunScriptAsyncOptions { + return typeof scheduledInSeconds === "object" && scheduledInSeconds !== null + ? scheduledInSeconds + : { scheduledInSeconds: scheduledInSeconds ?? undefined, tag: tag ?? undefined }; } /** @@ -692,6 +724,19 @@ export async function getProgress(jobId?: any): Promise<number | null> { }); } +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +export async function workerIsDraining(jobId?: string): Promise<boolean> { + return await JobService.workerIsDraining({ + id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID", + workspace: getWorkspace(), + }); +} + /** * Set a flow user state * @param key key of the state @@ -1787,6 +1832,52 @@ export function setWorkflowCtx(ctx: WorkflowCtx | null) { Reflect.set(globalThis, "__wmill_wf_ctx", ctx); } +type TaskBodyScope = { run<R>(store: true, fn: () => R): R; getStore(): true | undefined }; + +/** Marks the async context of a task body running in its child job. A flag on + * the ctx cannot stand in: the workflow body keeps replaying beside the task + * body (`Promise.all([a(), b()])`), and its calls must still park rather than + * run a sibling task's body in this job. Kept on `globalThis`, like the ctx, + * so a bundle holding two copies of this module shares one scope. Resolved + * through `process.getBuiltinModule` because a `node:` import would break + * browser bundles of this package. */ +function taskBodyScope(): TaskBodyScope | null { + const existing = Reflect.get(globalThis, "__wmill_wf_task_body"); + if (existing !== undefined) return existing; + let scope: TaskBodyScope | null = null; + try { + const AsyncLocalStorage = (globalThis as any).process?.getBuiltinModule?.("node:async_hooks")?.AsyncLocalStorage; + if (AsyncLocalStorage) scope = new AsyncLocalStorage(); + } catch {} + Reflect.set(globalThis, "__wmill_wf_task_body", scope); + return scope; +} + +function runTaskBody<R>(fn: () => R): R { + const scope = taskBodyScope(); + return scope ? scope.run(true, fn) : fn(); +} + +function insideTaskBody(): boolean { + return taskBodyScope()?.getStore() === true; +} + +/** The ctx SDK calls dispatch through, or null where they run in place: outside + * a workflow, and inside a task body, which is one job and has no rounds of its + * own to dispatch or checkpoint a step in. */ +function dispatchCtx(): WorkflowCtx | null { + if (insideTaskBody()) return null; + return _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); +} + +function assertNotInsideTaskBody(what: string): void { + if (insideTaskBody()) { + throw new Error( + `${what} cannot be called from inside a task: a task runs as a single job and cannot suspend or dispatch steps. Call it from the workflow() body instead.`, + ); + } +} + export class WorkflowCtx { private completed: Record<string, any>; @@ -2259,11 +2350,11 @@ export class WorkflowCtx { } export async function sleep(seconds: number): Promise<void> { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + const ctx = dispatchCtx(); if (ctx) { return ctx._sleep(seconds) as Promise<void>; } - // Outside workflow context, just wait locally + // Outside a workflow, or inside a task body, just wait locally await new Promise((r) => setTimeout(r, seconds * 1000)); } @@ -2274,12 +2365,15 @@ export async function sleep(seconds: number): Promise<void> { * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step<T>( name: string, fn: () => T | Promise<T>, ): Promise<Jsonified<Awaited<T>>> { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + const ctx = dispatchCtx(); if (ctx) { return ctx._runInlineStep(name, fn) as Promise<Jsonified<Awaited<T>>>; } @@ -2297,8 +2391,9 @@ export async function step<T>( * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a `workflow()`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a `Date` comes back as a string, a @@ -2332,7 +2427,7 @@ export function task<T extends (...args: any[]) => Promise<any>>( // .then() which throws StepSuspend and empties pending before the caller // can flush. const wrapper = function (...args: any[]) { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + const ctx = dispatchCtx(); if (ctx) { // Inside a workflow with checkpoint/replay context — dispatch as step const script = taskPath ?? taskName; @@ -2352,7 +2447,7 @@ export function task<T extends (...args: any[]) => Promise<any>>( return (async () => { let result: any; try { - result = await fn(...args); + result = await runTaskBody(() => fn(...args)); } catch (e) { if (isSuspendSignal(e, StepSuspend)) throw e; ctx._raiseStepFailure(e); @@ -2361,7 +2456,7 @@ export function task<T extends (...args: any[]) => Promise<any>>( })(); } return stepResult; - } else if (getEnv("WM_JOB_ID") && !getEnv("WM_FLOW_JOB_ID")) { + } else if (!insideTaskBody() && getEnv("WM_JOB_ID") && !getEnv("WM_FLOW_JOB_ID")) { // Inside a Windmill root job without checkpoint context — v1 HTTP dispatch // WM_FLOW_JOB_ID is set on child jobs, so we skip dispatch for those return (async () => { @@ -2411,7 +2506,8 @@ export function taskScript(path: string, options?: TaskOptions): (...args: any[] assertUsableRetry(options?.retry); const name = path.split("/").pop() || path; const wrapper = function (...args: any[]) { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + assertNotInsideTaskBody(`taskScript("${path}")`); + const ctx = dispatchCtx(); if (ctx) { const kwargs = args.length === 1 && typeof args[0] === "object" && args[0] !== null ? args[0] @@ -2437,7 +2533,8 @@ export function taskFlow(path: string, options?: TaskOptions): (...args: any[]) assertUsableRetry(options?.retry); const name = path.split("/").pop() || path; const wrapper = function (...args: any[]) { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + assertNotInsideTaskBody(`taskFlow("${path}")`); + const ctx = dispatchCtx(); if (ctx) { const kwargs = args.length === 1 && typeof args[0] === "object" && args[0] !== null ? args[0] @@ -2490,7 +2587,8 @@ export function waitForApproval(options?: { skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + assertNotInsideTaskBody("waitForApproval()"); + const ctx = dispatchCtx(); if (!ctx) { throw new Error("waitForApproval can only be called inside a workflow()"); } diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 488d265310..d7a04fff19 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.826.0", + "version": "1.833.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index a517f1ff0f..42ea8acded 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.826.0", + "version": "1.833.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/typescript-client/tests/workflow_nested_task.test.ts b/typescript-client/tests/workflow_nested_task.test.ts new file mode 100644 index 0000000000..39ac2185d8 --- /dev/null +++ b/typescript-client/tests/workflow_nested_task.test.ts @@ -0,0 +1,91 @@ +/** + * A task body runs as one child job, so the SDK calls it makes cannot be + * dispatched as steps of their own. Against the real client. + * + * Run with: bun test typescript-client/tests/workflow_nested_task.test.ts + */ +import { expect, test, describe, mock, afterEach } from "bun:test"; + +mock.module("../services.gen", () => ({ + ResourceService: {}, + VariableService: {}, + JobService: {}, + HelpersService: {}, + AppService: {}, + MetricsService: {}, + OidcService: {}, + UserService: {}, + KafkaTriggerService: {}, +})); +mock.module("../core/OpenAPI", () => ({ + OpenAPI: { BASE: "http://localhost:8000/api", TOKEN: "tok" }, +})); + +const { WorkflowCtx, StepSuspend, task, taskScript, step, setWorkflowCtx } = await import( + "../client.ts" +); + +let ran: string[] = []; + +const inner = task(async function inner(x: string) { + ran.push("inner"); + return `inner:${x}`; +}); + +const outer = task(async function outer(x: string) { + ran.push("outer"); + await Promise.resolve(); + const viaTask = await inner(x); + const viaStep = await step("s", () => "stepped"); + return `${viaTask}+${viaStep}`; +}); + +const dispatching = task(async function dispatching() { + return await taskScript("f/other/script")({}); +}); + +/** Run `body` as the child round executing `key`, and hand back what it raised. */ +async function childRound(key: string, body: () => Promise<unknown>): Promise<any> { + setWorkflowCtx(new WorkflowCtx({ completed_steps: {}, _executing_key: key })); + const hung = new Promise((r) => setTimeout(() => r("hung"), 500)); + return await Promise.race([body().then(() => "returned", (e) => e), hung]); +} + +afterEach(() => { + ran = []; + setWorkflowCtx(null); +}); + +describe("SDK calls inside a task body", () => { + test("a nested task and step run in the task's own job", async () => { + const raised = await childRound("outer", async () => { + await outer("a"); + }); + + expect(raised).toBeInstanceOf(StepSuspend); + expect(raised.dispatchInfo).toMatchObject({ + mode: "step_complete", + result: "inner:a+stepped", + }); + }); + + // The workflow body keeps replaying beside the task body, and its own calls + // must stay parked: running them would execute a sibling task in this job. + test("a sibling task of the workflow body is not run", async () => { + const raised = await childRound("outer", async () => { + await Promise.all([outer("a"), inner("sibling")]); + }); + + expect(raised.dispatchInfo).toMatchObject({ mode: "step_complete" }); + expect(ran).toEqual(["outer", "inner"]); + }); + + test("a step that would need a dispatch fails instead of hanging", async () => { + const raised = await childRound("dispatching", async () => { + await dispatching(); + }); + + expect(raised).toBeInstanceOf(Error); + expect(raised.message).toContain("cannot be called from inside a task"); + }); +}); diff --git a/version.txt b/version.txt index 47ce201f9f..d032a2f6fc 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.826.0 +1.833.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 0c496bc161..61b9f70fd1 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.826.0", + "version": "1.833.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.826.0", + "version": "1.833.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index da525da3b8..d14defa2ad 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.826.0", + "version": "1.833.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",