diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index b0b2ebb77a..161df09227 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -319,4 +319,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,sqs_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,sqs_trigger --all -- --nocapture --test-threads=10 diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 354e8a60ca..55036776c6 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -40,11 +40,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index d731faafd9..c83cbc90d3 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -13,7 +13,8 @@ on: jobs: notify_discord_when_pr_opened: - if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') + # No thread is opened for Dependabot PRs, so nothing downstream may assume one. + if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') && (github.event.pull_request.user.login != 'dependabot[bot]') uses: ./.github/workflows/shareable-discord-notification.yml with: PR_TITLE: ${{ github.event.pull_request.title }} @@ -28,7 +29,8 @@ jobs: DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} merge_success_emoji: - if: github.event.action == 'closed' + # Must match open_thread's exclusion: this reacts to a thread that was never created. + if: github.event.action == 'closed' && github.event.pull_request.user.login != 'dependabot[bot]' uses: ./.github/workflows/shareable-discord-notification.yml with: PR_STATUS: "merged" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index a8b20623bc..c94521d332 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -40,6 +40,11 @@ jobs: runs-on: ubicloud if: (github.event_name != 'workflow_dispatch') || (github.event.inputs && !github.event.inputs.ee) + # Jobs that build from, extract or retag this image must reference it by + # this digest: concurrent main-branch runs push :dev too, so a tag run + # reading :dev can ship a main build under the release version. + outputs: + digest: ${{ steps.docker_build.outputs.digest }} steps: - uses: actions/checkout@v4 with: @@ -112,6 +117,8 @@ jobs: build_ee: runs-on: ubicloud if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee + outputs: + digest: ${{ steps.docker_build.outputs.digest }} steps: - uses: actions/checkout@v4 with: @@ -192,31 +199,31 @@ jobs: - uses: actions/checkout@v4 - run: | # pulling docker image with desired arch so that actions-docker-extract doesn't do it - docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} - docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} + docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} + docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} - run: | # Checks the image is in docker prior to running actions-docker-extract. It fails if not # Also useful to visually check that the arch is the right opencontainers - docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} - docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} + docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} + docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} - uses: shrink/actions-docker-extract@v3 id: extract with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} path: "/usr/src/app/windmill" - uses: shrink/actions-docker-extract@v3 id: extract-duckdb-ffi-internal with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so" - uses: shrink/actions-docker-extract@v3 id: extract-ee with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} path: "/usr/src/app/windmill" - name: Rename binary with corresponding architecture @@ -373,8 +380,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Tag main and latest run: | - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main - uses: sigstore/cosign-installer@v4.1.2 if: startsWith(github.ref, 'refs/tags/v') @@ -409,8 +416,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Tag main and latest for ee run: | - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main - uses: sigstore/cosign-installer@v4.1.2 if: startsWith(github.ref, 'refs/tags/v') @@ -447,12 +454,18 @@ jobs: dockerhub-user: windmilllabs dockerhub-password: ${{ secrets.DOCKER_PAT }} + # Filed under main, not under the release tag this job runs on. The scanned image is + # `-ee:main` and the Security tab only reads the default branch, so tag-only uploads + # left main's last Docker Scout result at 2025-01-29: the tool showed as failing and + # its alerts could never close. `sha` is the release commit, which is on main. - name: Upload SARIF result id: upload-sarif if: ${{ github.event_name != 'pull_request_target' }} - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: sarif.output.json + ref: refs/heads/main + sha: ${{ github.sha }} # docker_scout_ee: # runs-on: ubicloud @@ -549,6 +562,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileCuda" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | @@ -602,6 +617,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileSlim" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | @@ -656,6 +673,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileSlimEe" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | @@ -709,6 +728,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileFull" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} tags: | ${{ steps.meta-public.outputs.tags }} labels: | @@ -761,6 +782,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileFullEe" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index a89275cfb5..a18dc28ff2 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -38,11 +38,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 4bfb5b4147..5e5c6a8c61 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -37,11 +37,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 23ae9b3ffa..6dc5574962 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.815.0" + ".": "1.817.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 820f2c5d6c..595426d619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## [1.817.0](https://github.com/windmill-labs/windmill/compare/v1.816.0...v1.817.0) (2026-09-22) + + +### Features + +* add workspace mentions to AI composer ([#11264](https://github.com/windmill-labs/windmill/issues/11264)) ([e7ba595](https://github.com/windmill-labs/windmill/commit/e7ba5954b69514d72c140f9b1e697fb2236df23e)) +* allow workspace-scoped dynamic custom tags ([#11283](https://github.com/windmill-labs/windmill/issues/11283)) ([efa7a0a](https://github.com/windmill-labs/windmill/commit/efa7a0a70af43f8827542059dd450326a9b88d62)) +* check dynamic worker tags on the tag they resolve to ([#11271](https://github.com/windmill-labs/windmill/issues/11271)) ([69fafb9](https://github.com/windmill-labs/windmill/commit/69fafb9262f90184e6c2dc9a84f1c3feeb9d9638)) +* deploy from the UI to a workspace on another instance ([#11245](https://github.com/windmill-labs/windmill/issues/11245)) ([4d12ea4](https://github.com/windmill-labs/windmill/commit/4d12ea461454f89367bd91daa7e8c39a89035507)) + + +### Bug Fixes + +* add missing credential fields to settings redaction lists ([#11272](https://github.com/windmill-labs/windmill/issues/11272)) ([2a40f25](https://github.com/windmill-labs/windmill/commit/2a40f25bda81d41b9f578338c85656744f59ebfc)) +* apply SSRF validation to workspace webhook URLs ([#11285](https://github.com/windmill-labs/windmill/issues/11285)) ([586699c](https://github.com/windmill-labs/windmill/commit/586699c4938b5203920a43e69a706310078b93f5)) +* apply token path scopes to the native trigger list ([#11281](https://github.com/windmill-labs/windmill/issues/11281)) ([faf7b22](https://github.com/windmill-labs/windmill/commit/faf7b22be0827738c90c1f3ed6cd3e214d6c63d3)) +* check workflow-as-code task tags against CUSTOM_TAGS ([#11273](https://github.com/windmill-labs/windmill/issues/11273)) ([2699d50](https://github.com/windmill-labs/windmill/commit/2699d5065ea3dfe985636733fb6022ca23187291)) +* clear the impersonation token from session storage on logout ([#11274](https://github.com/windmill-labs/windmill/issues/11274)) ([9936673](https://github.com/windmill-labs/windmill/commit/9936673ee62fb6dd929a654a6138aeca687c0fb7)) +* enforce token path scopes on GET /raw_apps/list ([#11284](https://github.com/windmill-labs/windmill/issues/11284)) ([9ad2c91](https://github.com/windmill-labs/windmill/commit/9ad2c91ddb661ab60ada9ec4039ef8d025602969)) +* keep a cancel that lands after a worker last read its queue row ([#11246](https://github.com/windmill-labs/windmill/issues/11246)) ([02006f3](https://github.com/windmill-labs/windmill/commit/02006f364a4217e967435d9e71d8617c03b65413)) +* size the AI chat output budget by model, not provider ([#11286](https://github.com/windmill-labs/windmill/issues/11286)) ([7d0f2ce](https://github.com/windmill-labs/windmill/commit/7d0f2ce3bf920a68f1ea22d59481d239947f1b51)) +* stop cutting DeepSeek chat turns off mid-thought ([#11282](https://github.com/windmill-labs/windmill/issues/11282)) ([5821700](https://github.com/windmill-labs/windmill/commit/58217006ece56bbaf0447af67cac487e72b928c7)) +* stop the raw app editor stealing focus in ai sessions ([#11287](https://github.com/windmill-labs/windmill/issues/11287)) ([7322143](https://github.com/windmill-labs/windmill/commit/732214361f36eb07d65854118bd053c96ce681fb)) + +## [1.816.0](https://github.com/windmill-labs/windmill/compare/v1.815.0...v1.816.0) (2026-09-21) + + +### Features + +* add an ACL editor for data table roles ([#11063](https://github.com/windmill-labs/windmill/issues/11063)) ([9994e03](https://github.com/windmill-labs/windmill/commit/9994e03bc75404503001a5db37e175b66e7371f2)) +* add per-model context window override in workspace AI settings ([#11232](https://github.com/windmill-labs/windmill/issues/11232)) ([c637b06](https://github.com/windmill-labs/windmill/commit/c637b06a548a5725aceb7504bcb62383d35614ac)) +* **ai-chat:** disclose an app's exposure on deploy, refuse drag-and-drop apps ([#11137](https://github.com/windmill-labs/windmill/issues/11137)) ([85b36a2](https://github.com/windmill-labs/windmill/commit/85b36a2de5f3fa08750b6d2ab8203f4b745cfdd2)) +* data table roles in the DB manager and raw apps ([#11139](https://github.com/windmill-labs/windmill/issues/11139)) ([e1e3692](https://github.com/windmill-labs/windmill/commit/e1e3692fbc82d50c021ef8bf8ca7019d760705f0)) +* drafts follow their item through a move; behind means base ≠ head ([#10577](https://github.com/windmill-labs/windmill/issues/10577)) ([f00b2fc](https://github.com/windmill-labs/windmill/commit/f00b2fcb1e65cb8f8e95d55f7cc58b816ac7d89d)) +* edit variables, resources and triggers in their own session tab ([#11206](https://github.com/windmill-labs/windmill/issues/11206)) ([6186a06](https://github.com/windmill-labs/windmill/commit/6186a0645d3f3e92e06b666a6b5774b8af2b488f)) +* let every EE instance set the announcement banner ([#11238](https://github.com/windmill-labs/windmill/issues/11238)) ([87761d1](https://github.com/windmill-labs/windmill/commit/87761d179799026ebaf9ed91bf589852b5453ee3)) +* snap-collapse the sidebar by dragging it to the left edge ([#11225](https://github.com/windmill-labs/windmill/issues/11225)) ([56a6ad3](https://github.com/windmill-labs/windmill/commit/56a6ad322e8354695a0a96eef9e83ffa135b3ad4)) + + +### Bug Fixes + +* bump git sync hub scripts to cli 1.815.1 ([#11239](https://github.com/windmill-labs/windmill/issues/11239)) ([9e0e835](https://github.com/windmill-labs/windmill/commit/9e0e835c6876305d3a44902fb1432fd9c2348a7c)) +* check custom_instance_user can connect during instance database setup ([#11244](https://github.com/windmill-labs/windmill/issues/11244)) ([1b0f119](https://github.com/windmill-labs/windmill/commit/1b0f119c7b8ac2aacf2e2b4428c57f661df87ae5)) +* **cli:** read wmill.yaml from the branch a git-sync deploy writes to ([#11236](https://github.com/windmill-labs/windmill/issues/11236)) ([974b026](https://github.com/windmill-labs/windmill/commit/974b0265f85f9cfac65bbd2f6ebbcb0e966e3301)) +* guard data table migration routes against operators and unauthorized authors ([#11243](https://github.com/windmill-labs/windmill/issues/11243)) ([b742544](https://github.com/windmill-labs/windmill/commit/b7425443e1e08be029fc676047f727f84b646323)) +* tell the user when a draft conflict has stopped their edits saving ([#11241](https://github.com/windmill-labs/windmill/issues/11241)) ([d8ebbc0](https://github.com/windmill-labs/windmill/commit/d8ebbc06f5c56dad2570115700d640105d856c57)) + + +### Performance Improvements + +* index audit logs by workspace and operation ([#11235](https://github.com/windmill-labs/windmill/issues/11235)) ([3b8c326](https://github.com/windmill-labs/windmill/commit/3b8c3264ecb33884420b1bdac9cae9c841e721a0)) +* keep the sessions page fast with many ai sessions ([#11224](https://github.com/windmill-labs/windmill/issues/11224)) ([4cbc944](https://github.com/windmill-labs/windmill/commit/4cbc944c0bcb938300c964437d745d15a3ea608f)) +* retire the legacy pre-partitioning audit table ([#11240](https://github.com/windmill-labs/windmill/issues/11240)) ([9f2685d](https://github.com/windmill-labs/windmill/commit/9f2685d788e624c9ed9db5cbaee456ea21af2b29)) + ## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18) diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 2e29b9064d..3d0c6d6734 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -98,9 +98,11 @@ export async function runEval( // Wrap tools to intercept fn calls for tracking. // Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type // but the actual callbacks passed at runtime will satisfy both interfaces. + // `fn` forwards `this`: the chat loop calls it on a per-iteration copy carrying the def the + // model was sent, and a tool reading `this.def` must see that one, as it does in production. const wrappedTools = tools.map((tool) => ({ ...tool, - fn: async (p: any) => { + fn: async function (this: unknown, p: any) { toolCallsCount++; toolsCalled.push(tool.def.function.name); let argumentsText = ""; @@ -120,7 +122,7 @@ export async function runEval( toolName: tool.def.function.name, argumentsText, }); - return tool.fn(p); + return tool.fn.call(this, p); }, })); diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b17d37cb29..96aa3ab983 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -1112,9 +1112,9 @@ - opens the Kafka triggers page - does not write, deploy, or delete anything -- id: global-openpage7-compare-review +- id: global-draft-does-not-open-compare prompt: |- - Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself. + Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it). I want to look over the change before it gets deployed. runtime: maxTurns: 8 validate: @@ -1122,6 +1122,24 @@ toolExpect: requiredToolsUsed: - write_script + forbiddenToolsUsed: + - open_page + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - creates the script draft without opening any workspace page + - does not deploy or delete anything + +- id: global-openpage7-compare-fork-deploy + prompt: |- + I'm done with my changes in this fork and want to ship them to the parent workspace myself. Take me there. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: - open_page forbiddenToolsUsed: - deploy_workspace_item @@ -1131,17 +1149,9 @@ field: page stringIncludesAnyOf: - compare - # The eval chat is untracked (no modified-items mask), so the model must scope - # the review by passing the item it changed explicitly — an omitted mask would - # preselect every pending change in the workspace. - - tool: open_page - field: items - stringIncludesAnyOf: - - f/evals/global/compare_review_demo skipJudge: true judgeChecklist: - - creates the script draft, then opens the Compare & Deploy review page instead of deploying itself - - preselects only the created script on the review page + - opens Compare & Deploy only because the user explicitly wants to deploy the fork into its parent - does not deploy or delete anything - id: global-openpage8-runs-label-and-worker diff --git a/backend/.sqlx/query-132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710.json b/backend/.sqlx/query-132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710.json new file mode 100644 index 0000000000..15d1703e5d --- /dev/null +++ b/backend/.sqlx/query-132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at, member_since, target_changed_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::timestamptz IS NOT NULL\n OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,\n token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,\n proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,\n member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at\n WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at\n RETURNING connected_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "connected_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Varchar", + "Varchar", + "Timestamptz", + "Timestamptz", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710" +} diff --git a/backend/.sqlx/query-14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518.json b/backend/.sqlx/query-14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518.json new file mode 100644 index 0000000000..45fbabdae9 --- /dev/null +++ b/backend/.sqlx/query-14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "remote_deploy_target", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518" +} diff --git a/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json new file mode 100644 index 0000000000..46b7d2509c --- /dev/null +++ b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue q SET scheduled_for = now()\n FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev\n WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL\n AND q.scheduled_for > now()\n RETURNING prev.scheduled_for", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d" +} diff --git a/backend/.sqlx/query-207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f.json b/backend/.sqlx/query-207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f.json new file mode 100644 index 0000000000..e698dbecb2 --- /dev/null +++ b/backend/.sqlx/query-207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET token = $1\n WHERE workspace_id = $2 AND email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f" +} diff --git a/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json new file mode 100644 index 0000000000..90566827e7 --- /dev/null +++ b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(DISTINCT email) FROM usr WHERE is_service_account AND NOT disabled", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075" +} diff --git a/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json new file mode 100644 index 0000000000..f5a2ac20ed --- /dev/null +++ b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11" +} diff --git a/backend/.sqlx/query-3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd.json b/backend/.sqlx/query-3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd.json new file mode 100644 index 0000000000..b3b683eca1 --- /dev/null +++ b/backend/.sqlx/query-3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd" +} diff --git a/backend/.sqlx/query-3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44.json b/backend/.sqlx/query-3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44.json new file mode 100644 index 0000000000..9901e61b6a --- /dev/null +++ b/backend/.sqlx/query-3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, token FROM remote_deploy_token\n WHERE workspace_id = $1 AND token <> '' FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44" +} diff --git a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json similarity index 82% rename from backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json rename to backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json index 578edc6044..5c0e45b0d4 100644 --- a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json +++ b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", "describe": { "columns": [ { @@ -236,5 +236,5 @@ true ] }, - "hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e" + "hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56" } diff --git a/backend/.sqlx/query-51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d.json b/backend/.sqlx/query-51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d.json new file mode 100644 index 0000000000..b6babc98b0 --- /dev/null +++ b/backend/.sqlx/query-51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT clock_timestamp() AS \"started_at!\", s.remote_deploy_target,\n s.remote_deploy_target_changed_at,\n (SELECT u.created_at FROM usr u\n WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since\n FROM workspace_settings s WHERE s.workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "started_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "remote_deploy_target", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "remote_deploy_target_changed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "member_since", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + true, + true, + null + ] + }, + "hash": "51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d" +} diff --git a/backend/.sqlx/query-530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be.json b/backend/.sqlx/query-530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be.json new file mode 100644 index 0000000000..65266cbae0 --- /dev/null +++ b/backend/.sqlx/query-530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)\n VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',\n 'test@windmill.dev', 'read only', false, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be" +} diff --git a/backend/.sqlx/query-5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429.json b/backend/.sqlx/query-5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429.json new file mode 100644 index 0000000000..c29a42c56b --- /dev/null +++ b/backend/.sqlx/query-5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'\n WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429" +} diff --git a/backend/.sqlx/query-5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67.json b/backend/.sqlx/query-5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67.json new file mode 100644 index 0000000000..7633c95854 --- /dev/null +++ b/backend/.sqlx/query-5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t\n JOIN workspace_settings s ON s.workspace_id = t.workspace_id\n WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3\n AND t.remote_workspace_id = $4 AND t.token <> ''\n AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at\n AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id\n AND u.email = t.email AND u.created_at = t.member_since)\n OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "remote_email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "proxy_key", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "connected_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67" +} diff --git a/backend/.sqlx/query-6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473.json b/backend/.sqlx/query-6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473.json new file mode 100644 index 0000000000..20b5bd1a94 --- /dev/null +++ b/backend/.sqlx/query-6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at)\n VALUES ($1, $2, '', '', '', '', '', clock_timestamp())\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n token = '', proxy_key = '', connected_at = clock_timestamp()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473" +} diff --git a/backend/.sqlx/query-81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083.json b/backend/.sqlx/query-81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083.json new file mode 100644 index 0000000000..40b6e22db6 --- /dev/null +++ b/backend/.sqlx/query-81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1\n AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083" +} diff --git a/backend/.sqlx/query-8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e.json b/backend/.sqlx/query-8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e.json new file mode 100644 index 0000000000..c732c4549d --- /dev/null +++ b/backend/.sqlx/query-8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e" +} diff --git a/backend/.sqlx/query-960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2.json b/backend/.sqlx/query-960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2.json new file mode 100644 index 0000000000..7d819b9b84 --- /dev/null +++ b/backend/.sqlx/query-960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2" +} diff --git a/backend/.sqlx/query-965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7.json b/backend/.sqlx/query-965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7.json new file mode 100644 index 0000000000..a7fe120abb --- /dev/null +++ b/backend/.sqlx/query-965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7" +} diff --git a/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json b/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json new file mode 100644 index 0000000000..0bc09d9430 --- /dev/null +++ b/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, canceled_reason = $3 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b" +} diff --git a/backend/.sqlx/query-9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f.json b/backend/.sqlx/query-9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f.json new file mode 100644 index 0000000000..112ca1603f --- /dev/null +++ b/backend/.sqlx/query-9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,\n remote_deploy_target_changed_at = CASE\n WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()\n ELSE remote_deploy_target_changed_at END\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f" +} diff --git a/backend/.sqlx/query-a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff.json b/backend/.sqlx/query-a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff.json new file mode 100644 index 0000000000..18951e2a87 --- /dev/null +++ b/backend/.sqlx/query-a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff" +} diff --git a/backend/.sqlx/query-a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68.json b/backend/.sqlx/query-a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68.json new file mode 100644 index 0000000000..6529400823 --- /dev/null +++ b/backend/.sqlx/query-a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET token = 'not-under-this-key'\n WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68" +} diff --git a/backend/.sqlx/query-af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7.json b/backend/.sqlx/query-af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7.json new file mode 100644 index 0000000000..4ec7511c7c --- /dev/null +++ b/backend/.sqlx/query-af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',\n 'test@windmill.dev', 'gone', true)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7" +} diff --git a/backend/.sqlx/query-b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923.json b/backend/.sqlx/query-b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923.json new file mode 100644 index 0000000000..77cd542d6b --- /dev/null +++ b/backend/.sqlx/query-b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923" +} diff --git a/backend/.sqlx/query-383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99.json b/backend/.sqlx/query-b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9.json similarity index 79% rename from backend/.sqlx/query-383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99.json rename to backend/.sqlx/query-b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9.json index 8a780ccda7..c15e0edb33 100644 --- a/backend/.sqlx/query-383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99.json +++ b/backend/.sqlx/query-b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n ))\n RETURNING token_prefix", + "query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n AND NOT starts_with(label, 'remote-deploy:')\n ))\n RETURNING token_prefix", "describe": { "columns": [ { @@ -20,5 +20,5 @@ false ] }, - "hash": "383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99" + "hash": "b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9" } diff --git a/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json new file mode 100644 index 0000000000..4ed2afc15b --- /dev/null +++ b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone\n FROM v2_job j\n JOIN v2_job_queue q USING (id)\n JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger\n WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'\n AND j.parent_job IS NULL AND q.scheduled_for > now()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "schedule", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cron_version", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "timezone", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7" +} diff --git a/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json b/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json deleted file mode 100644 index 8a4b957c2a..0000000000 --- a/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06" -} diff --git a/backend/.sqlx/query-c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540.json b/backend/.sqlx/query-c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540.json new file mode 100644 index 0000000000..7422dd2333 --- /dev/null +++ b/backend/.sqlx/query-c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540" +} diff --git a/backend/.sqlx/query-cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6.json b/backend/.sqlx/query-cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6.json new file mode 100644 index 0000000000..85c999e3ba --- /dev/null +++ b/backend/.sqlx/query-cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()\n WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6" +} diff --git a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json similarity index 60% rename from backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json rename to backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json index 3d61a47eb1..f631078e76 100644 --- a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json +++ b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2", + "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2\n AND NOT COALESCE(\n flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}',\n false\n )", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf" + "hash": "cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b" } diff --git a/backend/.sqlx/query-d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2.json b/backend/.sqlx/query-d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2.json new file mode 100644 index 0000000000..cde3e698d9 --- /dev/null +++ b/backend/.sqlx/query-d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2" +} diff --git a/backend/.sqlx/query-d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9.json b/backend/.sqlx/query-d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9.json new file mode 100644 index 0000000000..4031e19231 --- /dev/null +++ b/backend/.sqlx/query-d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE usr SET created_at = created_at - interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9" +} diff --git a/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json b/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json new file mode 100644 index 0000000000..9930e0494f --- /dev/null +++ b/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "canceled!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61" +} diff --git a/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json b/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json new file mode 100644 index 0000000000..b11e41fe89 --- /dev/null +++ b/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "canceled_reason", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b" +} diff --git a/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json b/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json new file mode 100644 index 0000000000..a1d873778f --- /dev/null +++ b/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842" +} diff --git a/backend/.sqlx/query-eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30.json b/backend/.sqlx/query-eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30.json deleted file mode 100644 index ec641bf32d..0000000000 --- a/backend/.sqlx/query-eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30" -} diff --git a/backend/.sqlx/query-a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a.json b/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json similarity index 85% rename from backend/.sqlx/query-a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a.json rename to backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json index 0f43afed39..6964dfa8d6 100644 --- a/backend/.sqlx/query-a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a.json +++ b/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", + "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ", "describe": { "columns": [ { @@ -93,7 +93,10 @@ "Int8", "Int8", "Text", - "Bool" + "Bool", + "Bool", + "TextArray", + "TextArray" ] }, "nullable": [ @@ -111,5 +114,5 @@ false ] }, - "hash": "a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a" + "hash": "f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff" } diff --git a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json b/backend/.sqlx/query-f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76.json similarity index 59% rename from backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json rename to backend/.sqlx/query-f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76.json index 5661c59faf..c3e7b0168d 100644 --- a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json +++ b/backend/.sqlx/query-f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true", + "query": "SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf" + "hash": "f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76" } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 099a707ee5..b2f9886ed6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -588,15 +588,15 @@ dependencies = [ [[package]] name = "astral-tokio-tar" -version = "0.5.6" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" dependencies = [ - "filetime", "futures-core", "libc", "portable-atomic", "rustc-hash 2.1.3", + "rustix 1.1.5", "tokio", "tokio-stream", "xattr", @@ -928,9 +928,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.3" +version = "1.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0baa720ebadea158c5bda642ac444a2af0cdf7bb66b46d1e4533de5d1f449d0" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -938,8 +938,8 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -1070,15 +1070,16 @@ dependencies = [ [[package]] name = "aws-sdk-config" -version = "1.68.0" +version = "1.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7de8bcff88ff2948bf2154204b7eba0ea30698c1c23770c4a917505fb9fcbd5" +checksum = "0360cf8f8ab0e328f89b5c60bdcc6203ebce83b8a018c05ed177c4f32f819aff" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -1086,7 +1087,7 @@ dependencies = [ "bytes", "fastrand 2.5.0", "http 0.2.12", - "once_cell", + "http 1.5.0", "regex-lite", "tracing", ] @@ -1144,15 +1145,16 @@ dependencies = [ [[package]] name = "aws-sdk-sqs" -version = "1.77.0" +version = "1.94.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a6dcd63dffae1f7872df42ce9f1f0f785f8c9a3d3e880ef6c171d0415439df" +checksum = "0894909314920ab2c907110e7f91b9f7a6c826bdc830ccf36079c69af2b82335" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -1160,6 +1162,7 @@ dependencies = [ "bytes", "fastrand 2.5.0", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] @@ -1214,15 +1217,16 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.79.0" +version = "1.98.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317377afba3498fca4948c5d32b399ef9a5ad35561a1e8a6f2ac7273dabf802d" +checksum = "89c4f19655ab0856375e169865c91264de965bd74c407c7f1e403184b1049409" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -1231,6 +1235,7 @@ dependencies = [ "aws-types", "fastrand 2.5.0", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] @@ -1340,7 +1345,7 @@ dependencies = [ "hyper 0.14.32", "hyper 1.11.1", "hyper-rustls 0.24.2", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "pin-project-lite", "rustls 0.21.12", @@ -2221,7 +2226,7 @@ dependencies = [ "memmap2", "num-traits", "num_cpus", - "rand 0.9.0", + "rand 0.9.5", "rand_distr", "rayon", "safetensors", @@ -2257,7 +2262,7 @@ dependencies = [ "candle-nn", "fancy-regex 0.17.0", "num-traits", - "rand 0.9.0", + "rand 0.9.5", "rayon", "serde", "serde_json", @@ -3996,7 +4001,7 @@ dependencies = [ "http 1.5.0", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "ipnet", "percent-encoding", @@ -4199,7 +4204,7 @@ dependencies = [ "deno_tls", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "log", "once_cell", @@ -5101,7 +5106,7 @@ checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" dependencies = [ "half", "num-traits", - "rand 0.9.0", + "rand 0.9.5", "rand_distr", ] @@ -5833,7 +5838,7 @@ dependencies = [ "cfg-if", "crunchy", "num-traits", - "rand 0.9.0", + "rand 0.9.5", "rand_distr", "zerocopy", ] @@ -5975,7 +5980,7 @@ dependencies = [ "log", "native-tls", "num_cpus", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.12.28", "serde", "serde_json", @@ -6002,7 +6007,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.0", + "rand 0.9.5", "serde", "thiserror 2.0.20", "tinyvec", @@ -6024,7 +6029,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.0", + "rand 0.9.5", "resolv-conf", "serde", "smallvec", @@ -6258,7 +6263,7 @@ dependencies = [ "headers", "http 1.5.0", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "pin-project-lite", "tokio", @@ -6317,9 +6322,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http 1.5.0", "hyper 1.11.1", @@ -7066,7 +7071,7 @@ dependencies = [ "http-body-util", "hyper 1.11.1", "hyper-http-proxy", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-timeout", "hyper-util", "jsonpath-rust", @@ -7312,9 +7317,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "bitflags 2.13.2", "libc", @@ -7938,7 +7943,7 @@ dependencies = [ "native-tls", "pem", "percent-encoding", - "rand 0.10.2", + "rand 0.10.3", "serde", "socket2 0.6.5", "thiserror 2.0.20", @@ -8444,7 +8449,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.12.28", "ring 0.17.14", "rustls-pemfile 2.2.0", @@ -8813,7 +8818,7 @@ dependencies = [ "futures-util", "opentelemetry 0.30.0", "percent-encoding", - "rand 0.9.0", + "rand 0.9.5", "serde_json", "thiserror 2.0.20", "tokio", @@ -8915,7 +8920,7 @@ dependencies = [ "hmac", "pkcs12", "pkcs5", - "rand 0.9.0", + "rand 0.9.5", "rc2", "sha1", "sha2 0.10.9", @@ -9118,9 +9123,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -9128,9 +9133,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -9138,9 +9143,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", @@ -9151,9 +9156,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", ] @@ -9499,7 +9504,7 @@ dependencies = [ "hmac", "md-5 0.10.6", "memchr", - "rand 0.9.0", + "rand 0.9.5", "sha2 0.10.9", "stringprep", ] @@ -9874,7 +9879,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring 0.17.14", "rustc-hash 2.1.3", @@ -9954,20 +9959,19 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", - "zerocopy", ] [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -10044,7 +10048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.0", + "rand 0.9.5", ] [[package]] @@ -10334,7 +10338,7 @@ dependencies = [ "http-body 1.1.0", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-tls", "hyper-util", "js-sys", @@ -10382,7 +10386,7 @@ dependencies = [ "http-body 1.1.0", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "js-sys", "log", @@ -10459,7 +10463,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" dependencies = [ - "rand 0.10.2", + "rand 0.10.3", ] [[package]] @@ -10547,7 +10551,7 @@ dependencies = [ "oauth2", "pastey", "pin-project-lite", - "rand 0.10.2", + "rand 0.10.3", "reqwest 0.13.5", "rmcp-macros", "schemars 1.2.2", @@ -11109,7 +11113,7 @@ dependencies = [ "openssl-sys", "pkg-config", "quick-xml", - "rand 0.9.0", + "rand 0.9.5", "serde", "thiserror 2.0.20", "url", @@ -13392,7 +13396,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.0", + "rand 0.9.5", "socket2 0.6.5", "tokio", "tokio-util", @@ -14084,7 +14088,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand 0.9.0", + "rand 0.9.5", "uuid", "web-time", ] @@ -14167,9 +14171,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-id-start" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" +checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" @@ -14788,7 +14792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -14811,7 +14815,7 @@ dependencies = [ "opentelemetry 0.30.0", "opentelemetry_sdk 0.30.0", "prometheus", - "rand 0.9.0", + "rand 0.9.5", "rdkafka", "reqwest 0.13.5", "rumqttc", @@ -14876,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.815.0" +version = "1.817.0" dependencies = [ "async-stream", "async-trait", @@ -14910,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14923,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "argon2", @@ -14971,7 +14975,7 @@ dependencies = [ "prometheus", "quick-xml", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "regex", "reqwest 0.13.5", "rsa", @@ -15063,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15086,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15103,7 +15107,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15129,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.815.0" +version = "1.817.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15139,7 +15143,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15156,7 +15160,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15178,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15201,7 +15205,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15217,7 +15221,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15239,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15261,7 +15265,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15275,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -15287,7 +15291,7 @@ dependencies = [ "futures", "hex", "hmac", - "rand 0.9.0", + "rand 0.9.5", "rdkafka", "reqwest 0.13.5", "rmcp", @@ -15310,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15335,7 +15339,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15363,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15385,7 +15389,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15405,8 +15409,9 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.815.0" +version = "1.817.0" dependencies = [ + "anyhow", "axum 0.8.9", "chrono", "futures", @@ -15431,6 +15436,9 @@ dependencies = [ "windmill-native-triggers", "windmill-object-store", "windmill-parser", + "windmill-parser-bash", + "windmill-parser-go", + "windmill-parser-graphql", "windmill-parser-py", "windmill-parser-py-asset", "windmill-parser-sql", @@ -15443,7 +15451,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15472,7 +15480,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.815.0" +version = "1.817.0" dependencies = [ "lazy_static", "serde", @@ -15484,7 +15492,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.815.0" +version = "1.817.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15508,7 +15516,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15522,10 +15530,11 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.815.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", + "constant_time_eq 0.3.1", "futures", "hex", "http 1.5.0", @@ -15533,6 +15542,7 @@ dependencies = [ "lazy_static", "magic-crypt", "regex", + "reqwest 0.13.5", "serde", "serde_json", "sha2 0.10.9", @@ -15541,6 +15551,7 @@ dependencies = [ "tokio", "tokio-postgres", "tracing", + "url", "uuid", "windmill-api-auth", "windmill-api-jobs", @@ -15557,7 +15568,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.815.0" +version = "1.817.0" dependencies = [ "chrono", "lazy_static", @@ -15571,7 +15582,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15590,7 +15601,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.815.0" +version = "1.817.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15650,12 +15661,15 @@ dependencies = [ "postgres-native-tls 0.5.3", "prometheus", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", + "rcgen", "regex", "reqwest 0.13.5", "reqwest-middleware", "reqwest-retry", "rsa", + "rustls 0.23.35", + "rustls-native-certs 0.8.4", "schemars 0.8.22", "semver 1.0.28", "serde", @@ -15676,6 +15690,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tokio", "tokio-postgres", + "tokio-rustls 0.26.5", "tokio-stream", "tokio-util", "tonic 0.13.1", @@ -15686,6 +15701,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "webpki-roots 1.0.9", "windmill-macros", "windmill-parser", "windmill-parser-py", @@ -15697,7 +15713,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.815.0" +version = "1.817.0" dependencies = [ "chrono", "futures", @@ -15717,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.815.0" +version = "1.817.0" dependencies = [ "regex", "serde", @@ -15734,7 +15750,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15761,7 +15777,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "futures", @@ -15778,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.815.0" +version = "1.817.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15794,7 +15810,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -15815,7 +15831,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -15846,7 +15862,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "arc-swap", @@ -15871,7 +15887,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-stream", @@ -15906,7 +15922,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "futures", @@ -15924,7 +15940,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.815.0" +version = "1.817.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15933,7 +15949,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -15945,7 +15961,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -15957,7 +15973,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "gosyn", @@ -15969,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -15981,7 +15997,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -15993,7 +16009,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "nu-parser", @@ -16004,7 +16020,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16015,7 +16031,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16027,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16038,7 +16054,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16060,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -16072,7 +16088,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16086,7 +16102,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16103,7 +16119,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16116,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde", @@ -16128,7 +16144,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16146,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16162,7 +16178,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16178,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16192,7 +16208,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16211,7 +16227,7 @@ dependencies = [ "once_cell", "prometheus", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "regex", "reqwest 0.13.5", "serde", @@ -16231,7 +16247,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "const_format", @@ -16271,7 +16287,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.815.0" +version = "1.817.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16282,7 +16298,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16317,7 +16333,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16341,7 +16357,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16351,7 +16367,7 @@ dependencies = [ "hyper 1.11.1", "itertools 0.14.0", "lazy_static", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.13.5", "serde", "serde_json", @@ -16374,7 +16390,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16401,7 +16417,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16415,7 +16431,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.13.5", "serde", "serde_json", @@ -16434,7 +16450,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16454,7 +16470,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16488,7 +16504,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16505,6 +16521,7 @@ dependencies = [ "lazy_static", "matchit 0.7.3", "quick_cache", + "rand 0.9.5", "regex", "serde", "serde_json", @@ -16524,7 +16541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16547,7 +16564,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16571,7 +16588,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -16595,7 +16612,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16610,7 +16627,7 @@ dependencies = [ "pg_escape", "postgres-native-tls 0.5.2", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "rust_decimal", "serde", "serde_json", @@ -16630,7 +16647,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16658,7 +16675,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16683,14 +16700,14 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "bitflags 2.13.2", "chrono", "hex", "itertools 0.14.0", - "rand 0.9.0", + "rand 0.9.5", "serde", "serde_json", "sqlx", @@ -16702,7 +16719,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-once-cell", @@ -16731,7 +16748,7 @@ dependencies = [ "hmac", "hudsucker", "hyper-http-proxy", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "itertools 0.14.0", "jsonwebtoken 10.4.0", @@ -16755,7 +16772,7 @@ dependencies = [ "prometheus", "prost", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "rcgen", "regex", "reqwest 0.13.5", @@ -16820,7 +16837,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.815.0" +version = "1.817.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6dc7bdf81e..2e81eb2637 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.815.0" +version = "1.817.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.815.0" +version = "1.817.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -493,7 +493,7 @@ sql-builder = "^3" # behind a feature and changed `hash_password`), so a float breaks the build. argon2 = "0.6" quick_cache = "^0" -rand = "=0.9.0" +rand = "^0.9.3" rand_core = { version = "^0", features = ["std"] } arc-swap = "1" ed25519-dalek = { version = "2", features = ["rand_core"] } @@ -514,7 +514,7 @@ reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart" eventsource-stream = "0.2.3" time = "^0" serde_urlencoded = "^0" -astral-tokio-tar = "^0.5.6" +astral-tokio-tar = "^0.6.2" tempfile = "^3" x509-parser = "^0.16" tokio-util = { version = "=0.7.17", features = ["io"] } @@ -523,7 +523,7 @@ itertools = "^0.14.0" regex = "^1" semver = "^1" aws-sigv4 = "^1.3.4" -aws-sdk-config = "=1.68.0" +aws-sdk-config = "^1.94.0" aws-sdk-rds = "^1" async-trait = "0.1.88" @@ -685,8 +685,8 @@ aws-sdk-bedrockruntime = "=1.122.0" aws-credential-types = "^1" aws-smithy-types = "^1" aws-sdk-secretsmanager = "^1" -aws-sdk-sqs = "=1.77.0" -aws-sdk-sts = "=1.79.0" +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" @@ -757,4 +757,5 @@ hyper-http-proxy = { version = "1", default-features = false, features = ["rustl hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } rustls-native-certs = "0.8" +webpki-roots = "1" rcgen = "0.13" diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md index af33877078..73a4b53227 100644 --- a/backend/THREAT_MODEL.md +++ b/backend/THREAT_MODEL.md @@ -97,7 +97,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence | |---|---|---|---|---|---|---|---|---|---| | T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b | -| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | +| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; workspace webhook URLs are SSRF-checked when saved, not when sent, behind the `ALLOW_PRIVATE_WEBHOOK_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | | T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 | | T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 | | T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e | diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 53ba22c1bd..b87700bf13 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -38699f705abb4e223d86e18b30d004aeaf589401 +85683db6bfc78840da48fd9ca1bc22d8651162e4 diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index 72feb5392e..4c78f0524e 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -128,11 +128,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt r for r in body_schema['required'] if r in include_fields ] - # Apply opaque_fields: simplify listed properties to {"type": "object"} + # Apply opaque_fields: simplify listed properties to {"type": "object"}, + # keeping the description, which is all a caller learns about the field if body_schema and opaque_fields and 'properties' in body_schema: for field in opaque_fields: if field in body_schema['properties']: - body_schema['properties'][field] = {"type": "object"} + opaque = {"type": "object"} + description = body_schema['properties'][field].get('description') + if description: + opaque['description'] = description + body_schema['properties'][field] = opaque # If we have required fields specified and a body schema, update the required array if body_schema and required_fields: diff --git a/backend/migrations/20260919213810_remote_deploy.down.sql b/backend/migrations/20260919213810_remote_deploy.down.sql new file mode 100644 index 0000000000..ae26684ac6 --- /dev/null +++ b/backend/migrations/20260919213810_remote_deploy.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS remote_deploy_token; +ALTER TABLE workspace_settings + DROP COLUMN IF EXISTS remote_deploy_target, + DROP COLUMN IF EXISTS remote_deploy_target_changed_at; diff --git a/backend/migrations/20260919213810_remote_deploy.up.sql b/backend/migrations/20260919213810_remote_deploy.up.sql new file mode 100644 index 0000000000..c32f81fd88 --- /dev/null +++ b/backend/migrations/20260919213810_remote_deploy.up.sql @@ -0,0 +1,35 @@ +-- The workspace on another Windmill instance this workspace deploys into from the UI: +-- `{"base_url": ..., "workspace_id": ...}`, and when it last changed. A token connected under an +-- earlier change counts for nothing, even once the setting points back at the target it was for. +ALTER TABLE workspace_settings + ADD COLUMN remote_deploy_target JSONB, + ADD COLUMN remote_deploy_target_changed_at TIMESTAMPTZ; + +-- One user's token for the remote deploy target, encrypted with the workspace key. +-- `base_url`/`remote_workspace_id` name the target it was granted for: a token is only +-- ever sent to that target, so re-pointing the workspace setting cannot redirect it. +-- Keyed by the account rather than by membership, since a superadmin deploys from workspaces +-- it is not a member of. The account key cascades: whatever deletes or renames an account +-- takes its tokens along, so a later account with the same address cannot inherit them. +CREATE TABLE remote_deploy_token ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE, + base_url VARCHAR(1000) NOT NULL, + remote_workspace_id VARCHAR(50) NOT NULL, + token TEXT NOT NULL, + remote_email VARCHAR(255) NOT NULL, + -- Part of every proxy URL, and readable only by its owner through the API: a link from + -- elsewhere, which rides the session cookie, cannot know it and so cannot spend the token. + proxy_key VARCHAR(64) NOT NULL, + -- When the connect that wrote the row started, or when a disconnect emptied it. + connected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- What the connect ran under: the `created_at` of the owner's membership (NULL for a + -- superadmin with none) and the target's `remote_deploy_target_changed_at`. The row counts + -- only while both are still the same, so a re-add or a target change voids it. + member_since TIMESTAMPTZ, + target_changed_at TIMESTAMPTZ, + PRIMARY KEY (workspace_id, email) +); + +GRANT ALL ON remote_deploy_token TO windmill_user; +GRANT ALL ON remote_deploy_token TO windmill_admin; diff --git a/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql b/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql new file mode 100644 index 0000000000..25b81b8c69 --- /dev/null +++ b/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql @@ -0,0 +1,6 @@ +-- `created_by` tells this migration's row from one a user created by hand; a hub +-- sync updates in place and leaves it alone. +DELETE FROM resource_type +WHERE workspace_id = 'admins' + AND name = 'ai_instruction' + AND created_by = 'system'; diff --git a/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql b/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql new file mode 100644 index 0000000000..1b89469a91 --- /dev/null +++ b/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql @@ -0,0 +1,15 @@ +-- Folder instructions for the AI chat: a markdown file resource whose +-- `value.content` applies to every item under the directory it lives in. +-- `format_extension` makes the resource editor render it as a plain .md file. +-- Seeded under 'admins' so every workspace sees it before the next hub sync. +INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at) +VALUES ( + 'admins', + 'ai_instruction', + '{"type": "object", "properties": {"content": {"type": "string"}}}', + 'Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.', + 'system', + 'md', + now() +) +ON CONFLICT (workspace_id, name) DO NOTHING; diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 271ad4835b..ac75080994 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -16,11 +16,13 @@ use windmill_parser::{ use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Span, Spanned}; use swc_ecma_ast::{ - ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, Ident, IdentName, Lit, - MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, ObjectLit, ObjectPat, Param, Pat, Stmt, - Str, TsArrayType, TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, - TsLitType, TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, - TsTypeAnn, TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, + ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, ExprOrSpread, Ident, + IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, + ObjectLit, ObjectPat, Param, Pat, Prop, PropName, PropOrSpread, Stmt, Str, TsArrayType, + TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, TsLitType, + TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, TsTypeAnn, + TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, UnaryExpr, + UnaryOp, }; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax}; @@ -578,6 +580,15 @@ fn parse_param( } }; + let literal = match &*right { + Expr::Object(_) | Expr::Array(_) if !skip_dflt => literal_json(&right), + _ => None, + }; + // Types an untyped object default that is not a plain literal (`{ a: x }`). + let literal_shape = match &*right { + Expr::Object(_) => Some(Typ::Object(ObjectType::new(None, None))), + _ => None, + }; let dflt = if skip_dflt { None } else { @@ -593,14 +604,19 @@ fn parse_param( Expr::Lit(Lit::Num(Number { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::BigInt(BigInt { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::Bool(Bool { value, .. })) => Some(Value::Bool(value)), - Expr::Object(ObjectLit { span, .. }) => eval_span(span, cm), - Expr::Array(ArrayLit { span, .. }) => eval_span(span, cm), + Expr::Object(ObjectLit { span, .. }) | Expr::Array(ArrayLit { span, .. }) => { + literal.or_else(|| eval_span(span, cm)) + } _ => None, } }; - if typ == Typ::Unknown && dflt.is_some() { - typ = json_to_typ(dflt.as_ref().unwrap(), false); + if typ == Typ::Unknown { + match &dflt { + Some(d) => typ = json_to_typ(d, false), + None if !skip_dflt => typ = literal_shape.unwrap_or(Typ::Unknown), + None => {} + } } Ok(Arg { otyp, @@ -636,6 +652,59 @@ fn parse_param( r } +/// A default written as a plain literal, read off the AST: `['a']` and `{ a: 1 }` are +/// not JSON, and outside wasm nothing can evaluate them (see `eval_sync`). +fn literal_json(expr: &Expr) -> Option { + let num = |v: f64| { + if v.fract() == 0.0 && v.abs() < 9007199254740992.0 { + Some(serde_json::json!(v as i64)) + } else { + serde_json::Number::from_f64(v).map(Value::Number) + } + }; + match expr { + Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())), + Expr::Lit(Lit::Num(n)) => num(n.value), + Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)), + Expr::Lit(Lit::Null(_)) => Some(Value::Null), + Expr::Unary(UnaryExpr { op: UnaryOp::Minus, arg, .. }) => match &**arg { + Expr::Lit(Lit::Num(n)) => num(-n.value), + _ => None, + }, + Expr::Paren(p) => literal_json(&p.expr), + Expr::Array(a) => a + .elems + .iter() + .map(|e| match e { + Some(ExprOrSpread { spread: None, expr }) => literal_json(expr), + _ => None, + }) + .collect::>>() + .map(Value::Array), + Expr::Object(o) => o + .props + .iter() + .map(|p| match p { + PropOrSpread::Prop(p) => match &**p { + Prop::KeyValue(KeyValueProp { key, value }) => { + let key = match key { + PropName::Ident(i) => i.sym.to_string(), + PropName::Str(s) => s.value.to_string(), + PropName::Num(n) => n.value.to_string(), + _ => return None, + }; + Some((key, literal_json(value)?)) + } + _ => None, + }, + _ => None, + }) + .collect::>>() + .map(Value::Object), + _ => None, + } +} + fn eval_span(span: Span, cm: &Lrc) -> Option { let expr = cm .span_to_snippet(span) @@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn eval_sync(_code: &str) -> Result { - panic!("eval_sync is only available in wasm32") + // The server parses signatures with defaults too: a default that is not JSON + // (`{ a: 1 }`, `['x']`) is then left out rather than failing the parse. + Err("evaluating a JavaScript default needs the wasm build".to_string()) } diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 4206dd68f8..b4abc23488 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -7,6 +7,38 @@ mod tests { remove_pinned_import_specifiers, }; + /// The server parses defaults natively, where a non-JSON literal cannot be evaluated + /// but must still type and default the argument as the editor's wasm parse does. + #[test] + fn non_json_literal_defaults_parse_natively() { + let code = + "export function main(opts = { enabled: true, 'n': -1 }, tags = ['a'], o = { a: x }) {}"; + let sig = parse_deno_signature(code, false, false, None).unwrap(); + let defaults: Vec<_> = sig + .args + .iter() + .map(|a| (a.typ.clone(), a.default.clone())) + .collect(); + assert_eq!( + defaults, + vec![ + ( + Typ::Object(ObjectType::new( + None, + Some(vec![ + ObjectProperty::new("enabled".into(), Box::new(Typ::Bool)), + ObjectProperty::new("n".into(), Box::new(Typ::Int)), + ]) + )), + Some(json!({"enabled": true, "n": -1})) + ), + (Typ::List(Box::new(Typ::Str(None))), Some(json!(["a"]))), + // Not a plain literal: typed by its shape, with no default value. + (Typ::Object(ObjectType::new(None, None)), None), + ] + ); + } + #[test] fn test_imports_basic() { let code = r#" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index bed1d70cf8..ccba27bcc5 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -775,9 +775,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "colorchoice" @@ -1530,7 +1530,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2580,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2816,7 +2816,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -2902,15 +2902,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2934,9 +2933,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -3047,6 +3046,16 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" @@ -3064,7 +3073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3098,6 +3107,15 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "php-parser-rs" version = "0.1.3" @@ -3169,9 +3187,9 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64 0.22.1", "byteorder", @@ -3187,9 +3205,9 @@ dependencies = [ [[package]] name = "postgres-types" -version = "0.2.9" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "array-init", "bit-vec 0.6.3", @@ -3197,7 +3215,7 @@ dependencies = [ "chrono", "fallible-iterator", "postgres-protocol", - "serde", + "serde_core", "serde_json", "uuid", ] @@ -3394,15 +3412,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.0", + "rand 0.9.5", "ring", "rustc-hash 2.1.1", "rustls", @@ -3457,9 +3475,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3468,13 +3486,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", - "zerocopy", ] [[package]] @@ -3752,7 +3769,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "503c78f59814e2664c9980b739b19e40f549233ecf39928040ee54fdd431f614" dependencies = [ - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3828,7 +3845,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "rkyv", "serde", "serde_json", @@ -3944,9 +3961,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -4574,7 +4591,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -4585,7 +4602,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -4616,7 +4633,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "sha2 0.10.9", @@ -4626,7 +4643,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -5043,9 +5060,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -5254,9 +5271,9 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.13" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -5267,15 +5284,15 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf 0.11.3", + "phf 0.13.1", "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.0", - "socket2 0.5.10", + "rand 0.10.1", + "socket2 0.6.3", "tokio", "tokio-util", - "whoami", + "whoami 2.1.0", ] [[package]] @@ -5777,7 +5794,7 @@ dependencies = [ "getopts", "log", "phf_codegen", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -5899,6 +5916,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -5923,6 +5949,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.103" @@ -6134,7 +6169,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361" +dependencies = [ + "libredox", + "wasite 1.0.2", "web-sys", ] @@ -6171,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.815.0" +version = "1.817.0" dependencies = [ "aho-corasick", "anyhow", @@ -6216,7 +6261,7 @@ dependencies = [ "pkcs1", "postgres-native-tls", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "regex", "reqwest", "reqwest-middleware", @@ -6257,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.815.0" +version = "1.817.0" dependencies = [ "proc-macro2", "quote", @@ -6269,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.815.0" +version = "1.817.0" dependencies = [ "convert_case", "serde", @@ -6278,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6290,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6302,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "gosyn", @@ -6314,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6326,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6338,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "nu-parser", @@ -6349,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6360,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6372,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6383,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -6405,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6417,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6431,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "convert_case", @@ -6448,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6461,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde", @@ -6473,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6491,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6507,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6523,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6555,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6569,14 +6614,14 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.815.0" +version = "1.817.0" dependencies = [ "anyhow", "bitflags", "chrono", "hex", "itertools 0.14.0", - "rand 0.9.0", + "rand 0.9.5", "serde", "serde_json", "sqlx", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 2f700b7b01..0244b7b106 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.815.0" +version = "1.817.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/parsers/windmill-parser/src/json_schema.rs b/backend/parsers/windmill-parser/src/json_schema.rs new file mode 100644 index 0000000000..6ca216dd89 --- /dev/null +++ b/backend/parsers/windmill-parser/src/json_schema.rs @@ -0,0 +1,402 @@ +use serde_json::{json, Map, Value}; + +use crate::{MainArgSignature, ObjectProperty, Typ}; + +/// The run-form schema of a script's `main`, derived the way the editor derives it +/// (`inferArgs` in the frontend, `argSigToJsonSchemaType` in `windmill-utils-internal`), +/// so a script deployed without a schema gets the one the editor would have sent. +/// `previous` is the schema of the version being superseded: as in the editor, it is +/// updated in place, so what the user set on it (argument order, descriptions, ...) +/// survives wherever the code still agrees with it. +pub fn main_arg_schema(sig: &MainArgSignature, previous: Option<&Value>) -> Value { + let mut schema = match previous { + Some(Value::Object(p)) => p.clone(), + _ => json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + }) + .as_object() + .cloned() + .unwrap_or_default(), + }; + let mut old_props = match schema.remove("properties") { + Some(Value::Object(p)) => p, + _ => Map::new(), + }; + let mut properties = Map::new(); + let mut required = vec![]; + for arg in &sig.args { + let mut prop = match old_props.remove(&arg.name) { + Some(Value::Object(p)) => p, + _ => fresh_property(), + }; + if !matches!(arg.typ, Typ::OneOf(_)) { + prop.remove("oneOf"); + } + apply_typ(&arg.typ, &mut prop); + // `T | T[]` marks an argument debouncing accumulates into. + if let Some(otyp) = arg + .otyp + .as_deref() + .filter(|o| o.contains('[') && o.contains('|')) + { + prop.insert("originalType".into(), json!(otyp)); + } + match &arg.default { + Some(d) => prop.insert("default".into(), d.clone()), + None => prop.remove("default"), + }; + if !arg.has_default { + required.push(json!(arg.name)); + } + properties.insert(arg.name.clone(), Value::Object(prop)); + } + schema.insert("properties".into(), Value::Object(properties)); + schema.insert("required".into(), Value::Array(required)); + if sig.has_cmd_binding == Some(true) { + schema.insert("x-windmill-ps-cmd-binding".into(), json!(true)); + schema.insert( + "x-windmill-ps-supports-should-process".into(), + json!(sig.supports_should_process.unwrap_or(false)), + ); + } else { + schema.remove("x-windmill-ps-cmd-binding"); + schema.remove("x-windmill-ps-supports-should-process"); + } + Value::Object(schema) +} + +/// Keys a user sets on a property in the schema editor, kept whatever the code says. +const USER_FIELDS: &[&str] = &[ + "description", + "pattern", + "min", + "max", + "currency", + "currencyLocale", + "multiselect", + "customErrorMessage", + "required", + "showExpr", + "password", + "order", + "dateFormat", + "title", + "placeholder", +]; + +/// Item keys a list the parser cannot see into keeps from the previous schema. +const ITEMS_USER_FIELDS: &[&str] = &[ + "properties", + "required", + "additionalProperties", + "enum", + "resourceType", + "contentEncoding", + "description", +]; + +fn fresh_property() -> Map { + let mut p = Map::new(); + p.insert("description".into(), json!("")); + p.insert("type".into(), json!("")); + p +} + +fn object_properties( + props: &[ObjectProperty], + old: Option<&Map>, +) -> Map { + props + .iter() + .map(|p| { + let mut prop = old + .and_then(|o| o.get(&p.key)) + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(fresh_property); + apply_typ(&p.typ, &mut prop); + (p.key.clone(), Value::Object(prop)) + }) + .collect() +} + +fn kept_items(old: &Map) -> Value { + let old_items = old.get("items").and_then(Value::as_object); + let mut items = Map::new(); + items.insert( + "type".into(), + old_items + .and_then(|i| i.get("type")) + .filter(|t| t.as_str().is_some_and(|t| !t.is_empty())) + .cloned() + .unwrap_or(json!("object")), + ); + if let Some(old_items) = old_items { + for f in ITEMS_USER_FIELDS { + if let Some(v) = old_items.get(*f) { + items.insert(f.to_string(), v.clone()); + } + } + } + Value::Object(items) +} + +/// Port of `argSigToJsonSchemaType`: rewrites `old` in place to carry `typ`. +fn apply_typ(typ: &Typ, old: &mut Map) { + let mut new = Map::new(); + // Keys the editor sets to `undefined`, which drops them from the stored JSON. + let mut unset: Vec<&str> = vec![]; + let mut set = |k: &str, v: Value| { + new.insert(k.to_string(), v); + }; + match typ { + Typ::Int => set("type", json!("integer")), + Typ::Float => set("type", json!("number")), + Typ::Bool => set("type", json!("boolean")), + Typ::Email => { + set("type", json!("string")); + set("format", json!("email")); + } + Typ::Sql => { + set("type", json!("string")); + set("format", json!("sql")); + } + Typ::Bytes => { + set("type", json!("string")); + set("contentEncoding", json!("base64")); + set("originalType", json!("bytes")); + } + Typ::Datetime => { + set("type", json!("string")); + set("format", json!("date-time")); + } + Typ::Date => { + set("type", json!("string")); + set("format", json!("date")); + } + Typ::OneOf(variants) => { + set("type", json!("object")); + let old_variants = old.get("oneOf").and_then(Value::as_array); + let one_of: Vec = variants + .iter() + .map(|v| { + let old_variant = old_variants + .and_then(|o| o.iter().find(|o| o.get("title") == Some(&json!(v.label)))) + .and_then(Value::as_object); + let old_props = old_variant + .and_then(|o| o.get("properties")) + .and_then(Value::as_object); + let mut variant = json!({ + "type": "object", + "title": v.label, + "properties": object_properties(&v.properties, old_props), + }); + if let Some(order) = old_variant.and_then(|o| o.get("order")) { + variant["order"] = order.clone(); + } + variant + }) + .collect(); + set("oneOf", Value::Array(one_of)); + } + Typ::Object(o) => { + set("type", json!("object")); + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + if let Some(props) = &o.props { + let old_props = old.get("properties").and_then(Value::as_object); + set( + "properties", + Value::Object(object_properties(props, old_props)), + ); + } + } + Typ::Str(variants) => { + set("type", json!("string")); + match variants { + Some(v) => { + set("originalType", json!("enum")); + set("enum", json!(v)); + } + None => { + set("originalType", json!("string")); + match old.get("enum") { + Some(e) if old.get("originalType") == Some(&json!("string")) => { + set("enum", e.clone()) + } + _ => unset.push("enum"), + } + } + } + } + Typ::Resource(name) => { + set("type", json!("object")); + set("format", json!(format!("resource-{name}"))); + } + Typ::DynSelect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynselect-{name}"))); + } + Typ::DynMultiselect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynmultiselect-{name}"))); + } + Typ::List(inner) => { + set("type", json!("array")); + let (items, original_type) = match inner.as_ref() { + Typ::Int | Typ::Float => (json!({"type": "number"}), "number[]"), + Typ::Bytes => ( + json!({"type": "string", "contentEncoding": "base64"}), + "bytes[]", + ), + Typ::Str(Some(v)) => (json!({"type": "string", "enum": v}), "enum[]"), + Typ::Str(None) => { + let mut items = json!({"type": "string"}); + if let Some(e) = old.get("items").and_then(|i| i.get("enum")) { + items["enum"] = e.clone(); + } + (items, "string[]") + } + Typ::Resource(r) => (json!({"type": "resource", "resourceType": r}), "resource[]"), + Typ::Object(o) => { + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + let items = match o.props.as_deref().filter(|p| !p.is_empty()) { + Some(props) => json!({ + "type": "object", + "properties": object_properties(props, None), + }), + None => kept_items(old), + }; + (items, "record[]") + } + _ => (kept_items(old), "object[]"), + }; + set("items", items); + set("originalType", json!(original_type)); + } + // The parser cannot tell: whatever type the user gave the argument stands. + Typ::Unknown => set("type", old.get("type").cloned().unwrap_or(json!("object"))), + } + + for f in USER_FIELDS { + if let Some(v) = old.get(*f) { + new.insert(f.to_string(), v.clone()); + } + } + + let old_type = old.get("type").cloned(); + let new_type = new.get("type").cloned(); + let old_format = old + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let new_format = new + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let is_date = |f: &Option| matches!(f.as_deref(), Some("date" | "date-time")); + let mut keep_old_format = false; + // A changed type takes every key `new` sets, so none of the adjustments below + // apply: running them would carry the old type's date format or items over. + if old_type != new_type { + } else if new_format.as_deref() == Some("date-time") && old_format.as_deref() == Some("date") { + new.insert("format".into(), json!("date")); + } else if is_date(&new_format) { + keep_old_format = true; + } else if old.get("items").and_then(|i| i.get("type")) + != new.get("items").and_then(|i| i.get("type")) + { + old.remove("items"); + } else if old_type == Some(json!("string")) && old_format.is_some() { + keep_old_format = true; + } + if keep_old_format { + match old.get("format") { + Some(f) => { + new.insert("format".into(), f.clone()); + } + None => { + new.remove("format"); + unset.push("format"); + } + } + } + if (old_type != new_type || new_type != Some(json!("string"))) && !new.contains_key("format") { + old.remove("format"); + } + + for k in unset { + if !new.contains_key(k) { + old.remove(k); + } + } + old.extend(new); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Arg, ObjectType}; + + fn arg(name: &str, typ: Typ, default: Option) -> Arg { + Arg { + name: name.to_string(), + has_default: default.is_some(), + default, + typ, + ..Default::default() + } + } + + #[test] + fn keeps_what_the_user_set_on_the_previous_version() { + let sig = MainArgSignature { + args: vec![ + arg("n", Typ::Int, None), + arg("db", Typ::Resource("postgresql".into()), None), + arg( + "tags", + Typ::List(Box::new(Typ::Str(None))), + Some(json!(["a"])), + ), + arg( + "opts", + Typ::Object(ObjectType::new(None, None)), + Some(json!({})), + ), + arg("any", Typ::Unknown, None), + ], + ..Default::default() + }; + let previous = json!({ + "type": "object", + "order": ["db", "n"], + "properties": { + "n": {"type": "integer", "description": "how many", "min": 1, "nullable": true}, + "tags": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}, + "gone": {"type": "string"}, + }, + }); + let schema = main_arg_schema(&sig, Some(&previous)); + assert_eq!(schema["order"], json!(["db", "n"])); + assert_eq!(schema["required"], json!(["n", "db", "any"])); + assert_eq!( + schema["properties"]["n"], + json!({"type": "integer", "description": "how many", "min": 1, "nullable": true}) + ); + assert!(schema["properties"].get("gone").is_none()); + assert_eq!(schema["properties"]["db"]["format"], "resource-postgresql"); + assert_eq!( + schema["properties"]["tags"]["items"], + json!({"type": "string", "enum": ["a", "b"]}) + ); + assert_eq!(schema["properties"]["tags"]["default"], json!(["a"])); + assert_eq!(schema["properties"]["opts"]["type"], "object"); + // Untyped in the code and new: any JSON value, as the editor stores it. + assert_eq!(schema["properties"]["any"]["type"], ""); + } +} diff --git a/backend/parsers/windmill-parser/src/lib.rs b/backend/parsers/windmill-parser/src/lib.rs index 4270edcc79..7ca226b432 100644 --- a/backend/parsers/windmill-parser/src/lib.rs +++ b/backend/parsers/windmill-parser/src/lib.rs @@ -15,6 +15,7 @@ use serde_json::Value; pub mod asset_parser; pub mod duckdb_builtins; pub mod duckdb_macros; +pub mod json_schema; pub mod sql_materialize; /// S3 output format for SQL queries (moved here to avoid pulling sqlx into WASM via windmill-types) diff --git a/backend/src/main.rs b/backend/src/main.rs index 9e7a93cfcd..68240dd119 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1944,13 +1944,13 @@ async fn process_notify_event( #[cfg(feature = "http_trigger")] "notify_http_trigger_change" => { tracing::info!("HTTP trigger change detected: {}", payload); - match windmill_api::triggers::http::refresh_routers(db, true).await { - Ok(_) => { + match windmill_api::triggers::http::refresh_loaded_routers(db, true).await { + Ok(true) => { tracing::info!("Refreshed HTTP routers (trigger change)"); } + Ok(false) => {} Err(err) => { tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}"); - windmill_api::triggers::http::invalidate_routers(); return false; } }; @@ -2207,8 +2207,8 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload http route workspaced route setting"); } #[cfg(feature = "http_trigger")] - match windmill_api::triggers::http::refresh_routers(db, false).await { - Ok((true, _)) => { + match windmill_api::triggers::http::refresh_loaded_routers(db, false).await { + Ok(true) => { tracing::info!( "Refreshed HTTP routers (http workspaced route setting change)" ); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index ea756fc23c..169eb81681 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -171,6 +171,9 @@ postgres_trigger: path(char), script_path(char), is_flow(bool), workspace_id(cha FK: (workspace_id) -> workspace(id) raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(ts), data(text), extra_perms(jsonb), labels(text[]) FK: (workspace_id) -> workspace(id) +remote_deploy_token: workspace_id(char), email(char), base_url(char), remote_workspace_id(char), token(text), remote_email(char), proxy_key(char), connected_at(timestamptz), member_since(timestamptz), target_changed_at(timestamptz) + FK: (email) -> password(email) + FK: (workspace_id) -> workspace(id) resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char), labels(text[]) FK: (workspace_id) -> workspace(id) resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool), display_name(char) @@ -234,7 +237,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr FK: (workspace_id) -> workspace(id) workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool) FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id) -workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool) +workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts) FK: (workspace_id) -> workspace(id) zombie_job_counter: job_id(uuid), counter(int) FK: (job_id) -> v2_job(id) diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 7af2e0794e..105458847a 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests"); - let resp = authed( - client().post(format!("{ws}/apps/update/{APP_PATH}")), - ADMIN_TOKEN, - ) - .json(&json!({ "path": "u/test-user/My App" })) - .send() - .await?; - assert_eq!( - resp.status(), - 200, - "a space is literal: {}", - resp.text().await? - ); - // Set on update: an app that already sits on such a path cannot be switched. + // Set on update: an app that already sits on such a path cannot be switched. Path + // validation now refuses these paths on every write, so plant the kind of app saved + // before it did. let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&json!({ - "path": "u/test-user/x:y", + "path": "u/test-user/legacy", "summary": "App", "value": {}, "policy": { "execution_mode": "publisher", "triggerables_v2": {} } @@ -602,6 +591,12 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 201, "{}", resp.text().await?); + sqlx::query( + "UPDATE app SET path = 'u/test-user/x:y' + WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/legacy'", + ) + .execute(&db) + .await?; let resp = authed( client().post(format!("{ws}/apps/update/u/test-user/x:y")), ADMIN_TOKEN, @@ -610,6 +605,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "switched to Guests on a `:` path"); + let body = resp.text().await?; + assert!(body.contains("cannot be set to Guests"), "{body}"); Ok(()) } diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 63d5341ba3..14f8c422a4 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -1303,6 +1303,62 @@ async fn test_bun_wac_inline_task_cache_is_per_task(db: Pool) -> anyho Ok(()) } +/// A task that picks its own worker tag is held to CUSTOM_TAGS like a flow step: with none +/// set, only a superadmin may send it to `gpu`, so the workflow fails instead of queueing it. +#[sqlx::test(fixtures("base"))] +async fn test_bun_wac_task_tag_outside_custom_tags(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +import { workflow, task } from "windmill-client"; + +const onGpu = task(async (n: number) => { + return n * 2; +}, { tag: "gpu" }); + +export const main = workflow(async (n: number) => { + return await onGpu(n); +}); +"# + .to_owned(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Bun, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + })) + .arg("n", serde_json::json!(5)) + .as_user("test-user-2", "test2@windmill.dev") + .run_until_complete(&db, false, port) + .await; + + assert!(!job.success, "the workflow must fail"); + let result = job.json_result().unwrap(); + let message = result["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("task 'onGpu' cannot run on tag 'gpu'"), + "unexpected error: {result}" + ); + let children: i64 = sqlx::query_scalar("SELECT count(*) FROM v2_job WHERE parent_job = $1") + .bind(job.id) + .fetch_one(&db) + .await?; + assert_eq!(children, 0, "no child is queued on the refused tag"); + Ok(()) +} + // ============================================================================ // Environment Variable Tests // ============================================================================ diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index b6a943a23b..7ea221c2e8 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> a Ok(()) } +// A branch/loop sub-flow of a deployed flow is marked at push when its parent has no flow_env, +// own or inherited, so its worker skips `fetch_root_flow_env`. With an env two scopes up (root +// → loop → imported flow without env → parallel loop → leaf) nothing may be marked and the +// root's value must reach the leaf; with no env anywhere every loop iteration is marked. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Deployed, so their loops run as `FlowNode` sub-flows. + let deploy = |path: &'static str, version: i64, value: serde_json::Value| { + let db = db.clone(); + async move { + sqlx::query( + "INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')", + ) + .bind(path) + .bind(version) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')", + ) + .bind(version) + .bind(path) + .bind(&value) + .execute(&db) + .await?; + RunJob::from(JobPayload::FlowDependencies { + path: path.to_string(), + dedicated_worker: None, + version, + debouncing_settings: Default::default(), + }) + .run_until_complete(&db, false, port) + .await; + anyhow::Ok(()) + } + }; + deploy( + "f/system/imported_loop_no_env", + 9991004, + json!({ "modules": [{ "id": "inner", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1, 2]" }, + "skip_failures": false, + "parallel": true, + "modules": [{ "id": "leaf", "value": { + "type": "rawscript", + "language": "deno", + "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", + "content": "export function main(prev: any) { return prev; }", + "input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } } + }}] + }}]}), + ) + .await?; + let root = |flow_env: serde_json::Value| { + json!({ "flow_env": flow_env, "modules": [{ "id": "outer", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1]" }, + "skip_failures": false, + "parallel": false, + "modules": [{ "id": "import", "value": { + "type": "flow", + "path": "f/system/imported_loop_no_env", + "input_transforms": {} + }}] + }}]}) + }; + deploy( + "f/system/root_with_env", + 9991005, + root(json!({ "KEY": "from-root" })), + ) + .await?; + deploy("f/system/root_no_env", 9991006, root(json!(null))).await?; + + let run = |path: &'static str, version: i64| { + let db = db.clone(); + async move { + let completed = RunJob::from(JobPayload::Flow { + path: path.to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version, + labels: None, + }) + .run_until_complete(&db, false, port) + .await; + // The mark of every loop iteration pushed under the run. + let marks = sqlx::query_as::<_, (String, Option)>( + "SELECT j.kind::text, (c.flow_status->>'no_inherited_flow_env')::bool + FROM v2_job j JOIN v2_job_completed c ON c.id = j.id + WHERE COALESCE(j.root_job, j.parent_job) = $1 + AND j.flow_step_id IN ('outer', 'inner')", + ) + .bind(completed.id) + .fetch_all(&db) + .await?; + anyhow::Ok((completed.json_result().unwrap(), marks)) + } + }; + + let (result, marks) = run("f/system/root_with_env", 9991005).await?; + assert_eq!(result, json!([["from-root", "from-root"]])); + assert_eq!(marks, vec![("flownode".to_string(), None); 3]); + + let (result, marks) = run("f/system/root_no_env", 9991006).await?; + assert_eq!(result, json!([[null, null]])); + assert_eq!(marks, vec![("flownode".to_string(), Some(true)); 3]); + + Ok(()) +} + // stop_after_if predicate sees flow_env. Regression for the eval at line 614 // of `update_flow_status_after_job_completion_internal` which used to pass // `None` for flow_env unconditionally. diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 5ab870a364..26a46d272e 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1608,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

) { + clear_settings_and_configs(&db).await; + + let mut desired = BTreeMap::new(); + desired.insert( + "accent_color".to_string(), + serde_json::json!("#000;}body{display:none"), + ); + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect_err("a non-hex accent color must fail the sync"); + assert!(err.to_string().contains("accent_color"), "got: {err}"); + assert!(get_global_setting(&db, "accent_color").await.is_none()); + + desired.insert("accent_color".to_string(), serde_json::json!("#1f9d55")); + windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect("a hex accent color must sync"); +} diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index c538cbe6e1..1a973f6fe6 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -239,6 +239,7 @@ mod job_payload { let result = RunJob::from(JobPayload::FlowNode { id, path: "f/system/hello_with_nodes_flow/forloop-0".into(), + no_inherited_flow_env: false, }) .arg("iter", json!({ "value": "tests", "index": 0 })) .run_until_complete(&db, false, port) diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index c8c154c250..8c5bfee0eb 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -516,10 +516,14 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul Some("SECRET_TOKEN_2"), ) .await; - assert!(status.is_success(), "owner must mint a resume secret: {secret}"); + assert!( + status.is_success(), + "owner must mint a resume secret: {secret}" + ); let secret = secret.trim().trim_matches('"').to_string(); - let approval_result = - format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"); + let approval_result = format!( + "completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}" + ); let (status, body) = get(&base, &approval_result, None).await; assert!( status.is_success(), @@ -991,6 +995,89 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul !status.is_success(), "a public token must not let an anonymous caller cancel the run (got {status}): {body}" ); + // ---- RUN_NOW is gated like cancel, and only moves a job still waiting for a + // future start. ---- + let run_now = format!("queue/run_now/{RUNNING_JOB}"); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_3")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must not start another user's job early (got {status}): {body}" + ); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "a running job has no future start to move (got {status}): {body}" + ); + sqlx::query( + "UPDATE v2_job_queue SET running = false, scheduled_for = now() + interval '1 hour' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "owner must start their deferred job now (got {status}): {body}" + ); + let due: bool = + sqlx::query_scalar("SELECT scheduled_for <= now() FROM v2_job_queue WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .fetch_one(&db) + .await?; + assert!(due, "run_now must make the job due immediately"); + + // A schedule tick that is not due yet is refused: the schedule would queue that same + // tick again on completion and run twice. + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as) + VALUES ('test-workspace', 'u/test-user-2/daily', 'test-user-2', '0 0 3 * * *', + 'u/test-user-2/running_secret', 'u/test-user-2')", + ) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job SET trigger_kind = 'schedule', trigger = 'u/test-user-2/daily' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = + (date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day 3 hours') AT TIME ZONE 'UTC' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "an upcoming schedule tick must not be started early (got {status}): {body}" + ); + // A tick a concurrency limit pushed past its time is off the cron occurrences and + // may still be started early. + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = now() + interval '1 hour 37 seconds 123 milliseconds' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "a deferred schedule tick must still start now (got {status}): {body}" + ); + sqlx::query("UPDATE v2_job SET trigger_kind = NULL, trigger = NULL WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .execute(&db) + .await?; + // The owner still cancels their own job (no over-blocking). Keep this last: it // takes RUNNING_JOB out of the queue. let (status, body) = post( diff --git a/backend/tests/perpetual_cancel_race.rs b/backend/tests/perpetual_cancel_race.rs new file mode 100644 index 0000000000..df239eb7ab --- /dev/null +++ b/backend/tests/perpetual_cancel_race.rs @@ -0,0 +1,183 @@ +//! A cancel reaches the queue row first and the worker only later: one landing after the worker's +//! last read of that row completes the run carrying no `canceled_by` of its own, and a perpetual +//! script must still stop rather than restart as if nothing had been cancelled. + +use serde_json::json; +use sqlx::{types::Json, Pool, Postgres}; +use uuid::Uuid; +use windmill_queue::{add_completed_job, get_mini_completed_job}; + +const W_ID: &str = "test-workspace"; +const PATH: &str = "u/test-user/loop"; + +async fn start_perpetual_run(db: &Pool) -> anyhow::Result { + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, \ + language, lock, restart_unless_cancelled) \ + VALUES ($1, 1, $2, '', '', 'echo', 'test-user', 'bash', '', true)", + ) + .bind(W_ID) + .bind(PATH) + .execute(db) + .await?; + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \ + permissioned_as_email, kind, runnable_id, runnable_path, script_lang, tag, args, \ + visible_to_owner) \ + VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', 'script', 1, \ + $3, 'bash', 'bash', '{\"n\": 1}', true)", + ) + .bind(id) + .bind(W_ID) + .bind(PATH) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) \ + VALUES ($1, $2, now(), true, now(), 'bash')", + ) + .bind(id) + .bind(W_ID) + .execute(db) + .await?; + Ok(id) +} + +/// Waits for another connection to be stuck on the queue row of `id`, which is where the +/// completion's delete lands while the cancel is uncommitted. +async fn wait_until_blocked_on(db: &Pool, id: Uuid) -> anyhow::Result<()> { + for _ in 0..100 { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks l JOIN v2_job_queue q \ + ON q.ctid = ('(' || l.page || ',' || l.tuple || ')')::tid \ + WHERE NOT l.granted AND l.locktype = 'tuple' AND q.id = $1) \ + OR EXISTS (SELECT 1 FROM pg_stat_activity \ + WHERE wait_event_type = 'Lock' AND query LIKE '%v2_job_queue%')", + ) + .bind(id) + .fetch_one(db) + .await?; + if blocked { + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + anyhow::bail!("the completion never reached the delete, so the race was not exercised") +} + +/// The same cancel, one step earlier: still being written when the completion reads the queue row, +/// which reads it without a lock. Only the delete that follows waits for the writer. +#[sqlx::test(fixtures("base"))] +async fn a_cancel_still_being_written_is_kept(db: Pool) -> anyhow::Result<()> { + let running = start_perpetual_run(&db).await?; + let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap(); + + let mut cancel = db.begin().await?; + sqlx::query( + "UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1", + ) + .bind(running) + .execute(&mut *cancel) + .await?; + + let completing = tokio::spawn({ + let db = db.clone(); + async move { + add_completed_job( + &db, + &job, + true, + false, + Json(&json!("done")), + None, + 0, + None, + false, + None, + false, + ) + .await + } + }); + // Committing on a timer would let a slow completion read the cancel through the insert + // instead, leaving the half this test is for unexercised and green. Waiting for the delete to + // block on the row is what puts it there. + wait_until_blocked_on(&db, running).await?; + cancel.commit().await?; + completing.await??; + + let (status, canceled_by): (String, Option) = + sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1") + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(status, "canceled", "the run is recorded as canceled"); + assert_eq!(canceled_by.as_deref(), Some("test-user")); + + let queued: Vec = sqlx::query_scalar( + "SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(PATH) + .fetch_all(&db) + .await?; + assert!( + queued.is_empty(), + "a canceled perpetual run queues no next one: {queued:?}" + ); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn a_cancel_landing_after_the_worker_read_its_row_is_kept( + db: Pool, +) -> anyhow::Result<()> { + let running = start_perpetual_run(&db).await?; + sqlx::query( + "UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1", + ) + .bind(running) + .execute(&db) + .await?; + + let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap(); + add_completed_job( + &db, + &job, + true, + false, + Json(&json!("done")), + None, + 0, + // What the worker carries when the cancel landed after it last read the row. + None, + false, + None, + false, + ) + .await?; + + let (status, canceled_by): (String, Option) = + sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1") + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(status, "canceled", "the run is recorded as canceled"); + assert_eq!(canceled_by.as_deref(), Some("test-user")); + + let queued: Vec = sqlx::query_scalar( + "SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(PATH) + .fetch_all(&db) + .await?; + assert!( + queued.is_empty(), + "a canceled perpetual run queues no next one: {queued:?}" + ); + Ok(()) +} diff --git a/backend/tests/raw_apps_list_scope.rs b/backend/tests/raw_apps_list_scope.rs new file mode 100644 index 0000000000..503bca7577 --- /dev/null +++ b/backend/tests/raw_apps_list_scope.rs @@ -0,0 +1,76 @@ +//! `GET /raw_apps/list` must honor a path-scoped token. The route layer only checks +//! the scope domain and RLS knows nothing of token scopes, so the per-path filter +//! lives in the handler, ahead of the page's LIMIT. + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +async fn list_paths(port: u16, token: &str, query: &str) -> anyhow::Result> { + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/raw_apps/list?{query}" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await?; + assert_eq!(resp.status(), 200, "list: {}", resp.text().await?); + let body: Vec = resp.json().await?; + let mut paths: Vec = body + .iter() + .map(|a| a["path"].as_str().unwrap().to_string()) + .collect(); + paths.sort(); + Ok(paths) +} + +#[sqlx::test(fixtures("base"))] +async fn test_raw_apps_list_filters_by_token_scope(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + // The list sorts by edited_at desc, so the out-of-scope apps fill the first page + // unless the scope is applied before the LIMIT. + for (path, age_minutes) in [ + ("f/other/c", 0), + ("u/test-userx/d", 1), + ("u/test-user/a", 2), + ("u/test-user/sub/b", 3), + // A `prefix/*` grant also covers the path at the prefix itself. + ("u/test-user", 4), + ] { + sqlx::query( + "INSERT INTO raw_app (path, workspace_id, data, edited_at) + VALUES ($1, 'test-workspace', '', now() - make_interval(mins => $2))", + ) + .bind(path) + .bind(age_minutes) + .execute(&db) + .await?; + } + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, scopes) + VALUES (encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN', + 'test@windmill.dev', 'scoped', ARRAY['raw_apps:read:u/test-user/*'])", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The same user through an unscoped token sees every row. + assert_eq!( + list_paths(port, "SECRET_TOKEN", "").await?, + [ + "f/other/c", + "u/test-user", + "u/test-user/a", + "u/test-user/sub/b", + "u/test-userx/d" + ] + ); + assert_eq!( + list_paths(port, "SCOPED_TOKEN", "per_page=3").await?, + ["u/test-user", "u/test-user/a", "u/test-user/sub/b"] + ); + Ok(()) +} diff --git a/backend/tests/script_schema_inference.rs b/backend/tests/script_schema_inference.rs new file mode 100644 index 0000000000..083716f8df --- /dev/null +++ b/backend/tests/script_schema_inference.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use windmill_api_client::types::{NewScript, ScriptLang}; +use windmill_test_utils::init_client; + +const PATH: &str = "u/test-user/inferred_schema"; + +fn bun_ns(content: &str, parent_hash: Option) -> NewScript { + NewScript { + draft_only: None, + content: content.into(), + language: ScriptLang::Bun, + lock: None, + parent_hash, + path: PATH.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + envs: vec![], + is_template: None, + kind: None, + summary: "".to_string(), + tag: None, + // What this client sends for "no schema". + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } +} + +async fn head(db: &Pool) -> anyhow::Result<(i64, Value)> { + Ok(sqlx::query_as( + "SELECT hash, schema::jsonb FROM script \ + WHERE workspace_id = 'test-workspace' AND path = $1 AND archived = false", + ) + .bind(PATH) + .fetch_one(db) + .await?) +} + +/// A deploy without a schema (MCP, a bare API call) gets the one the editor would +/// have sent, and a new version keeps what the user wrote on the previous one's. +#[sqlx::test(fixtures("base"))] +async fn schemaless_deploy_infers_schema_keeping_annotations( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, name = \"x\") { return n }", + None, + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n"])); + assert_eq!(schema["properties"]["n"]["type"], "number"); + assert_eq!(schema["properties"]["name"]["default"], "x"); + + sqlx::query( + "UPDATE script SET schema = jsonb_set(schema::jsonb, '{properties,n,description}', \ + '\"how many\"')::json WHERE hash = $1", + ) + .bind(hash) + .execute(&db) + .await?; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, flag: boolean) { return n }", + Some(format!("{hash:016x}")), + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n", "flag"])); + assert_eq!(schema["properties"]["n"]["description"], "how many"); + assert_eq!(schema["properties"]["flag"]["type"], "boolean"); + assert!(schema["properties"].get("name").is_none()); + + // Code that does not parse keeps the schema rather than losing it for good. + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number {", + Some(format!("{hash:016x}")), + ), + ) + .await?; + assert_eq!(head(&db).await?.1, schema); + + Ok(()) +} diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index b8bd6b30a7..d7fb992280 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -5439,7 +5439,7 @@ async fn test_duckdb_ffi(db: Pool) -> anyhow::Result<()> { } /// Test that flow substeps with tags that are not available for the workspace fail. -/// This validates that `check_tag_available_for_workspace_internal` is properly called +/// This validates that `check_tag_available_for_push` is properly called /// when pushing jobs from worker_flow. #[sqlx::test(fixtures("base"))] #[serial] @@ -5512,17 +5512,178 @@ async fn test_flow_substep_tag_availability_check(db: Pool) -> anyhow: Ok(()) } +/// A step's `$flow_expr[...]` tag is checked on the queue it resolves to, not on its text: the +/// custom tags list `bun` alone, which admits the step resolving to it and refuses the one that +/// lands elsewhere. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +#[serial] +async fn test_flow_substep_tag_checked_on_resolved_value(db: Pool) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![ + "bun".to_string() + ]))); + + let step = |id: &str, tag: Option<&str>| { + json!({ + "id": id, + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main() { return { lang: 'bun' } }", + "tag": tag, + }, + }) + }; + let flow: FlowValue = serde_json::from_value(json!({ + "modules": [ + step("a", None), + step("b", Some("$flow_expr[results.a.lang]")), + step("c", Some("$flow_expr[results.a.lang]-gpu")), + ], + }))?; + + // A non-superadmin, so the custom tags apply. + let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .as_user("test-user-2", "test2@windmill.dev") + .run_until_complete(&db, false, server.addr.port()) + .await; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + + let b_tag = sqlx::query_scalar::<_, String>( + "SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'", + ) + .bind(job.id) + .fetch_one(&db) + .await?; + assert_eq!(b_tag, "bun"); + + assert!(!job.success); + let result = job.json_result().unwrap(); + let message = result["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("(resolved to bun-gpu) is not included in the allowed CUSTOM_TAGS"), + "got {result:?}" + ); + + Ok(()) +} + +/// A flow whose preprocessor runs lands on its tag filled in from the preprocessor's output, so +/// running it judges the tag as written: even raw args that fill it in to a listed tag do not +/// admit a template nobody listed. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "hello"))] +#[serial] +async fn test_flow_tag_judged_as_written_before_preprocessor( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let set_flow_tag = |tag: &'static str| { + let db = db.clone(); + async move { + sqlx::query("UPDATE flow SET tag = $1 WHERE path = 'f/system/hello_with_preprocessor'") + .bind(tag) + .execute(&db) + .await?; + // Flow info is cached per version, which a real redeploy would bump. + windmill_common::FLOW_INFO_CACHE + .remove(&("test-workspace".to_string(), 1443253234253456)); + anyhow::Ok(()) + } + }; + // As a non-superadmin, so the custom tags apply. + let run_as_user = |entries: &[&str], query: &str| { + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from( + entries.iter().map(|e| e.to_string()).collect(), + ))); + reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/jobs/run/f/f/system/hello_with_preprocessor{query}" + )) + .bearer_auth("SECRET_TOKEN_2") + .json(&json!({ "foo": "bar" })) + .send() + }; + + sqlx::query( + "UPDATE flow SET extra_perms = '{\"u/test-user-2\": false}' + WHERE path = 'f/system/hello_with_preprocessor'", + ) + .execute(&db) + .await?; + set_flow_tag("pp-$args[foo]").await?; + let refused = run_as_user(&["pp-bar"], "").await?; + let refused_status = refused.status(); + let refused_body = refused.text().await?; + // `push` drops a `?tag=` of such a flow, so it does not stand in for the flow's own tag. + let overridden = run_as_user(&["pp-bar"], "?tag=pp-bar").await?.status(); + let admitted = run_as_user(&["pp-$args[foo]"], "").await?.status(); + // A tag the preprocessor cannot change is known now, so patterns apply to it. + set_flow_tag("pp-large").await?; + let static_admitted = run_as_user(&["pp-$args[size]"], "").await?.status(); + + // Nothing a flow's tag can read resolves `$flow_expr[...]`, so the flow keeps its tag. + set_flow_tag("$flow_expr[flow_input.foo]").await?; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + let unresolvable = RunJob::from(JobPayload::Flow { + path: "f/system/hello_with_preprocessor".to_string(), + dedicated_worker: None, + apply_preprocessor: true, + version: 1443253234253456, + labels: None, + }) + .as_user("test-user-2", "test2@windmill.dev") + .run_until_complete(&db, false, port) + .await; + + assert_eq!(refused_status, 400, "got {refused_body}"); + assert!( + refused_body.contains("Tag pp-$args[foo] is not included"), + "got {refused_body}" + ); + assert_eq!(overridden, 400); + assert!(admitted.is_success(), "got {admitted}"); + assert!(static_admitted.is_success(), "got {static_admitted}"); + + assert!(unresolvable.success, "got {:?}", unresolvable.json_result()); + let tag = sqlx::query_scalar::<_, String>("SELECT tag FROM v2_job WHERE id = $1") + .bind(unresolvable.id) + .fetch_one(&db) + .await?; + assert_eq!(tag, "flow"); + + Ok(()) +} + /// The `*` fork marker only grants through a real `parent_workspace_id` lineage lookup, which the /// parse-level unit tests cannot reach: they hand `applies_to_workspace` a synthetic chain, so a /// regression in the lookup or in the `is_fork_scoped()` gate that skips it would pass them. #[sqlx::test(fixtures("base"))] #[serial] async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> anyhow::Result<()> { - use windmill_common::jobs::check_tag_available_for_workspace_internal; use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; initialize_tracing().await; + // For a non-superadmin caller (a superadmin would bypass the scope check entirely). + async fn allowed(db: &Pool, w_id: &str, tag: &str) -> bool { + let no_args = std::collections::HashMap::new(); + let args = windmill_queue::PushArgs::from(&no_args); + windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None) + .await + .is_ok() + } + // The ancestor chain is cached process-wide by workspace id, so use one no other test takes. let fork = "wm-fork-tagmarker"; sqlx::query!( @@ -5538,27 +5699,18 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> a "bare(test-workspace)".to_string(), ]))); - // A non-superadmin caller (a superadmin would bypass the scope check entirely). - let is_super_admin = false; - for (w_id, tag) in [("test-workspace", "bare"), ("test-workspace", "forky")] { assert!( - check_tag_available_for_workspace_internal(&db, w_id, tag, is_super_admin, None) - .await - .is_ok(), + allowed(&db, w_id, tag).await, "{tag} should be available in the workspace it names" ); } assert!( - check_tag_available_for_workspace_internal(&db, fork, "forky", is_super_admin, None) - .await - .is_ok(), + allowed(&db, fork, "forky").await, "a `*` tag must be granted to a fork through its parent lineage" ); assert!( - check_tag_available_for_workspace_internal(&db, fork, "bare", is_super_admin, None) - .await - .is_err(), + !allowed(&db, fork, "bare").await, "an unmarked tag must not reach a fork of the workspace it names" ); @@ -5567,6 +5719,122 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> a Ok(()) } +/// A workspace-scoped pattern admits the tags in its range only from the workspaces its scope +/// allows, and a tag listed by its own name with a scope stays inside that scope whatever pattern +/// it fits. The fork lineage and `$workspace` are resolved for a matching pattern. +#[sqlx::test(fixtures("base"))] +#[serial] +async fn test_scoped_custom_tag_pattern_admission(db: Pool) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + initialize_tracing().await; + + // For a non-superadmin caller (a superadmin would bypass the scope check entirely). + async fn check(db: &Pool, w_id: &str, tag: &str, size: &str) -> Result<(), String> { + let args = std::collections::HashMap::from([( + "size".to_string(), + serde_json::value::to_raw_value(size).unwrap(), + )]); + let args = windmill_queue::PushArgs::from(&args); + windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None) + .await + .map_err(|e| e.to_string()) + } + async fn allowed(db: &Pool, w_id: &str, tag: &str, size: &str) -> bool { + check(db, w_id, tag, size).await.is_ok() + } + async fn allowed_as_written(db: &Pool, w_id: &str, tag: &str) -> bool { + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + false, + None, + ) + .await + .is_ok() + } + + // The ancestor chain is cached process-wide by workspace id, so use one no other test takes. + let fork = "wm-fork-tagpattern"; + sqlx::query!( + "INSERT INTO workspace (id, name, owner, parent_workspace_id) + VALUES ($1, $1, 'test-user', 'test-workspace')", + fork + ) + .execute(&db) + .await?; + + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![ + "gpu-$args[size]".to_string(), + "$args[size]-secret(test-workspace)".to_string(), + "gpu-secret(other)".to_string(), + "$workspace-$args[size](test-workspace*)".to_string(), + "urgent".to_string(), + "urgent(other)".to_string(), + ]))); + + let confined = check(&db, "test-workspace", "gpu-$args[size]", "secret").await; + let checks = [ + ( + "scoped pattern from its workspace", + allowed(&db, "test-workspace", "cpu-secret", "").await, + ), + ( + "scoped pattern refused elsewhere", + !allowed(&db, "other", "cpu-secret", "").await, + ), + ("confined tag refused past the patterns it fits", confined.is_err()), + ( + "global pattern for the tags nothing confines", + allowed(&db, "test-workspace", "gpu-large", "").await, + ), + ( + "confined tag from its workspace", + allowed(&db, "other", "gpu-secret", "").await, + ), + // Listing the tag by name without a scope as well keeps it open everywhere. + ( + "tag listed both globally and scoped", + allowed(&db, "test-workspace", "urgent", "").await, + ), + // The fork's `$workspace` is its parent's, whose tags its lineage reaches. Written as the + // resolved tag, not as the entry, so only the pattern can admit it. + ( + "fork-scoped `$workspace` pattern from a fork", + allowed(&db, fork, "test-workspace-large", "").await, + ), + ( + "fork-scoped `$workspace` pattern refused elsewhere", + !allowed(&db, "other", "$workspace-$args[size]", "large").await, + ), + ( + "as written from its workspace", + allowed_as_written(&db, "test-workspace", "$args[size]-secret").await, + ), + ( + "as written refused elsewhere", + !allowed_as_written(&db, "other", "$args[size]-secret").await, + ), + ]; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + + for (what, ok) in checks { + assert!(ok, "{what}"); + } + let confined = confined.unwrap_err(); + assert!( + confined.contains("the custom tag gpu-secret(other) restricts it to other workspaces") + && confined.contains("gpu-$args[size]") + && confined.contains("$args[size]-secret(test-workspace)"), + "got {confined}" + ); + + Ok(()) +} + #[cfg(all(feature = "quickjs", feature = "python"))] #[sqlx::test(fixtures("base"))] async fn test_whileloop_propagates_inner_iterator_eval_failure( diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 6e5aa6b1da..869f40e14c 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -1073,7 +1073,8 @@ fn no_auth_admin_authed() -> ApiAuthed { } /// Resolves OptJobAuthed from request parts. -/// Takes ownership of Parts and returns them back. +/// Takes ownership of Parts and returns them back, memoized into the extensions so a +/// second extraction in the same request is free. #[allow(unreachable_code, unused_mut)] pub async fn resolve_opt_job_authed( mut parts: Parts, @@ -1089,10 +1090,11 @@ pub async fn resolve_opt_job_authed( )); } - let already_authed = parts.extensions.get::().cloned(); - - if let Some(authed) = already_authed { - return Ok((authed, parts)); + // Safe only while what the route checks below read is fixed before the first + // extraction: path and method always are, `workspace_id` only while every + // `GatewayWorkspaceId` writer stays layered outside auth extraction. + if let Some(opt_job_authed) = parts.extensions.get::().cloned() { + return Ok((opt_job_authed, parts)); } let already_tokened = parts.extensions.get::().cloned(); @@ -1185,6 +1187,9 @@ pub async fn resolve_opt_job_authed( if let Some(workspace_id) = workspace_id { Span::current().record("workspace_id", &workspace_id); } + // Separate from the ApiAuthed insert above, which handlers taking + // `Extension` read. + parts.extensions.insert(opt_job_authed.clone()); return Ok((opt_job_authed, parts)); } } @@ -1311,3 +1316,69 @@ pub async fn list_tokens_internal( Ok(Json(tokens)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo + /// hit observable: only a working memo can answer the second one. + #[tokio::test] + async fn resolve_opt_job_authed_is_memoized() { + // Never connected: an AUTH_CACHE hit resolves without a query, and AuthCache + // only needs the handle to exist. Without the timeout a regression — which + // falls through to a real lookup — spends sqlx's 30s default failing. + let db = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(50)) + .connect_lazy("postgres://memo@127.0.0.1:1/memo") + .expect("lazy pool"); + + let token = "memo_guard_token"; + // Workspace-scoped: a job token on a workspace-less route is refused by + // check_job_token_for_global_route before the memo is ever taken. + let key = ("memo_workspace".to_string(), token.to_string()); + let job_id = uuid::Uuid::from_u128(0x6d656d6f); + AUTH_CACHE.insert( + key.clone(), + ExpiringAuthCache { + authed: ApiAuthed { + email: "memo@windmill.dev".to_string(), + username: "memo".to_string(), + ..Default::default() + }, + expiry: chrono::Utc::now() + chrono::Duration::hours(1), + job_id: Some(job_id), + }, + ); + + #[cfg(feature = "enterprise")] + let cache = AuthCache::new(db, None, None); + #[cfg(not(feature = "enterprise"))] + let cache = AuthCache::new(db, None); + + let mut parts = http::Request::builder() + .uri("/api/w/memo_workspace/jobs/list") + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .body(()) + .unwrap() + .into_parts() + .0; + parts.extensions.insert(Arc::new(cache)); + + let (_, parts) = resolve_opt_job_authed(parts) + .await + .map_err(|(e, _)| e) + .expect("first resolution"); + + AUTH_CACHE.remove(&key); + + let (second, _) = resolve_opt_job_authed(parts) + .await + .map_err(|(e, _)| e) + .expect("second resolution must hit the memo, not re-resolve"); + assert_eq!(second.authed.username, "memo"); + // The wrapper must survive whole: memoizing only the inner ApiAuthed and + // rebuilding around it drops the provenance forbid_elevated_job_token gates on. + assert_eq!(second.job_id, Some(job_id)); + } +} diff --git a/backend/windmill-api-integration-tests/tests/login_link.rs b/backend/windmill-api-integration-tests/tests/login_link.rs index 3906232075..5c3a231a48 100644 --- a/backend/windmill-api-integration-tests/tests/login_link.rs +++ b/backend/windmill-api-integration-tests/tests/login_link.rs @@ -115,60 +115,6 @@ async fn login_link_is_single_use_and_same_origin(db: Pool) -> anyhow: Ok(()) } -#[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn confirmed_login_link_is_spent_by_the_click_not_the_page( - 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"); - - let resp = client() - .post(format!("{base}/users/login_links")) - .header("Authorization", "Bearer SECRET_TOKEN") - .json(&json!({"email": "test2@windmill.dev", "confirm": true})) - .send() - .await?; - assert_eq!(resp.status(), 201); - let link = resp.json::().await?; - // The URL handed out is the frontend page, not the API path that signs in on a GET. - let token = link["url"] - .as_str() - .unwrap() - .split_once("/user/login_link?token=") - .expect("confirmation page url") - .1 - .to_string(); - - let confirm = || { - client() - .post(format!("{base}/auth/login_link/{token}")) - .send() - }; - let resp = confirm().await?; - assert_eq!(resp.status(), 200); - assert!(resp - .headers() - .get_all("set-cookie") - .iter() - .any(|c| c.to_str().unwrap().starts_with("token="))); - assert_eq!( - resp.json::().await?["location"], - "/user/workspaces" - ); - - let resp = confirm().await?; - assert_eq!(resp.status(), 200); - assert!(resp.headers().get("set-cookie").is_none()); - assert_eq!( - resp.json::().await?["location"], - "/user/login_link_expired?reason=used" - ); - - Ok(()) -} - #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn login_link_mint_can_require_a_login_type(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 059b3aa2d2..e7f0147323 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -12,7 +12,7 @@ use serde_json::json; use sqlx::{Pool, Postgres}; use axum::http::StatusCode; -use windmill_api_auth::ApiAuthed; +use windmill_api_auth::{ApiAuthed, ScopePathFilter}; use windmill_common::{ error::Error, variables::{build_crypt, encrypt}, @@ -661,6 +661,7 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< None, Some("f/test/after"), Some(false), + ScopePathFilter::AllowAll, ) .await?; assert_eq!( @@ -672,6 +673,55 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< Ok(()) } +// ============================================================================ +// 6. Token scope filtering on list +// ============================================================================ + +/// A `native_triggers:read:f/test/*` token must only list triggers under `f/test/`, on the `/` +/// boundary (`f/testx/` is out of scope). +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_list_native_triggers_scope_filter(db: Pool) -> anyhow::Result<()> { + for (i, path) in ["f/testx/out", "f/test/in"].into_iter().enumerate() { + insert_test_script(&db, path).await?; + store_native_trigger( + &db, + "test-workspace", + ServiceName::Nextcloud, + &format!("ext-{i}"), + &NativeTriggerConfig { + script_path: path.to_string(), + is_flow: false, + webhook_token: format!("abcdefghij123456789{i}"), + }, + json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), + None, + true, + ) + .await?; + } + + let triggers = list_native_triggers( + &db, + "test-workspace", + ServiceName::Nextcloud, + None, + None, + None, + None, + ScopePathFilter::Restricted { exact: vec![], prefix: vec!["f/test".to_string()] }, + ) + .await?; + assert_eq!( + triggers + .iter() + .map(|t| t.script_path.as_str()) + .collect::>(), + vec!["f/test/in"] + ); + + Ok(()) +} + // --- parse_stop_channel_params --- #[test] diff --git a/backend/windmill-api-integration-tests/tests/remote_deploy.rs b/backend/windmill-api-integration-tests/tests/remote_deploy.rs new file mode 100644 index 0000000000..27447c3095 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/remote_deploy.rs @@ -0,0 +1,297 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_common::variables::{build_crypt, encrypt}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +/// The deploy target is this very server, which the proxy has no way to tell from another +/// instance: it only ever knows the configured URL, the caller's stored token, and the path. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_remote_deploy_proxy(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/remote_deploy"); + + // Planted rather than set through the route, which is enterprise-gated. Unlike the route, it + // deletes no token for another target, which is what a connect landing just after the + // route's cleanup leaves behind. + let set_target = |base_url: String, workspace_id: &'static str| { + let db = db.clone(); + async move { + sqlx::query!( + "UPDATE workspace_settings + SET remote_deploy_target = $1, remote_deploy_target_changed_at = now() + WHERE workspace_id = 'test-workspace'", + json!({ "base_url": base_url, "workspace_id": workspace_id }) + ) + .execute(&db) + .await + .unwrap(); + } + }; + set_target(format!("http://localhost:{port}"), "test-workspace").await; + + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let status = resp.json::().await?; + assert_eq!(status["target"]["workspace_id"], "test-workspace"); + assert!(status["connection"].is_null()); + + // Deploying before connecting names the step that is missing, rather than reaching the target + // with the caller's credentials for this instance. + let resp = authed(client().get(format!("{base}/proxy/none/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("Connect to")); + + let target = + json!({ "base_url": format!("http://localhost:{port}"), "workspace_id": "test-workspace" }); + + // A token obtained for another target is refused before it is sent anywhere. + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": { "base_url": format!("http://localhost:{port}"), "workspace_id": "elsewhere" }})) + .send() + .await?; + assert_eq!(resp.status(), 400); + + // A token the target refuses is not stored. + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "not-a-token", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 400); + + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let connection = resp.json::().await?; + assert_eq!(connection["remote_email"], "test@windmill.dev"); + let key = connection["proxy_key"].as_str().unwrap().to_string(); + + let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + // Whatever the remote returns is served from this origin, so it must never render as a page. + let csp = resp.headers()["content-security-policy"] + .to_str()? + .to_string(); + assert!(csp.starts_with("sandbox"), "{csp}"); + assert_eq!( + resp.json::().await?["email"], + "test@windmill.dev" + ); + + // A link from elsewhere rides the session cookie but cannot know the key, so it cannot spend + // the stored token. + // Nor can a credential that may not use the proxy read the key, to build such a link itself. + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only) + VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN', + 'test@windmill.dev', 'read only', false, true)" + ) + .execute(&db) + .await?; + let resp = client() + .get(format!("{base}/target")) + .header("Authorization", "Bearer READ_ONLY_TOKEN") + .send() + .await?; + assert_eq!(resp.status(), 200); + assert!(resp.json::().await?["connection"].is_null()); + + let guessed = "x".repeat(key.len()); + let resp = authed(client().get(format!("{base}/proxy/{guessed}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + + // A connect locks nothing against a key rotation, so its row can land under the old key: that + // is no connection, which the drawer offers to replace, rather than an error on every deploy. + sqlx::query!( + "UPDATE remote_deploy_token SET token = 'not-under-this-key' + WHERE workspace_id = 'test-workspace'" + ) + .execute(&db) + .await?; + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + + // A target that refuses the stored token must not answer 401: the browser reads an + // unhandled 401 as its own session having expired and logs the user out of this instance. + let mc = build_crypt(&db, "test-workspace").await?; + sqlx::query!( + "UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'", + encrypt(&mc, "revoked-token") + ) + .execute(&db) + .await?; + let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 502); + + // Nor does a connect lock anything against a removal from the workspace, so its row can land + // after the removal cleared the table: a row from an earlier membership must not come back + // with a re-add, even one whose `created_at` reads earlier than the connect (it is the start + // of the re-adding transaction). + let as_test2 = + |builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer SECRET_TOKEN_2"); + let resp = as_test2(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN_2", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let key2 = resp.json::().await?["proxy_key"] + .as_str() + .unwrap() + .to_string(); + let resp = as_test2(client().get(format!("{base}/target"))) + .send() + .await?; + assert_eq!( + resp.json::().await?["connection"]["proxy_key"], + key2.as_str() + ); + sqlx::query!( + "UPDATE usr SET created_at = created_at - interval '1 hour' + WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'" + ) + .execute(&db) + .await?; + let resp = as_test2(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + let resp = as_test2(client().get(format!("{base}/proxy/{key2}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("Connect to")); + + // Deleting the account must take its token with it, or the next account created with that + // address would act on the remote as this one. + let resp = authed(client().delete(format!( + "http://localhost:{port}/api/users/delete/test2@windmill.dev" + ))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let left = sqlx::query_scalar!( + "SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'" + ) + .fetch_one(&db) + .await?; + assert_eq!(left, Some(0)); + + // The token was granted for one target and is never sent to another, so re-pointing the + // workspace leaves the caller unconnected instead of handing its token to the new target. + set_target(format!("http://localhost:{port}"), "other-workspace").await; + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + + // The row for the old target outlived the change, as one from a connect in flight across it + // would: pointing the setting back must not revive it, even with a stamp that reads earlier + // than the connect. + set_target(format!("http://localhost:{port}"), "test-workspace").await; + sqlx::query!( + "UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01' + WHERE workspace_id = 'test-workspace'" + ) + .execute(&db) + .await?; + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + + // A connect in flight across a disconnect must not undo it when it lands. The disconnect is + // stamped an hour ahead, as if every connect starting now had started before it. + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let resp = authed(client().post(format!("{base}/disconnect"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + sqlx::query!( + "UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour' + WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'" + ) + .execute(&db) + .await?; + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 400); + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + + // A superadmin outside the workspace has no membership to bind to, so the row is bound to the + // account by the credential making the request: an account deleted during the remote call, + // and its address taken by another, must not inherit it. A deleted credential that auth still + // holds in its cache stands in for one whose account went away mid-call. + sqlx::query!( + "DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'" + ) + .execute(&db) + .await?; + sqlx::query!("DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'") + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN', + 'test@windmill.dev', 'gone', true)" + ) + .execute(&db) + .await?; + let as_gone = + |builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer GONE_TOKEN"); + let resp = as_gone(client().get(format!("{base}/target"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + sqlx::query!("DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'") + .execute(&db) + .await?; + let resp = as_gone(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 400); + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index ff603269e7..29b8a0a611 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -319,6 +319,14 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .unwrap(); assert_eq!(resp.status(), 200); + let resp = authed(client().post(format!("{base}/edit_webhook"))) + .json(&json!({"webhook": "http://127.0.0.1/hook"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("ALLOW_PRIVATE_WEBHOOK_URLS")); + // --- edit_auto_invite (EE-gated) --- let resp = authed(client().post(format!("{base}/edit_auto_invite"))) .json(&json!({"operator": false, "invite_all": false, "auto_add": false})) diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 7184f8ecec..9ad7189434 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -40,8 +40,8 @@ use windmill_common::{ FlowVersionInfo, DB, }; use windmill_queue::{ - cancel_job, get_result_and_success_by_id_from_flow, parse_result_object, push, PushArgs, - PushArgsOwned, PushIsolationLevel, + cancel_job, check_tag_available_for_push, get_result_and_success_by_id_from_flow, + parse_result_object, push, tag_reads_args, PushArgs, PushArgsOwned, PushIsolationLevel, }; use crate::types::RunJobQuery; @@ -50,7 +50,27 @@ use crate::types::RunJobQuery; // Tag / license checks // --------------------------------------------------------------------------- +/// `args` must be the ones the job is pushed with, see [`windmill_queue::check_tag_available_for_push`]. pub async fn check_tag_available_for_workspace( + db: &DB, + w_id: &str, + tag: &Option, + args: &PushArgs<'_>, + authed: &ApiAuthed, +) -> error::Result<()> { + if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { + let tags = get_scope_tags(authed); + // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. + let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; + check_tag_available_for_push(db, w_id, tag, args, is_super_admin, tags).await + } else { + Ok(()) + } +} + +/// [`check_tag_available_for_workspace`] for a tag filled in from values nothing can check yet, +/// such as a flow's preprocessor output: only an entry spelled exactly like it admits it. +pub async fn check_tag_as_written_available_for_workspace( db: &DB, w_id: &str, tag: &Option, @@ -60,7 +80,16 @@ pub async fn check_tag_available_for_workspace( let tags = get_scope_tags(authed); // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; - check_tag_available_for_workspace_internal(db, w_id, tag, is_super_admin, tags).await + check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + is_super_admin, + tags, + ) + .await } else { Ok(()) } @@ -760,9 +789,25 @@ pub async fn run_flow<'c>( .. } = flow_version_info; - let tag = run_query.tag.clone().or(tag); + let flow_tag = tag; + let tag = run_query.tag.clone().or(flow_tag.clone()); + let push_args = PushArgs { args: &args.args, extra: args.extra }; + let apply_preprocessor = + !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + if !apply_preprocessor || run_query.tag.is_some() { + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + } + // `push` drops the tag of a flow whose preprocessor runs, and the worker then puts the flow on + // its own tag filled in from the preprocessor's output, after this caller's identity is gone. + // So that tag is the one to check, and an `$args[...]` in it is judged as written. + if apply_preprocessor { + if flow_tag.as_deref().is_some_and(tag_reads_args) { + check_tag_as_written_available_for_workspace(&db, &w_id, &flow_tag, &authed).await?; + } else { + check_tag_available_for_workspace(&db, &w_id, &flow_tag, &push_args, &authed).await?; + } + } let scheduled_for = run_query.get_scheduled_for(&db).await?; let return_tx = tx_o.is_some(); @@ -798,11 +843,10 @@ pub async fn run_flow<'c>( path: flow_path.to_string(), dedicated_worker, version, - apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) - && has_preprocessor.unwrap_or(false), + apply_preprocessor, labels, }, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -988,7 +1032,8 @@ pub async fn push_script_job_by_path_into_queue<'c>( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let return_tx = tx_o.is_some(); @@ -1020,7 +1065,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index dfd4a6e3ba..78cd6eb378 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -619,6 +619,7 @@ async fn edit_schedule( dynamic_skip = $23, email = $24, edited_by = $25, + edited_at = now(), permissioned_as = $26, labels = COALESCE($27, labels) WHERE path = $19 AND workspace_id = $20 diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index 8b34512872..72353c6f1b 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -30,7 +30,11 @@ windmill-parser-ts-asset.workspace = true windmill-parser-sql-asset.workspace = true windmill-parser-sql.workspace = true windmill-parser-yaml.workspace = true +windmill-parser-bash.workspace = true +windmill-parser-go.workspace = true +windmill-parser-graphql.workspace = true +anyhow.workspace = true axum.workspace = true futures.workspace = true http.workspace = true diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 51f2619805..631e8606e6 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1132,6 +1132,96 @@ async fn validate_dbt_relation( }) } +/// The schema the editor would have derived from `content`, for a deploy that sends +/// none (MCP, a bare API call). `None` for a language whose parser this build lacks, +/// or code that does not parse. +fn infer_main_schema( + language: &ScriptLang, + kind: Option<&ScriptKind>, + content: &str, + previous: Option<&serde_json::Value>, +) -> Option { + let entrypoint = + matches!(kind, Some(ScriptKind::Preprocessor)).then(|| "preprocessor".to_string()); + // The database a SQL script runs against is an argument unless the code names + // it (`-- database f/...`), as in the editor. + let with_db = |sig: anyhow::Result, resource: &str| { + sig.map(|mut sig| { + if windmill_parser_sql::parse_db_resource(content).is_none() { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "database".to_string(), + typ: windmill_parser::Typ::Resource(resource.to_string()), + ..Default::default() + }, + ); + } + sig + }) + }; + let sig = match language { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + windmill_parser_ts::parse_deno_signature(content, false, false, entrypoint) + } + #[cfg(feature = "python")] + ScriptLang::Python3 => { + windmill_parser_py::parse_python_signature(content, entrypoint, false) + } + ScriptLang::Go => windmill_parser_go::parse_go_sig(content), + ScriptLang::Bash => windmill_parser_bash::parse_bash_sig(content), + ScriptLang::Powershell => windmill_parser_bash::parse_powershell_sig(content), + ScriptLang::Postgresql => { + with_db(windmill_parser_sql::parse_pgsql_sig(content), "postgresql") + } + ScriptLang::Mysql => with_db(windmill_parser_sql::parse_mysql_sig(content), "mysql"), + ScriptLang::Bigquery => { + with_db(windmill_parser_sql::parse_bigquery_sig(content), "bigquery") + } + ScriptLang::Snowflake => with_db( + windmill_parser_sql::parse_snowflake_sig(content), + "snowflake", + ), + ScriptLang::Mssql => with_db( + windmill_parser_sql::parse_mssql_sig(content), + "ms_sql_server", + ), + ScriptLang::OracleDB => { + with_db(windmill_parser_sql::parse_oracledb_sig(content), "oracledb") + } + ScriptLang::DuckDb => windmill_parser_sql::parse_duckdb_sig(content), + ScriptLang::Graphql => { + windmill_parser_graphql::parse_graphql_sig(content).map(|mut sig| { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "api".to_string(), + typ: windmill_parser::Typ::Resource("graphql".to_string()), + ..Default::default() + }, + ); + sig + }) + } + ScriptLang::Ansible => windmill_parser_yaml::parse_ansible_sig(content), + _ => return None, + }; + match sig { + Ok(sig) => serde_json::value::to_raw_value(&windmill_parser::json_schema::main_arg_schema( + &sig, previous, + )) + .ok() + .map(|v| Schema(sqlx::types::Json(v))), + Err(e) => { + tracing::warn!( + "could not infer the schema of a {} script: {e:#}", + language.as_str() + ); + None + } + } +} + async fn create_script_internal<'c>( mut ns: NewScript, w_id: String, @@ -1359,6 +1449,40 @@ async fn create_script_internal<'c>( parent_adopted_from_retired_path = ns.parent_hash.is_some(); } + // Before hashing, so the hash and the no-op check see the schema that gets stored. + // `{}` counts as absent: an agent filling every tool argument sends it for "none". + let schema_absent = ns.schema.as_ref().is_none_or(|s| { + serde_json::from_str::>(s.0.get()) + .is_ok_and(|m| m.is_empty()) + }); + if schema_absent && !matches!(ns.language, ScriptLang::Dbt) { + let previous = match &ns.parent_hash { + Some(p_hash) => sqlx::query_scalar::<_, Option>>( + "SELECT schema FROM script WHERE hash = $1 AND workspace_id = $2", + ) + .bind(p_hash.0) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await? + .flatten() + .map(|s| s.0), + None => None, + }; + // Code the server cannot parse keeps the previous version's schema, as it + // does in the editor, rather than losing it for good. + ns.schema = infer_main_schema( + &ns.language, + ns.kind.as_ref(), + &ns.content, + previous.as_ref(), + ) + .or_else(|| { + previous + .and_then(|p| serde_json::value::to_raw_value(&p).ok()) + .map(|v| Schema(sqlx::types::Json(v))) + }); + } + // Must stay below the parent resolution above: an auto_parent deploy hashed before // it carries a first deploy's lineage, so redeploying content the path has held // before collides with that archived version instead of superseding it. The diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e8d8a7124c..bc52d85d54 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -58,9 +58,9 @@ use windmill_common::{ error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ - 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, + 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, 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, @@ -115,6 +115,17 @@ async fn get_ruff_config_unauthed(Extension(db): Extension) -> error::Result .unwrap()) } +/// The announcement banner and accent color, which every signed-in session reads on each +/// full page load. +async fn get_instance_ui( + Extension(db): Extension, + _authed: ApiAuthed, +) -> JsonResult { + Ok(Json( + windmill_common::global_settings::get_instance_ui(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -126,6 +137,7 @@ pub fn global_service() -> Router { "/global/{key}", post(set_global_setting).get(get_global_setting), ) + .route("/instance_ui", get(get_instance_ui)) .route("/list_global", get(list_global_settings)) .route("/github_app_stale_webhooks", get(github_app_stale_webhooks)) .route( @@ -1216,6 +1228,12 @@ async fn run_setting_pre_write_hook( } } } + ACCENT_COLOR_SETTING => match value { + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.trim().is_empty() => {} + v => windmill_common::global_settings::validate_accent_color(v) + .map_err(|e| error::Error::BadRequest(format!("{ACCENT_COLOR_SETTING}: {e}")))?, + }, _ => {} } Ok(()) diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index e46afede8b..a1b1054a25 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -171,10 +171,7 @@ pub fn make_unauthed_service() -> Router { .route("/logout", post(logout).get(logout)) .route("/is_first_time_setup", get(is_first_time_setup)) .route("/request_password_reset", post(request_password_reset)) - .route( - "/login_link/{token}", - get(consume_login_link).post(confirm_login_link), - ) + .route("/login_link/{token}", get(consume_login_link)) .route("/is_smtp_configured", get(is_smtp_configured)) .route( "/is_password_login_disabled", @@ -2566,6 +2563,14 @@ pub async fn delete_workspace_user_internal( .execute(&mut **tx) .await?; + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", + email_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + sqlx::query!( "DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2", username_to_delete, @@ -3287,12 +3292,9 @@ async fn impersonate( } const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600; -// Long enough for a link sent by email to still work when it is read. `require_login_type` is -// only checked at mint, so a much longer cap would need re-checking it when the link is opened. -const LOGIN_LINK_MAX_TTL_S: u32 = 7200; +const LOGIN_LINK_MAX_TTL_S: u32 = 900; const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces"; const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired"; -const LOGIN_LINK_CONFIRM_PAGE: &str = "/user/login_link"; #[derive(Deserialize)] pub struct NewLoginLink { @@ -3303,9 +3305,6 @@ pub struct NewLoginLink { /// account it created can require `pending_oauth`, so the link stops working once the /// owner has set a password or signed in with a provider. pub require_login_type: Option, - /// Hand out a page that signs in only when its button is clicked. Mail scanners open links - /// on delivery, and opening the plain link spends it, so a link sent by email sets this. - pub confirm: Option, } #[derive(Serialize)] @@ -3456,12 +3455,11 @@ async fn create_login_link( .await?; tx.commit().await?; - let base_url = (**BASE_URL.load()).clone(); - let url = if nl.confirm.unwrap_or(false) { - format!("{base_url}{LOGIN_LINK_CONFIRM_PAGE}?token={token}") - } else { - format!("{base_url}/api/auth/login_link/{token}") - }; + let url = format!( + "{}/api/auth/login_link/{}", + (**BASE_URL.load()).clone(), + token + ); Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at }))) } @@ -3707,45 +3705,19 @@ async fn consume_login_link( Path(token): Path, Query(query): Query, ) -> Result { - let location = redeem_login_link(&headers, cookies, &db, &token, query.rd).await?; - Ok(login_link_redirect(location)) -} - -#[derive(Serialize)] -struct LoginLinkLocation { - location: String, -} - -/// The confirmation page's click. It answers with where to go rather than redirecting, and the -/// page navigates there itself. -async fn confirm_login_link( - headers: axum::http::HeaderMap, - cookies: Cookies, - Extension(db): Extension, - Path(token): Path, -) -> JsonResult { - let location = redeem_login_link(&headers, cookies, &db, &token, None).await?; - Ok(Json(LoginLinkLocation { location })) -} - -/// Spends the link and sets the session cookie, returning the post-login destination; or -/// returns the explanation page, with no session, when the link cannot be used. -async fn redeem_login_link( - headers: &axum::http::HeaderMap, - cookies: Cookies, - db: &DB, - token: &str, - requested_rd: Option, -) -> Result { - let bounce = |reason: &str| Ok(format!("{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}")); + let bounce = |reason: &str| { + Ok(login_link_redirect(format!( + "{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}" + ))) + }; if token.len() != 32 { return bounce("invalid"); } - let t_hash = hash_token(token); + let t_hash = hash_token(&token); // The account is unknown until the row is read, so only the global and per-IP tiers // apply here; a 32-char random token leaves nothing for the per-account tier to guard. windmill_common::login_rate_limit::check_and_increment_login_attempt( - headers, + &headers, &t_hash[..TOKEN_PREFIX_LEN], )?; @@ -3812,10 +3784,11 @@ async fn redeem_login_link( .await?; tx.commit().await?; - Ok(link + let rd = link .rd - .or_else(|| same_origin_rd(requested_rd)) - .unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string())) + .or_else(|| same_origin_rd(query.rd)) + .unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string()); + Ok(login_link_redirect(rd)) } #[derive(Deserialize)] @@ -4050,6 +4023,7 @@ async fn update_token_label( AND NOT starts_with(label, 'sdk_app:') AND NOT starts_with(label, 'impersonation:') AND NOT starts_with(label, 'cli-login:') + AND NOT starts_with(label, 'remote-deploy:') )) RETURNING token_prefix", req.label.as_deref(), @@ -4096,13 +4070,25 @@ async fn leave_workspace( &format!("u/{}", authed.username), ) .await?; - sqlx::query!( + let left = sqlx::query!( "DELETE FROM usr WHERE workspace_id = $1 AND username = $2", &w_id, authed.username ) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); + // A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop + // its connection. + if left > 0 { + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", + &authed.email, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index 52a58aa1a3..7ca1f692ad 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -35,6 +35,7 @@ windmill-store.workspace = true axum.workspace = true chrono.workspace = true +constant_time_eq.workspace = true futures = { workspace = true, optional = true } hex.workspace = true magic-crypt.workspace = true @@ -42,6 +43,7 @@ http.workspace = true hyper.workspace = true lazy_static.workspace = true regex.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true @@ -49,5 +51,6 @@ sqlx.workspace = true tokio.workspace = true tokio-postgres.workspace = true tracing.workspace = true +url.workspace = true uuid.workspace = true strum.workspace = true diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index e7da2b8496..c437d7e2b4 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -9,6 +9,7 @@ pub mod datatable_permissions; pub mod datatable_permissions_oss; pub mod datatable_replay_oss; pub mod deployment_requests; +pub mod remote_deploy; pub mod workspaces; pub mod workspaces_extra; pub mod workspaces_oss; diff --git a/backend/windmill-api-workspaces/src/remote_deploy.rs b/backend/windmill-api-workspaces/src/remote_deploy.rs new file mode 100644 index 0000000000..3e701cbf14 --- /dev/null +++ b/backend/windmill-api-workspaces/src/remote_deploy.rs @@ -0,0 +1,794 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Deploying from the UI into a workspace of another Windmill instance. +//! +//! The browser runs the same deploy it runs between two workspaces of this instance, and sends +//! every call aimed at the target through [`proxy`], which forwards it to the remote workspace +//! with the caller's own token for that instance. The remote authorizes and audits the deploy as +//! that person, so nothing here needs to know what a deploy is made of. + +use std::time::Duration; + +use axum::{ + body::{Body, Bytes}, + extract::{DefaultBodyLimit, Extension, OriginalUri, Path}, + http::{header, HeaderMap, Method, StatusCode}, + response::Response, + routing::{any, get, post}, + Json, Router, +}; +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::{ + auth::hash_token, + error::{error_source_chain, Error, Result}, + ssrf::validate_url_for_ssrf, + utils::{configure_client, rd_string, require_admin}, + variables::{build_crypt, decrypt, encrypt}, + worker::CLOUD_HOSTED, + DB, +}; + +lazy_static! { + static ref REMOTE_WORKSPACE_ID: Regex = Regex::new("^[a-zA-Z0-9_-]{1,50}$").unwrap(); + /// Shared so that a deploy's calls reuse connections. A cloud instance instead builds a client + /// per request, pinned to the addresses it just validated for that target. + static ref REMOTE_CLIENT: reqwest::Client = remote_client_builder().build().unwrap(); +} + +const PROXY_PREFIX: &str = "/remote_deploy/proxy/"; + +pub fn workspaced_service(proxy_body_limit: usize) -> Router { + Router::new() + .route("/target", get(get_target).post(set_target)) + .route("/connect", post(connect)) + .route("/disconnect", post(disconnect)) + // `{key}` is the caller's `proxy_key`. The session cookie is `SameSite=Lax`, so it rides a + // top-level navigation from any site: without a value such a link cannot know, it could + // spend the stored token, to run something on the remote or to render its content on this + // origin. `Sec-Fetch-Site` is no substitute, since plain-http instances never receive it. + .route( + "/proxy/{key}/{*rest}", + any(proxy).layer(DefaultBodyLimit::max(proxy_body_limit)), + ) +} + +/// A request URI as logs should record it: with the proxy key masked, since that key is what keeps +/// a link from spending a stored remote token. +pub struct RedactedUri<'a>(pub &'a axum::http::Uri); + +impl std::fmt::Display for RedactedUri<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Some((head, keyed)) = self.0.path().split_once(PROXY_PREFIX) else { + return self.0.fmt(f); + }; + let rest = keyed.find('/').map_or("", |i| &keyed[i..]); + write!(f, "{head}{PROXY_PREFIX}***{rest}")?; + match self.0.query() { + Some(query) => write!(f, "?{query}"), + None => Ok(()), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct RemoteDeployTarget { + /// Root URL of the remote instance, without `/api`. + pub base_url: String, + pub workspace_id: String, +} + +impl RemoteDeployTarget { + fn normalized(self) -> Result { + let base_url = self.base_url.trim().trim_end_matches('/').to_string(); + let parsed = url::Url::parse(&base_url) + .map_err(|e| Error::BadRequest(format!("Invalid remote instance URL: {e}")))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(Error::BadRequest( + "The remote instance URL must be an http(s) URL with a host".to_string(), + )); + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(Error::BadRequest( + "The remote instance URL must not carry credentials, a query or a fragment" + .to_string(), + )); + } + let workspace_id = self.workspace_id.trim().to_string(); + if !REMOTE_WORKSPACE_ID.is_match(&workspace_id) { + return Err(Error::BadRequest(format!( + "Invalid remote workspace id: {workspace_id}" + ))); + } + Ok(Self { base_url, workspace_id }) + } + + fn api_url(&self, suffix: &str) -> String { + format!("{}/api/w/{}/{suffix}", self.base_url, self.workspace_id) + } +} + +#[derive(Serialize)] +struct RemoteDeployConnection { + remote_email: String, + /// Goes in every proxy URL, see [`workspaced_service`]. + proxy_key: String, + connected_at: DateTime, +} + +#[derive(Serialize)] +struct RemoteDeployStatus { + target: Option, + /// The caller's own connection to `target`. + connection: Option, +} + +/// A stored token acts on another instance as its owner, with none of the restrictions of the +/// credential that reaches it. Only the owner acting directly with full rights may use or replace +/// it: a job token runs as whoever a `wm_deployers` member pointed `on_behalf_of` at, and a +/// scoped or read-only token was never granted this. +fn require_own_credentials(authed: &ApiAuthed) -> Result<()> { + if authed.job_id.is_some() + || authed.read_only + || !is_effectively_unscoped(authed.scopes.as_deref()) + { + return Err(Error::PermissionDenied( + "Deploying to a remote instance requires your own session or an unscoped token, \ + not a job, scoped or read-only token" + .to_string(), + )); + } + Ok(()) +} + +fn parse_target(target: Option) -> Result> { + target + .map(serde_json::from_value) + .transpose() + .map_err(|e| Error::internal_err(format!("reading the remote deploy target: {e}"))) +} + +fn no_target(w_id: &str) -> Error { + Error::BadRequest(format!( + "Workspace {w_id} has no remote deploy target. An admin sets it in the workspace \ + settings, under Dev workspace" + )) +} + +async fn load_target(db: &DB, w_id: &str) -> Result> { + let target = sqlx::query_scalar!( + "SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await? + .flatten(); + parse_target(target) +} + +async fn require_target(db: &DB, w_id: &str) -> Result { + load_target(db, w_id).await?.ok_or_else(|| no_target(w_id)) +} + +fn remote_client_builder() -> reqwest::ClientBuilder { + configure_client(reqwest::ClientBuilder::new()) + .user_agent("windmill/remote-deploy") + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) +} + +async fn remote_client(target: &RemoteDeployTarget) -> Result { + // A workspace admin chose the URL. A self-hosted instance may deploy into its own private + // network, as it may reach a private git remote; a cloud workspace must not. + if !*CLOUD_HOSTED { + return Ok(REMOTE_CLIENT.clone()); + } + validate_url_for_ssrf(&target.base_url) + .await? + .apply_dns_pinning(remote_client_builder()) + .build() + .map_err(|e| Error::internal_err(format!("building the remote deploy client: {e}"))) +} + +fn unreachable(target: &RemoteDeployTarget, e: reqwest::Error) -> Error { + Error::BadGateway(format!( + "Could not reach the remote instance {}: {}", + target.base_url, + error_source_chain(&e) + )) +} + +/// Responses carrying a `proxy_key` must not be kept by any cache between here and the browser. +const NO_STORE: [(header::HeaderName, &str); 1] = [(header::CACHE_CONTROL, "no-store")]; + +struct StoredConnection { + token: String, + remote_email: String, + proxy_key: String, + connected_at: DateTime, +} + +/// The caller's connection to `target`, if they may still use it. +/// +/// `connect` takes no lock against what clears these rows (a removal from the workspace, a target +/// change, a key rotation): writers take those rows in every order, so any lock it held could close +/// a deadlock. A row can therefore land after one of them ran, however long its remote call took, +/// and is voided here instead, by identity rather than by comparing times: it counts only while +/// the target setting and the owner's membership are the very ones it was connected under (see +/// `member_since`, `target_changed_at`) or the owner is a superadmin, and while it decrypts. An +/// emptied row is a disconnect's. +async fn load_connection( + db: &DB, + w_id: &str, + email: &str, + target: &RemoteDeployTarget, +) -> Result> { + let Some(row) = sqlx::query_as!( + StoredConnection, + "SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t + JOIN workspace_settings s ON s.workspace_id = t.workspace_id + WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3 + AND t.remote_workspace_id = $4 AND t.token <> '' + AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at + AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id + AND u.email = t.email AND u.created_at = t.member_since) + OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))", + w_id, + email, + &target.base_url, + &target.workspace_id + ) + .fetch_optional(db) + .await? + else { + return Ok(None); + }; + match decrypt(&build_crypt(db, w_id).await?, row.token) { + Ok(token) => Ok(Some(StoredConnection { token, ..row })), + Err(e) => { + tracing::warn!("remote deploy token of {email} in {w_id} does not decrypt: {e}"); + Ok(None) + } + } +} + +async fn get_target( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result<( + [(header::HeaderName, &'static str); 1], + Json, +)> { + let target = load_target(&db, &w_id).await?; + // The connection carries the proxy key. A credential that may not use the proxy (see + // `require_own_credentials`) must not read it either: it could hand its owner a working link. + let connection = match &target { + Some(target) if require_own_credentials(&authed).is_ok() => { + load_connection(&db, &w_id, &authed.email, target) + .await? + .map(|c| RemoteDeployConnection { + remote_email: c.remote_email, + proxy_key: c.proxy_key, + connected_at: c.connected_at, + }) + } + _ => None, + }; + Ok((NO_STORE, Json(RemoteDeployStatus { target, connection }))) +} + +#[derive(Deserialize)] +struct SetRemoteDeployTarget { + target: Option, +} + +async fn set_target( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> Result { + if !cfg!(feature = "enterprise") { + return Err(Error::BadRequest( + "Deploying to another instance is only available on Windmill Enterprise Edition" + .to_string(), + )); + } + // Everyone who connects afterwards hands their token to this URL. + require_own_credentials(&authed)?; + require_admin(authed.is_admin, &authed.username)?; + let target = request + .target + .map(RemoteDeployTarget::normalized) + .transpose()?; + + let mut tx = db.begin().await?; + let (base_url, remote_workspace_id) = match &target { + Some(t) => (Some(t.base_url.as_str()), Some(t.workspace_id.as_str())), + None => (None, None), + }; + audit_log( + &mut *tx, + &authed, + "workspaces.edit_remote_deploy_target", + ActionKind::Update, + &w_id, + None, + Some( + [ + ("base_url", base_url.unwrap_or("")), + ("remote_workspace_id", remote_workspace_id.unwrap_or("")), + ] + .into(), + ), + ) + .await?; + let target_json = target + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| Error::internal_err(e.to_string()))?; + sqlx::query!( + "UPDATE workspace_settings SET remote_deploy_target = $1::jsonb, + remote_deploy_target_changed_at = CASE + WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp() + ELSE remote_deploy_target_changed_at END + WHERE workspace_id = $2", + target_json, + &w_id + ) + .execute(&mut *tx) + .await?; + // A token is only ever sent to the target it was granted for, so the ones for any other + // target are dead credentials. + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE workspace_id = $1 + AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)", + &w_id, + base_url, + remote_workspace_id + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + Ok(match target { + Some(t) => format!( + "Workspace {w_id} deploys to {} on {}", + t.workspace_id, t.base_url + ), + None => format!("Removed the remote deploy target of workspace {w_id}"), + }) +} + +#[derive(Deserialize)] +struct ConnectRequest { + token: String, + /// The target the caller got the token for, as the drawer showed it. + target: RemoteDeployTarget, +} + +#[derive(Deserialize)] +struct RemoteWhoami { + email: String, +} + +async fn connect( + authed: ApiAuthed, + Tokened { token: credential }: Tokened, + Extension(db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> Result<( + [(header::HeaderName, &'static str); 1], + Json, +)> { + require_own_credentials(&authed)?; + // Read before the remote call, which can take long enough for any of it to change: the row is + // bound to the target version and the membership seen here (see `load_connection`), and + // stamped with when this connect started, so that a disconnect or a newer connect made while + // it runs keeps it from landing. + let start = sqlx::query!( + r#"SELECT clock_timestamp() AS "started_at!", s.remote_deploy_target, + s.remote_deploy_target_changed_at, + (SELECT u.created_at FROM usr u + WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since + FROM workspace_settings s WHERE s.workspace_id = $1"#, + &w_id, + &authed.email + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| no_target(&w_id))?; + let target = parse_target(start.remote_deploy_target)?.ok_or_else(|| no_target(&w_id))?; + // A token is only ever sent to the instance it was meant for. Without this, re-pointing the + // target between the user getting a token and posting it here would hand it to the new one; + // after this check the token still goes to `target` only, never to what the setting became. + let requested = request.target.normalized()?; + if requested.base_url != target.base_url || requested.workspace_id != target.workspace_id { + return Err(Error::BadRequest( + "The remote deploy target changed since you started connecting; reload and connect \ + again" + .to_string(), + )); + } + let token = request.token.trim(); + if token.is_empty() { + return Err(Error::BadRequest("The token is empty".to_string())); + } + + let response = remote_client(&target) + .await? + .get(target.api_url("users/whoami")) + .bearer_auth(token) + .send() + .await + .map_err(|e| unreachable(&target, e))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(Error::BadRequest(format!( + "{} did not accept this token for workspace {} ({status}): {body}", + target.base_url, target.workspace_id + ))); + } + let whoami: RemoteWhoami = response.json().await.map_err(|e| { + Error::BadGateway(format!( + "{} did not answer like a Windmill instance: {}", + target.base_url, + error_source_chain(&e) + )) + })?; + + let proxy_key = rd_string(32); + let mc = build_crypt(&db, &w_id).await?; + // No lock against removals, target changes or key rotations: `load_connection` voids a row + // that lands after one of them. A disconnect or a newer connect stamps the row itself, which + // this one then leaves alone. Without a membership to bind to, the row is bound to the account + // through the credential making this request, which deleting the account removes (except + // through `leave_instance`, which leaves its tokens): the address could otherwise be deleted + // and taken by a new account while the remote call runs, and the foreign key would accept it. + let mut tx = db.begin().await?; + let connected_at = sqlx::query_scalar!( + "INSERT INTO remote_deploy_token + (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key, + connected_at, member_since, target_changed_at) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + WHERE $9::timestamptz IS NOT NULL + OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11) + ON CONFLICT (workspace_id, email) DO UPDATE SET + base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id, + token = EXCLUDED.token, remote_email = EXCLUDED.remote_email, + proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at, + member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at + WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at + RETURNING connected_at", + &w_id, + &authed.email, + &target.base_url, + &target.workspace_id, + encrypt(&mc, token), + &whoami.email, + &proxy_key, + start.started_at, + start.member_since, + start.remote_deploy_target_changed_at, + hash_token(&credential) + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + Error::BadRequest(match start.member_since { + Some(_) => "This connect was not saved: you disconnected or connected again while it \ + was running" + .to_string(), + None => format!( + "This connect was not saved: you disconnected, connected again or signed out \ + while it was running. Connecting workspace {w_id} without being one of its \ + members needs a session or an API token of this instance" + ), + }) + })?; + audit_log( + &mut *tx, + &authed, + "workspaces.remote_deploy_connect", + ActionKind::Create, + &w_id, + Some(&whoami.email), + Some([("base_url", target.base_url.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(( + NO_STORE, + Json(RemoteDeployConnection { remote_email: whoami.email, proxy_key, connected_at }), + )) +} + +async fn disconnect( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result { + require_own_credentials(&authed)?; + let mut tx = db.begin().await?; + // Emptied rather than deleted, and stamped, even with no connection yet: a connect that started + // before this must not bring one back when it lands (see `connect`). + sqlx::query!( + "INSERT INTO remote_deploy_token + (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key, + connected_at) + VALUES ($1, $2, '', '', '', '', '', clock_timestamp()) + ON CONFLICT (workspace_id, email) DO UPDATE SET + token = '', proxy_key = '', connected_at = clock_timestamp()", + &w_id, + &authed.email + ) + .execute(&mut *tx) + .await?; + audit_log( + &mut *tx, + &authed, + "workspaces.remote_deploy_disconnect", + ActionKind::Delete, + &w_id, + None, + None, + ) + .await?; + tx.commit().await?; + Ok(format!( + "Disconnected from the remote deploy target of {w_id}" + )) +} + +/// The remote URL for the request path after the proxy prefix and key, as the client sent it. +/// +/// `url` rewrites a path while parsing it: it resolves dot segments (`%2e` spellings included) +/// and turns backslashes into slashes. Either would reach a route other than the one this +/// instance authorized — outside the remote workspace, or a route its read-only check does not +/// recognize — so a path the parser would change is refused rather than forwarded. +fn forwarded_url( + target: &RemoteDeployTarget, + original_path: &str, + query: Option<&str>, +) -> Result { + let suffix = original_path + .split_once(PROXY_PREFIX) + .and_then(|(_, keyed)| keyed.split_once('/')) + .map(|(_, suffix)| suffix) + .unwrap_or_default(); + let invalid = || Error::BadRequest(format!("Invalid remote deploy path: {suffix}")); + let base_path = url::Url::parse(&target.base_url) + .map_err(|_| invalid())? + .path() + .trim_end_matches('/') + .to_string(); + let mut url = url::Url::parse(&target.api_url(suffix)).map_err(|_| invalid())?; + let expected_path = format!("{base_path}/api/w/{}/{suffix}", target.workspace_id); + if suffix.is_empty() || url.path() != expected_path { + return Err(invalid()); + } + url.set_query(query); + Ok(url) +} + +async fn proxy( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, key, _rest)): Path<(String, String, String)>, + OriginalUri(uri): OriginalUri, + method: Method, + headers: HeaderMap, + body: Bytes, +) -> Result { + require_own_credentials(&authed)?; + let target = require_target(&db, &w_id).await?; + let url = forwarded_url(&target, uri.path(), uri.query())?; + let stored = load_connection(&db, &w_id, &authed.email, &target) + .await? + .ok_or_else(|| { + Error::BadRequest(format!( + "Connect to {} with your own token before deploying there", + target.base_url + )) + })?; + if !constant_time_eq::constant_time_eq(key.as_bytes(), stored.proxy_key.as_bytes()) { + return Err(Error::BadRequest( + "This remote deploy link is not yours or is out of date; reload the page".to_string(), + )); + } + + // Only what describes the payload: the caller's cookie and token belong to this instance. + let mut request = remote_client(&target) + .await? + .request(method, url) + .bearer_auth(stored.token) + .body(body); + for name in [header::CONTENT_TYPE, header::ACCEPT] { + if let Some(value) = headers.get(&name) { + request = request.header(name, value.clone()); + } + } + let response = request.send().await.map_err(|e| unreachable(&target, e))?; + + let status = response.status(); + // Passed through, a 401 would read as this instance's session having expired and log the + // user out of it. + if status == StatusCode::UNAUTHORIZED { + let body = response.text().await.unwrap_or_default(); + return Err(Error::BadGateway(format!( + "{} rejected your stored token: {body}", + target.base_url + ))); + } + if status.is_redirection() { + let location = response + .headers() + .get(header::LOCATION) + .and_then(|l| l.to_str().ok()) + .unwrap_or_default() + .to_string(); + return Err(Error::BadGateway(format!( + "{} redirected to {location}. Set the remote deploy target to the URL it redirects to", + target.base_url + ))); + } + // The body is the remote's and may be anything its users stored (an uploaded file, a job + // result typed `text/html`); served from this origin it must never render as a page here. + let mut builder = Response::builder() + .status(status) + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header( + header::CONTENT_SECURITY_POLICY, + "sandbox; default-src 'none'", + ); + if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) { + builder = builder.header(header::CONTENT_TYPE, content_type); + } + builder + .body(Body::from_stream(response.bytes_stream())) + .map_err(|e| Error::internal_err(format!("building the proxied response: {e}"))) +} + +/// Move the stored remote tokens to a new workspace key. A token that no longer decrypts is +/// dropped: its owner connects again. A disconnect's emptied row is kept, as the stamp that keeps +/// an older connect from landing. +pub(crate) async fn reencrypt_tokens( + conn: &mut sqlx::PgConnection, + w_id: &str, + old: &MagicCrypt256, + new: &MagicCrypt256, +) -> Result<()> { + let rows = sqlx::query!( + "SELECT email, token FROM remote_deploy_token + WHERE workspace_id = $1 AND token <> '' FOR UPDATE", + w_id + ) + .fetch_all(&mut *conn) + .await?; + for row in rows { + match decrypt(old, row.token) { + Ok(plain) => { + sqlx::query!( + "UPDATE remote_deploy_token SET token = $1 + WHERE workspace_id = $2 AND email = $3", + encrypt(new, &plain), + w_id, + row.email + ) + .execute(&mut *conn) + .await?; + } + Err(_) => { + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2", + w_id, + row.email + ) + .execute(&mut *conn) + .await?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwarded_url_refuses_paths_the_parser_rewrites() { + let target = RemoteDeployTarget { + base_url: "https://prod.example.com/windmill".to_string(), + workspace_id: "prod".to_string(), + }; + let prefix = "/api/w/dev/remote_deploy/proxy/someKey"; + let url = forwarded_url( + &target, + &format!("{prefix}/scripts/get/p/f/team/my%20script"), + Some("with_starred_info=true"), + ) + .unwrap(); + assert_eq!( + url.as_str(), + "https://prod.example.com/windmill/api/w/prod/scripts/get/p/f/team/my%20script?with_starred_info=true" + ); + for path in [ + format!("{prefix}/../../users/list"), + format!("{prefix}/%2e%2e/%2e%2e/users/list"), + format!("{prefix}/jobs\\run_wait_result\\p/f/team/action"), + prefix.to_string(), + ] { + assert!( + forwarded_url(&target, &path, None).is_err(), + "{path} should be refused" + ); + } + } + + #[test] + fn redacted_uri_masks_the_proxy_key() { + let proxied: axum::http::Uri = "/api/w/dev/remote_deploy/proxy/secretKey/flows/get/f/a?x=1" + .parse() + .unwrap(); + assert_eq!( + RedactedUri(&proxied).to_string(), + "/api/w/dev/remote_deploy/proxy/***/flows/get/f/a?x=1" + ); + let other: axum::http::Uri = "/api/w/dev/flows/list?x=1".parse().unwrap(); + assert_eq!(RedactedUri(&other).to_string(), "/api/w/dev/flows/list?x=1"); + } + + #[test] + fn target_normalization() { + let target = RemoteDeployTarget { + base_url: " https://prod.example.com/ ".to_string(), + workspace_id: " prod ".to_string(), + } + .normalized() + .unwrap(); + assert_eq!(target.base_url, "https://prod.example.com"); + assert_eq!( + target.api_url("flows/create"), + "https://prod.example.com/api/w/prod/flows/create" + ); + + for (base_url, workspace_id) in [ + ("ftp://prod.example.com", "prod"), + ("https://user:pass@prod.example.com", "prod"), + ("https://prod.example.com?token=x", "prod"), + ("https://prod.example.com", "prod/../admins"), + ] { + assert!( + RemoteDeployTarget { + base_url: base_url.to_string(), + workspace_id: workspace_id.to_string(), + } + .normalized() + .is_err(), + "{base_url} {workspace_id} should be refused" + ); + } + } +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 4822c2f3b9..8ebd4eeb26 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -12,6 +12,7 @@ use windmill_api_auth::{ }; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; +use windmill_common::ssrf::{validate_webhook_url, webhook_ssrf_error_message}; use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; use windmill_common::webhook::WebhookShared; use windmill_common::{BASE_URL, DB}; @@ -1923,6 +1924,16 @@ async fn edit_webhook( )); } + // An empty URL is stored as-is and means "no webhook" to the sender. + if let Some(webhook) = ew.webhook.as_deref().filter(|w| !w.is_empty()) { + validate_webhook_url(webhook).await.map_err(|e| { + Error::BadRequest(format!( + "Webhook URL is not allowed: {}", + webhook_ssrf_error_message(&e) + )) + })?; + } + let mut tx = db.begin().await?; if let Some(webhook) = &ew.webhook { @@ -5960,6 +5971,13 @@ async fn set_encryption_key( &new_encryption_key, ) .await?; + crate::remote_deploy::reencrypt_tokens( + &mut tx, + &w_id, + &previous_encryption_key, + &new_encryption_key, + ) + .await?; tx.commit().await?; @@ -10143,13 +10161,25 @@ async fn leave_workspace( &format!("u/{}", authed.username), ) .await?; - sqlx::query!( + let left = sqlx::query!( "DELETE FROM usr WHERE workspace_id = $1 AND email = $2", &w_id, &authed.email ) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); + // A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop + // its connection. + if left > 0 { + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", + &authed.email, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 82423480fa..9039bda2ef 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id( // Duplicate workspace settings (keep copy in old workspace for reference) info!("Duplicating workspace_settings table"); sqlx::query!( - "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2", + "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", &rw.new_id, &old_id ) @@ -841,6 +841,14 @@ pub(crate) async fn change_workspace_id( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2", + &rw.new_id, + &old_id + ) + .execute(&mut *tx) + .await?; + info!("Updating variable table"); sqlx::query!( "UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e986976796..21ca421af3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.815.0 + version: 1.817.0 title: Windmill API contact: @@ -529,30 +529,6 @@ paths: responses: "302": description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown - post: - security: [] - summary: consume a single-use login link from its confirmation page, set the session cookie and answer where to go - operationId: confirmLoginLink - tags: - - user - parameters: - - name: token - in: path - required: true - schema: - type: string - responses: - "200": - description: the post-login destination, or /user/login_link_expired when the link is used, expired or unknown - content: - application/json: - schema: - type: object - required: - - location - properties: - location: - type: string /auth/reset_password: post: @@ -1786,6 +1762,26 @@ paths: schema: type: string + /settings/instance_ui: + get: + summary: get the instance-wide announcement banner and accent color + description: | + Readable by any authenticated user; every page load reads it. Each value + is the raw stored setting, or null when unset. + operationId: getInstanceUi + tags: + - setting + responses: + "200": + description: the instance_banner and accent_color settings + content: + application/json: + schema: + type: object + properties: + instance_banner: {} + accent_color: {} + /settings_u/ruff_config: get: summary: get instance ruff config (unauthenticated) @@ -4211,6 +4207,100 @@ paths: deploy_to: type: string + /w/{workspace}/remote_deploy/target: + get: + summary: get the workspace's deploy target on another instance, and the caller's connection to it + description: | + Once connected, deploy calls aimed at the target go through + `/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`, which forwards any method to + `{base_url}/api/w/{target workspace}/{route}` with the caller's stored token. + operationId: getRemoteDeployTarget + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: remote deploy target and the caller's connection to it + content: + application/json: + schema: + $ref: "#/components/schemas/RemoteDeployStatus" + post: + summary: set or clear the workspace's deploy target on another instance + description: Changing the target drops every token stored for the previous one. + operationId: setRemoteDeployTarget + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + target: + $ref: "#/components/schemas/RemoteDeployTarget" + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + + /w/{workspace}/remote_deploy/connect: + post: + summary: store the caller's own token for the remote deploy target + description: | + The token is checked against the target's whoami before it is stored. `target` names the + target the token was obtained for, and the request is refused if the workspace now points + elsewhere, so a token is never sent to an instance it was not meant for. + operationId: connectRemoteDeploy + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: + type: string + target: + $ref: "#/components/schemas/RemoteDeployTarget" + required: + - token + - target + responses: + "200": + description: the stored connection + content: + application/json: + schema: + $ref: "#/components/schemas/RemoteDeployConnection" + + /w/{workspace}/remote_deploy/disconnect: + post: + summary: forget the caller's token for the remote deploy target + operationId: disconnectRemoteDeploy + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/is_premium: get: summary: get if workspace is premium @@ -6928,7 +7018,7 @@ paths: type: string expires_in_s: type: integer - description: link lifetime in seconds, at most 7200 (default 600) + description: link lifetime in seconds, at most 900 (default 600) rd: type: string description: same-origin path the browser lands on after login (default /user/workspaces) @@ -6938,12 +7028,6 @@ paths: mint only while the account still has this login type (for example pending_oauth), so a link stops working once the owner has set a password or signed in with a provider - confirm: - type: boolean - description: >- - return a /user/login_link page that signs in only when its button is - clicked, instead of a link spent by opening it; set it for links sent by - email, which mail scanners open on delivery (default false) responses: "201": description: login link minted @@ -10601,6 +10685,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -10640,6 +10727,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -16220,6 +16310,29 @@ paths: items: type: string + /w/{workspace}/jobs/queue/run_now/{id}: + post: + summary: start a queued job scheduled for later now + description: | + Moves the start of a queued job that is waiting for a future time (scheduled, debounced, + re-scheduled by a concurrency limit, or a flow step waiting on a sleep or a retry delay) + to now, keeping the same job id. A job with a + concurrency limit is still checked against its limit when it is pulled. A schedule's + upcoming tick is refused, since the schedule would queue that tick again. + operationId: runQueuedJobNow + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: job id + content: + text/plain: + schema: + type: string + /w/{workspace}/jobs/queue/cancel_selection: post: summary: cancel jobs based on the given uuids @@ -29658,6 +29771,15 @@ components: type: string schema: type: object + description: >- + JSON Schema of the arguments of `main`, which is what a run form and an + MCP tool offer. Omitted (or `{}`), it is inferred from `content` for + TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible + scripts. For other languages, or code that does not parse, a new script + gets none. A new version of an existing script also keeps what the + previous version's schema says about each argument, or that whole schema + when nothing can be inferred. A dbt script always takes its schema from its + descriptor, whatever is sent. is_template: type: boolean lock: @@ -31078,6 +31200,7 @@ components: - "jobs" - "jobs.cancel" - "jobs.force_cancel" + - "jobs.run_now" - "jobs.disapproval" - "jobs.delete" - "account.delete" @@ -36562,6 +36685,44 @@ components: items: $ref: "#/components/schemas/GitSyncObjectType" + RemoteDeployTarget: + type: object + properties: + base_url: + type: string + description: root URL of the remote Windmill instance, without /api + workspace_id: + type: string + description: workspace on the remote instance that deploys land in + required: + - base_url + - workspace_id + + RemoteDeployConnection: + type: object + properties: + remote_email: + type: string + description: identity the stored token has on the remote instance + proxy_key: + type: string + description: goes in every proxy URL, `/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}` + connected_at: + type: string + format: date-time + required: + - remote_email + - proxy_key + - connected_at + + RemoteDeployStatus: + type: object + properties: + target: + $ref: "#/components/schemas/RemoteDeployTarget" + connection: + $ref: "#/components/schemas/RemoteDeployConnection" + WorkspaceDefaultScripts: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 432af1bef0..54886e2568 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -69,7 +69,7 @@ use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ - http_get_from_hub, not_found_if_none, paginate, paginate_optional, + check_proper_path, http_get_from_hub, not_found_if_none, paginate, paginate_optional, query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath, }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, @@ -2506,6 +2506,7 @@ async fn create_app_internal<'a>( // inside process_app_multipart!, so checking after this call would leave a // denied app committed in the DB. check_scopes(&authed, || format!("apps:write:{}", &app.path))?; + check_proper_path(&app.path)?; validate_frontend_sdk_scopes(&app.policy)?; if raw_app { validate_raw_app_path_keys(&app.value.0)?; @@ -3252,6 +3253,7 @@ async fn create_app_raw_source( // Before the compile, which costs a job on a worker: it must not run for a // path already taken, nor for one the caller can't write. `create_app_internal` // rejects both, but only after the sources have been built. + check_proper_path(&path)?; if app_exists(&db, &w_id, &path).await? { return Err(Error::BadRequest(format!("App {path} already exists"))); } @@ -3462,6 +3464,9 @@ async fn update_app_internal<'a>( // the token's write scope, not just the source path. if let Some(npath) = ns.path.as_deref() { check_scopes(&authed, || format!("apps:write:{}", npath))?; + if npath != path { + check_proper_path(npath)?; + } } if raw_app { @@ -4450,9 +4455,11 @@ async fn execute_component( // — like `/jobs/run/preview` — confine it to worker tags the caller may use // (a `if_jobs:filter_tags`-restricted token must not escape its filter). // `is_preview` implies an authed caller (the guard above returns otherwise). + let push_args = PushArgs { args: &args.args, extra: args.extra }; if is_preview { if let Some(authed) = opt_authed.as_ref() { - crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, authed).await?; + crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, authed) + .await?; } } // Identity is already resolved to the requesting user in preview mode (the @@ -4483,7 +4490,7 @@ async fn execute_component( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, &username, email, permissioned_as, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 18ddc0b787..b9e71271d4 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -116,7 +116,9 @@ use windmill_common::{ query_builders, scripts::{ScriptHash, ScriptLang}, users::username_to_permissioned_as, - utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath}, + utils::{ + not_found_if_none, now_from_db, paginate, require_admin, Pagination, ScheduleType, StripPath, + }, }; use windmill_common::{ @@ -304,6 +306,7 @@ pub fn workspaced_service() -> Router { .route("/queue/position/{timestamp}", get(get_queue_position)) .route("/queue/scheduled_for/{id}", get(get_scheduled_for)) .route("/queue/cancel_selection", post(cancel_selection)) + .route("/queue/run_now/{id}", post(run_queued_job_now)) .route("/completed/count", get(count_completed_jobs)) .route("/completed/count_jobs", get(count_completed_jobs_detail)) .route( @@ -835,6 +838,113 @@ async fn force_cancel( } } +/// Moves a queued job's `scheduled_for` to now so the next pull picks it up, keeping its id. +/// A job with a concurrency limit still goes through the usual check at pull time and is +/// re-scheduled again if its key is still saturated. +async fn run_queued_job_now( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?; + refuse_upcoming_schedule_tick(&db, &w_id, id).await?; + + let mut tx = db.begin().await?; + let previous = sqlx::query_scalar!( + "UPDATE v2_job_queue q SET scheduled_for = now() + FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev + WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL + AND q.scheduled_for > now() + RETURNING prev.scheduled_for", + id, + w_id, + ) + .fetch_optional(&mut *tx) + .await?; + + let Some(previous) = previous else { + tx.commit().await?; + return Err( + if sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + id, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false) + { + Error::BadRequest(format!( + "job {id} is not waiting for a future start: it is running, suspended, \ + canceled or already due" + )) + } else { + Error::NotFound(format!("queued job id {id} does not exist")) + }, + ); + }; + + audit_log( + &mut *tx, + &authed, + "jobs.run_now", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + Some([("previous_scheduled_for", previous.to_rfc3339().as_str())].into()), + ) + .await?; + tx.commit().await?; + + windmill_queue::append_logs( + &id, + &w_id, + format!( + "\nStart moved from {previous} to now by {}\n", + authed.display_username() + ), + &db.clone().into(), + ) + .await; + + Ok(id.to_string()) +} + +/// A schedule queues its next tick when the current one completes, computed from the +/// completion time. Starting a tick that is not yet due would therefore queue that same +/// tick again and run the schedule twice. A tick pushed past its time by a concurrency +/// limit no longer sits on a cron occurrence, so it may still be started early. +async fn refuse_upcoming_schedule_tick(db: &DB, w_id: &str, id: Uuid) -> error::Result<()> { + let Some(tick) = sqlx::query!( + "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone + FROM v2_job j + JOIN v2_job_queue q USING (id) + JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger + WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule' + AND j.parent_job IS NULL AND q.scheduled_for > now()", + id, + w_id, + ) + .fetch_optional(db) + .await? + else { + return Ok(()); + }; + let sched = ScheduleType::from_str(&tick.schedule, tick.cron_version.as_deref(), false)?; + let tz = + chrono_tz::Tz::from_str(&tick.timezone).map_err(|e| Error::BadRequest(e.to_string()))?; + let just_before = (tick.scheduled_for - chrono::Duration::milliseconds(1)).with_timezone(&tz); + if sched.find_next(&just_before)? == tick.scheduled_for { + return Err(Error::BadRequest(format!( + "job {id} is the upcoming tick of schedule {}; run the schedule's script or flow \ + directly instead", + tick.path + ))); + } + Ok(()) +} + #[derive(Serialize)] struct QueuePosition { position: Option, @@ -7226,7 +7336,10 @@ pub async fn run_workflow_as_code( ) -> error::Result<(StatusCode, String)> { #[cfg(feature = "enterprise")] check_license_key_valid().await?; - check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &authed).await?; + let mut extra = HashMap::new(); + extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint)); + let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; + check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?; check_scopes(&authed, || format!("jobs:run"))?; if !is_valid_entrypoint_name(&entrypoint) { @@ -7320,10 +7433,6 @@ pub async fn run_workflow_as_code( i += 1; } - let mut extra = HashMap::new(); - extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint)); - - let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(tag).or(Some(job.tag)); @@ -7360,7 +7469,7 @@ pub async fn run_workflow_as_code( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + args, authed.display_username(), email, permissioned_as, @@ -7646,7 +7755,8 @@ pub async fn run_wait_result_job_by_path_get( .await?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_authed.as_ref() { @@ -7670,7 +7780,7 @@ pub async fn run_wait_result_job_by_path_get( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -7791,7 +7901,8 @@ pub async fn run_wait_result_script_by_path_internal( .await?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_of.as_ref() { @@ -7815,7 +7926,7 @@ pub async fn run_wait_result_script_by_path_internal( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -7904,7 +8015,8 @@ pub async fn run_wait_result_script_by_hash( check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( @@ -7940,7 +8052,7 @@ pub async fn run_wait_result_script_by_hash( && has_preprocessor.unwrap_or(false), labels, }, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -8465,7 +8577,6 @@ async fn run_preview_script( require_path_read_access_for_preview(&authed, &preview.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); let preview_args = preview.args.unwrap_or_default(); @@ -8484,6 +8595,7 @@ async fn run_preview_script( } let extra = if extra.is_empty() { None } else { Some(extra) }; let push_args = PushArgs { extra, args: &preview_args }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (uuid, tx) = push( &db, @@ -8845,7 +8957,6 @@ async fn run_bundle_preview_script( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; let ltx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); let args = preview.args.unwrap_or_default(); @@ -8860,6 +8971,7 @@ async fn run_bundle_preview_script( m }); let push_args = PushArgs { extra, args: &args }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; is_tar = match preview.kind { Some(PreviewKind::Tarbundle) => true, @@ -9511,7 +9623,6 @@ async fn run_preview_flow_job( require_path_read_access_for_preview(&authed, &raw_flow.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false); @@ -9529,6 +9640,8 @@ async fn run_preview_flow_job( to_raw_value(temp_script_refs), ); } + check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed) + .await?; let (uuid, mut tx) = push( &db, @@ -9774,11 +9887,6 @@ async fn run_dynamic_select( } } - // Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue): - // a caller allowed to read the flow must still be allowed to use its worker tag. No-op for - // inline (tag is None); the script branch checked this inside its helper and returned above. - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; - // Invoke the dyn-select entrypoint instead of `main`. let mut args = request.args.unwrap_or_default(); args.insert( @@ -9786,6 +9894,11 @@ async fn run_dynamic_select( serde_json::value::to_raw_value(&request.entrypoint_function)?, ); + // Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue): + // a caller allowed to read the flow must still be allowed to use its worker tag. No-op for + // inline (tag is None); the script branch checked this inside its helper and returned above. + check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&args), &authed).await?; + let scheduled_for = run_query.get_scheduled_for(&db).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); @@ -9920,8 +10033,9 @@ pub async fn run_job_by_hash_inner( } let scheduled_for = run_query.get_scheduled_for(&db).await?; 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, &authed).await?; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( @@ -9957,7 +10071,7 @@ pub async fn run_job_by_hash_inner( && has_preprocessor.unwrap_or(false), labels, }, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 0fd5ac31b5..82f08cde73 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -534,7 +534,9 @@ pub async fn run_server( #[cfg(feature = "http_trigger")] { let http_killpill_rx = killpill_rx.resubscribe(); - triggers::http::refresh_routers_loop(&db, http_killpill_rx).await; + // A worker only serves `/r` when one of its own jobs calls the local API, so it loads the + // routers on that first request rather than at startup. + triggers::http::refresh_routers_loop(&db, http_killpill_rx, server_mode).await; } let triggers_service = triggers::generate_trigger_routers(); @@ -731,6 +733,12 @@ pub async fn run_server( path_autocomplete::workspaced_service(), ) .nest("/raw_apps", raw_apps::workspaced_service()) + .nest( + "/remote_deploy", + windmill_api_workspaces::remote_deploy::workspaced_service( + request_size_limit * 5, + ), + ) // CORS so the opaque-origin app iframe can read // resources/list, resources/type/* with a scoped token. .nest( diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index ffefaba2dd..1962710578 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -699,6 +699,10 @@ pub fn all_tools() -> Vec { "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -757,6 +761,10 @@ is, a different one moves it there and archives the old path"), "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -1024,10 +1032,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "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')" }, "path": { "type": "string" @@ -1080,10 +1090,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "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')" }, "tag": { "type": "string" diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 86fbec1806..472c7bbec9 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -386,6 +386,12 @@ impl McpBackend for WindmillBackend { }) } + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult { + windmill_common::runnables::runnable_list_fingerprint(&self.db, workspace_id) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None)) + } + fn all_endpoint_tools(&self) -> Vec { all_tools() } diff --git a/backend/windmill-api/src/raw_apps.rs b/backend/windmill-api/src/raw_apps.rs index d7bc3bc6c0..35e4f693a4 100644 --- a/backend/windmill-api/src/raw_apps.rs +++ b/backend/windmill-api/src/raw_apps.rs @@ -5,7 +5,10 @@ * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ -use crate::{db::ApiAuthed, utils::check_scopes}; +use crate::{ + db::ApiAuthed, + utils::{build_scope_path_filter, check_scopes, ScopePathFilter}, +}; use axum::{ body::Body, extract::{Extension, Json, Path, Query}, @@ -103,6 +106,31 @@ async fn list_apps( } } + // In the WHERE, not a retain after the fetch: the result is paginated, and a + // post-fetch filter would return short pages and let a page's size report how + // many raw apps the token may not read. One `?` per term, since a chained `.bind` + // substitutes into a `?` inside the value an earlier one inserted; `starts_with`, + // since LIKE reads a `_` in the path as a wildcard. + if let ScopePathFilter::Restricted { exact, prefix } = + build_scope_path_filter(&authed, "raw_apps", "read") + { + let terms: Vec = exact + .iter() + .chain(&prefix) + .map(|p| "app.path = ?".bind(p)) + .chain( + prefix + .iter() + .map(|p| "starts_with(app.path, ?)".bind(&format!("{p}/"))), + ) + .collect(); + sqlb.and_where(if terms.is_empty() { + "false".to_string() + } else { + format!("({})", terms.join(" OR ")) + }); + } + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as::<_, ListableApp>(&sql) diff --git a/backend/windmill-api/src/tracing_init.rs b/backend/windmill-api/src/tracing_init.rs index 56adeee184..d99a7e5907 100644 --- a/backend/windmill-api/src/tracing_init.rs +++ b/backend/windmill-api/src/tracing_init.rs @@ -14,6 +14,7 @@ use axum::response::Response as AxumResponse; use hyper::Response; use tower_http::trace::{MakeSpan, OnFailure, OnResponse}; use uuid::Uuid; +use windmill_api_workspaces::remote_deploy::RedactedUri; use windmill_common::log_context::{with_log_context, LogContext}; lazy_static::lazy_static! { @@ -86,7 +87,7 @@ impl MakeSpan for MyMakeSpan { tracing::error_span!( "request", method = %request.method(), - uri = %request.uri(), + uri = %RedactedUri(request.uri()), username = field::Empty, workspace_id = field::Empty, traceId = tracing_id, @@ -113,7 +114,7 @@ pub async fn log_context_middleware(request: Request, next: Next) -> AxumRespons let ctx = LogContext { method: Some(request.method().to_string()), - uri: Some(request.uri().to_string()), + uri: Some(RedactedUri(request.uri()).to_string()), trace_id, ..Default::default() }; diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 387ba56589..3d85904e50 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -381,7 +381,7 @@ async fn get_http_route_trigger( let routers_cache = HTTP_ROUTERS_CACHE.read().await; let routers_cache = if routers_cache.routers.is_empty() { - tracing::warn!("HTTP routers are not loaded, loading from db"); + tracing::info!("HTTP routers are not loaded, loading from db"); // refresh_routers takes the write lock, so holding this read guard across it deadlocks. drop(routers_cache); let (_, routers_cache) = refresh_routers(db, false).await?; diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 4f6a6d9efb..ea79aaa572 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -281,8 +281,29 @@ enum ArchiveImpl { Tar(tokio_tar::Builder), } +/// Entry paths come from item paths stored in the workspace; an absolute or +/// drive-prefixed path, or a parent segment, would make extraction write outside +/// the target directory. Win32 strips trailing dots and spaces from a segment, +/// so `.. ` resolves to `..` there: any segment made only of those is refused. +/// A colon matters only in the first segment (a drive prefix); later segments, +/// such as a data table name, may carry one. +fn check_archive_entry_path(path: &str) -> Result<()> { + let is_dot_segment = |seg: &str| !seg.is_empty() && seg.chars().all(|c| c == '.' || c == ' '); + let first = path.split(['/', '\\']).next().unwrap_or_default(); + if path.starts_with(['/', '\\']) + || first.contains(':') + || path.split(['/', '\\']).any(is_dot_segment) + { + return Err(Error::internal_err(format!( + "refusing to write archive entry with path traversal: {path}" + ))); + } + Ok(()) +} + impl ArchiveImpl { async fn write_to_archive(&mut self, content: &str, path: &str) -> Result<()> { + check_archive_entry_path(path)?; match self { ArchiveImpl::Tar(t) => { let bytes = content.as_bytes(); @@ -1425,9 +1446,17 @@ pub(crate) async fn tarball_workspace( use strum::IntoEnumIterator; for service_name in ServiceName::iter() { - let native_triggers = - list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None) - .await?; + let native_triggers = list_native_triggers( + &mut *tx, + &w_id, + service_name, + None, + None, + None, + None, + windmill_api_auth::ScopePathFilter::AllowAll, + ) + .await?; // Native triggers (Nextcloud, Google Drive, GitHub) are never // cloned into a fork — a fork only has one if its owner created @@ -1639,7 +1668,9 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color.clone(), operator_settings: row.operator_settings.clone(), - datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable.clone()), + datatable: windmill_common::workspaces::strip_datatable_permissions( + row.datatable.clone(), + ), slack_team_id: row.slack_team_id.clone(), slack_name: row.slack_name.clone(), slack_command_script: row.slack_command_script.clone(), @@ -1784,6 +1815,41 @@ pub(crate) async fn tarball_workspace( Ok((headers, body)) } +#[cfg(test)] +mod archive_entry_path_tests { + use super::check_archive_entry_path; + + #[test] + fn rejects_traversal_and_absolute_paths() { + for ok in [ + "u/admin/app.app.json", + "f/x/a..b.script.json", + "settings.yaml", + "migrations/datatable/foo:bar/20260617120000_name.up.sql", + ] { + assert!( + check_archive_entry_path(ok).is_ok(), + "{ok} should be accepted" + ); + } + for bad in [ + "f/x/../../evil.app.json", + "../evil", + "/etc/evil", + "f\\..\\evil", + "f/x/.. /.. /evil.app.json", + "f/x/.../evil", + "C:/evil", + "\\evil", + ] { + assert!( + check_archive_entry_path(bad).is_err(), + "{bad} should be rejected" + ); + } + } +} + #[cfg(test)] mod fork_export_tests { use super::*; diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 06de7cc4aa..e95fadfe66 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -18,7 +18,7 @@ parquet = [] aws_auth = ["dep:aws-sdk-sts", "dep:aws-config"] otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk", "dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic", "dep:opentelemetry"] -smtp = ["dep:mail-send"] +smtp = ["dep:mail-send", "dep:rustls", "dep:tokio-rustls", "dep:rustls-native-certs", "dep:webpki-roots"] scoped_cache = [] cloud = [] dev_override = [] @@ -85,6 +85,10 @@ aws-sdk-rds = { workspace = true, optional = true } indexmap.workspace = true bytes.workspace = true mail-send = { workspace = true, optional = true } +rustls = { workspace = true, optional = true } +tokio-rustls = { workspace = true, optional = true } +rustls-native-certs = { workspace = true, optional = true } +webpki-roots = { workspace = true, optional = true } futures-core.workspace = true async-stream.workspace = true const_format.workspace = true @@ -136,6 +140,7 @@ equivalent = "1.0.2" [dev-dependencies] # `test-util` is not part of tokio's `full`; it is what lets tests pause the clock. tokio = { workspace = true, features = ["test-util"] } +rcgen.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemalloc-ctl = { optional = true, workspace = true } diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index c12d62f206..86b7f173ac 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -20,7 +20,8 @@ use crate::{ /// Whether `label` denotes a user-created token rather than a system token /// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`, -/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`). System-token labels are load-bearing — +/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`, `remote-deploy:*`). +/// System-token labels are load-bearing — /// session cleanup, super_admin propagation, expiry notifications and username overrides /// all key off them — so they must not be user-editable. `None` (no label) is treated as /// a user token. @@ -48,6 +49,7 @@ pub fn is_user_token(label: Option<&str>) -> bool { && !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX) && !l.starts_with("impersonation:") && !l.starts_with(CLI_LOGIN_TOKEN_LABEL_PREFIX) + && !l.starts_with(REMOTE_DEPLOY_TOKEN_LABEL_PREFIX) } } } @@ -73,6 +75,14 @@ pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:"; /// `/users/tokens/create`. pub const CLI_LOGIN_TOKEN_LABEL_PREFIX: &str = "cli-login:"; +/// Label prefix, followed by the host of the instance deploying with it, of the token the +/// `/user/remote_deploy_authorize` page mints for another Windmill instance. Reserved in +/// [`is_user_token`], whose SQL and frontend mirrors spell it out: it is renewed by connecting +/// again from the deploying instance, which reports this one rejecting it, so an expiry email or +/// alert here points at the wrong place. Not in [`is_server_minted_label`], since the page mints it +/// through `/users/tokens/create`. +pub const REMOTE_DEPLOY_TOKEN_LABEL_PREFIX: &str = "remote-deploy:"; + /// Whether `label` belongs to a namespace only the server mints, and which therefore must be /// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label /// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`, @@ -997,6 +1007,7 @@ mod tests { assert!(!is_user_token(Some("sdk_app:u/admin/raw app"))); assert!(!is_user_token(Some("impersonation:admin@windmill.dev"))); assert!(!is_user_token(Some("cli-login:admin"))); + assert!(!is_user_token(Some("remote-deploy:windmill.example.com"))); } #[test] diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 6b9aae519d..e72a2bb4e2 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -281,6 +281,51 @@ pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> Ok(()) } +/// Instance-wide accent color (`#rrggbb`) that recolors the UI's accent tokens and +/// tints the sidebar, so each environment of a deployment is recognizable at a glance. +/// Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]. +pub const ACCENT_COLOR_SETTING: &str = "accent_color"; + +/// Validate an [`ACCENT_COLOR_SETTING`] value. +/// +/// Only `#rrggbb` is accepted: the value is interpolated into a stylesheet every user +/// loads, so anything looser is CSS injection into every session of the instance. +pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> { + let s = value + .as_str() + .ok_or_else(|| "must be a string".to_string())?; + let hex = s.strip_prefix('#').unwrap_or(""); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("must be a hex color of the form #rrggbb".to_string()); + } + Ok(()) +} + +/// The settings every signed-in browser reads on each full page load. +#[derive(serde::Serialize, Clone, Debug, Default, PartialEq)] +pub struct InstanceUi { + pub instance_banner: Option, + pub accent_color: Option, +} + +pub async fn get_instance_ui(db: &Pool) -> error::Result { + let rows = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &[INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING] as &[&str] + ) + .fetch_all(db) + .await?; + let mut ui = InstanceUi::default(); + for row in rows { + match row.name.as_str() { + INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value), + ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value), + _ => {} + } + } + Ok(ui) +} + /// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value. /// /// The receiver path is appended to it verbatim, so anything that doesn't @@ -939,6 +984,26 @@ mod tests { } } + #[test] + fn accent_color_accepts_only_hex_rgb() { + for ok in ["#1f9d55", "#ABCDEF"] { + assert!( + validate_accent_color(&serde_json::json!(ok)).is_ok(), + "{ok}" + ); + } + for bad in [ + serde_json::json!("1f9d55"), + serde_json::json!("#fff"), + serde_json::json!("red"), + serde_json::json!("#000000;} body{display:none"), + serde_json::json!("#12345g"), + serde_json::json!(123), + ] { + assert!(validate_accent_color(&bad).is_err(), "{bad}"); + } + } + #[test] fn instance_banner_rejects_unsafe_and_malformed_values() { // The link becomes the href of an anchor shown to every user of the instance, diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 14ef869b37..508379c3a4 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1029,9 +1029,10 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ ), ( "object_store_cache_config", - &["secret_key", "serviceAccountKey"], + &["secret_key", "serviceAccountKey", "accessKey"], ), ("custom_instance_pg_databases", &["user_pwd"]), + ("github_enterprise_app", &["private_key"]), ]; fn redact_json_value(value: &serde_json::Value) -> serde_json::Value { @@ -1353,6 +1354,14 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?, } + let accent_key = crate::global_settings::ACCENT_COLOR_SETTING; + match desired.get(accent_key) { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(s)) if s.trim().is_empty() => {} + Some(color) => crate::global_settings::validate_accent_color(color) + .map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?, + } + // An origin list that cannot be parsed is dropped at boot, leaving the // empty default — which is no restriction at all. Rejecting it here is what // keeps a typo in a ConfigMap from silently widening CORS instance-wide. @@ -2647,6 +2656,26 @@ mod tests { assert!(formatted.contains("****")); } + #[test] + fn format_setting_value_redacts_nested_credentials() { + let val = serde_json::json!({ + "type": "Azure", + "accountName": "acct", + "containerName": "c", + "accessKey": "azure-storage-account-key-12345" + }); + let formatted = format_setting_value("object_store_cache_config", &val); + assert!(!formatted.contains("azure-storage-account-key-12345")); + assert!(formatted.contains("acct")); + + let val = serde_json::json!({ + "app_id": 1, + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretbody\n-----END RSA PRIVATE KEY-----" + }); + let formatted = format_setting_value("github_enterprise_app", &val); + assert!(!formatted.contains("MIIEsecretbody")); + } + #[test] #[should_panic(expected = "literal_value() called on unresolved secret ref")] fn string_or_secret_ref_literal_value_panics_on_ref() { diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 6f3248f3fb..796d1c999a 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -17,7 +17,7 @@ use crate::{ get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, utils::{StripPath, HTTP_CLIENT}, - worker::{to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR}, + worker::{custom_tag_matches, to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR}, workspaces::workspace_with_fork_ancestors, FlowVersionInfo, ScriptHashInfo, Tag, }; @@ -337,33 +337,70 @@ lazy_static::lazy_static! { // `is_super_admin` is passed in (not derived from an email here) so callers can // make it job-token-aware: a job's WM_TOKEN must never count as superadmin // (GHSA-hfh4-cx4h-3fcr). See `is_super_admin_authed` at the request wrapper. +// +// Custom tags judge `resolved_tag`, the queue the job actually lands on, and `tag` only as the +// job's author wrote it: a dynamic tag names any queue its values do. Callers go through +// `windmill_queue::check_tag_available_for_push`, which resolves it the way `push` does. +// `resolved_tag` is `None` when the values are not known yet: only an entry spelled like `tag` +// admits it then. `tag_workspace` resolves to what the job's `$workspace` stands for. pub async fn check_tag_available_for_workspace_internal( db: &DB, w_id: &str, tag: &str, + resolved_tag: Option<&str>, + tag_workspace: impl Future, is_super_admin: bool, scope_tags: Option>, ) -> error::Result<()> { - let mut is_tag_in_scope_tags = None; - let mut is_tag_in_workspace_custom_tags = false; - - if let Some(scope_tags) = scope_tags.as_ref() { - is_tag_in_scope_tags = Some(scope_tags.contains(&tag)); - } + let is_tag_in_scope_tags = scope_tags.as_ref().map(|scope_tags| { + scope_tags.contains(&tag) || resolved_tag.is_some_and(|r| scope_tags.contains(&r)) + }); let custom_tags_per_w = CUSTOM_TAGS_PER_WORKSPACE.load(); - if custom_tags_per_w.global.contains(&tag.to_string()) { - is_tag_in_workspace_custom_tags = true; - } else if let Some(specific_tag) = custom_tags_per_w.specific.get(tag) { - // Only a fork-scoped tag can match through the lineage, so every other tag keeps the - // ancestor lookup off the push path entirely. - let chain = if specific_tag.is_fork_scoped() { - workspace_with_fork_ancestors(db, w_id).await? - } else { - vec![w_id.to_string()] - }; - is_tag_in_workspace_custom_tags = specific_tag.applies_to_workspace(&chain); - } + // Only an entry reading `$workspace` needs it, so every other tag keeps its lookup off the + // push path. + let tag_workspace = if resolved_tag.is_some() + && custom_tags_per_w + .global + .iter() + .chain(custom_tags_per_w.specific.keys()) + .any(|t| t.contains("$workspace")) + { + tag_workspace.await + } else { + w_id.to_string() + }; + // A job whose tag is written exactly as an entry resolves inside what that entry admits, which + // is the only way in for an entry `custom_tag_matches` cannot turn into a pattern. + let admits = |entry: &str| { + entry == tag || resolved_tag.is_some_and(|r| custom_tag_matches(entry, r, &tag_workspace)) + }; + let scoped = custom_tags_per_w + .specific + .iter() + .filter(|(name, _)| admits(name)) + .collect::>(); + // Only a fork-scoped tag can match through the lineage, so every other tag keeps the + // ancestor lookup off the push path entirely. + let chain = if scoped.iter().any(|(_, t)| t.is_fork_scoped()) { + workspace_with_fork_ancestors(db, w_id).await? + } else { + vec![w_id.to_string()] + }; + // A tag listed by its own name with a scope stays inside that scope: a pattern it fits does + // not open it to other workspaces. Only listing it by name without a scope does. + let lands_on = resolved_tag.unwrap_or(tag); + let confined_by = custom_tags_per_w + .specific + .get_key_value(lands_on) + .filter(|(name, t)| { + !name.contains('$') + && !t.applies_to_workspace(&chain) + && !custom_tags_per_w.global.iter().any(|e| e == lands_on) + }); + let is_tag_in_workspace_custom_tags = confined_by.is_none() + && (custom_tags_per_w.global.iter().any(|entry| admits(entry)) + || scoped.iter().any(|(_, t)| t.applies_to_workspace(&chain))); match is_tag_in_scope_tags { Some(true) | None => { @@ -375,6 +412,12 @@ pub async fn check_tag_available_for_workspace_internal( } if !is_super_admin { + let tag = match resolved_tag { + Some(resolved_tag) if resolved_tag != tag => { + format!("{tag} (resolved to {resolved_tag})") + } + _ => tag.to_string(), + }; if scope_tags.is_some() && is_tag_in_scope_tags.is_some() { return Err(Error::BadRequest(format!( "Tag {tag} is not available in your scope" @@ -383,9 +426,35 @@ pub async fn check_tag_available_for_workspace_internal( if *TAGS_ARE_SENSITIVE { return Err(Error::BadRequest(format!("{tag} is not available to you"))); + } else if let Some((name, confining)) = confined_by { + let overridden = custom_tags_per_w + .global + .iter() + .filter(|entry| admits(entry)) + .cloned() + .chain( + scoped + .iter() + .filter(|(_, t)| t.applies_to_workspace(&chain)) + .map(|(n, t)| t.authored(n)), + ) + .collect::>(); + let overridden = if overridden.is_empty() { + String::new() + } else { + format!( + ", which takes precedence over the custom tags it also fits: {}", + overridden.join(", ") + ) + }; + return Err(Error::BadRequest(format!( + "Tag {tag} is not available in workspace {w_id}: the custom tag {} restricts it \ + to other workspaces{overridden}", + confining.authored(name) + ))); } else { return Err(error::Error::BadRequest(format!( - "Only super admins are allowed to use tags that are not included in the allowed CUSTOM_TAGS: {:?}", + "Tag {tag} is not included in the allowed CUSTOM_TAGS, which only super admins can go beyond: {:?}", custom_tags_per_w ))); } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 3e74d8e17f..1015948882 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -118,6 +118,7 @@ pub mod queue; pub mod queue_metrics; pub mod result_stream; pub mod runnable_settings; +pub mod runnables; pub mod schedule; pub mod schema; pub mod scripts; diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 9d85278404..546d2d454b 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -333,6 +333,7 @@ pub fn try_expand_internal_db_query( // Metadata queries "LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type), "FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql), + "ALL_FOREIGN_KEYS" => expand_all_foreign_keys(db_type).map(ExpandedQuery::sql), "PRIMARY_KEY_CONSTRAINT" => { expand_primary_key_constraint(json_str, db_type).map(ExpandedQuery::sql) } @@ -2737,6 +2738,47 @@ fn expand_foreign_keys(json_str: &str, db_type: DbType) -> Result Result { + make_all_foreign_keys_query(db_type) +} + +/// Every foreign key of the database, one row per referencing column. +/// +/// Read from `pg_constraint` rather than `information_schema`: the latter's +/// `constraint_column_usage` loses the pairing of a composite key's columns, +/// which the diagram needs to line a source column up with the column it points +/// at. `unnest(conkey, confkey)` keeps them zipped in declaration order. +fn make_all_foreign_keys_query(db_type: DbType) -> Result { + if db_type != DbType::Postgresql { + return Err(format!( + "The schema diagram is only supported for PostgreSQL, not {:?}", + db_type + )); + } + Ok(String::from( + "SELECT + con.conname as fk_constraint_name, + src_ns.nspname as source_schema, + src.relname as source_table, + src_att.attname as source_column, + tgt_ns.nspname as target_schema, + tgt.relname as target_table, + tgt_att.attname as target_column, + k.ord as ordinal +FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class src ON src.oid = con.conrelid + JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace + JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid + JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace + JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(src_attnum, tgt_attnum, ord) ON true + JOIN pg_catalog.pg_attribute src_att ON src_att.attrelid = src.oid AND src_att.attnum = k.src_attnum + JOIN pg_catalog.pg_attribute tgt_att ON tgt_att.attrelid = tgt.oid AND tgt_att.attnum = k.tgt_attnum +WHERE con.contype = 'f' + AND src_ns.nspname NOT IN ('pg_catalog', 'information_schema') +ORDER BY src_ns.nspname, src.relname, con.conname, k.ord;", + )) +} + fn expand_primary_key_constraint(json_str: &str, db_type: DbType) -> Result { let p: PrimaryKeyConstraintPayload = serde_json::from_str(json_str) .map_err(|e| format!("Invalid PRIMARY_KEY_CONSTRAINT payload: {}", e))?; @@ -4721,6 +4763,26 @@ mod tests { assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n")); } + // ----------------------------------------------------------------------- + // ALL_FOREIGN_KEYS + // ----------------------------------------------------------------------- + + #[test] + fn test_expand_all_foreign_keys_postgresql() { + let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#; + let sql = expand_code(marker, &ScriptLang::Postgresql); + assert!(sql.contains("con.contype = 'f'")); + // Columns of a composite key must stay zipped with the ones they point at. + assert!(sql.contains("unnest(con.conkey, con.confkey) WITH ORDINALITY")); + } + + #[test] + fn test_expand_all_foreign_keys_rejects_non_postgres() { + let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#; + let result = try_expand_internal_db_query(marker, &ScriptLang::Mysql); + assert!(result.unwrap().is_err()); + } + // ----------------------------------------------------------------------- // PRIMARY_KEY_CONSTRAINT // ----------------------------------------------------------------------- diff --git a/backend/windmill-common/src/runnables.rs b/backend/windmill-common/src/runnables.rs new file mode 100644 index 0000000000..579e5e248e --- /dev/null +++ b/backend/windmill-common/src/runnables.rs @@ -0,0 +1,22 @@ +//! Queries spanning both scripts and flows. + +use crate::{error::Error, DB}; + +/// An opaque value that changes whenever a workspace's live scripts or flows are added, +/// removed, moved or redeployed: a redeploy is a new script hash or a new last flow +/// version, so schema and description changes register too. Not filtered by caller, +/// so it reveals nothing beyond whether it changed. +pub async fn runnable_list_fingerprint(db: &DB, workspace_id: &str) -> Result { + Ok(sqlx::query_scalar::<_, String>( + "SELECT COALESCE(md5(string_agg(k, ',' ORDER BY k)), '') FROM ( + SELECT 's' || path || ':' || hash FROM script + WHERE workspace_id = $1 AND archived = false + UNION ALL + SELECT 'f' || path || ':' || COALESCE(versions[array_upper(versions, 1)], 0) FROM flow + WHERE workspace_id = $1 AND archived = false + ) t(k)", + ) + .bind(workspace_id) + .fetch_one(db) + .await?) +} diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 31dee749e6..0cdf7ffa4f 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -8,6 +8,8 @@ pub const ALLOW_PRIVATE_SAML_METADATA_URLS_ENV: &str = "ALLOW_PRIVATE_SAML_METAD pub const ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV: &str = "ALLOW_PRIVATE_GUEST_JWKS_URLS"; +pub const ALLOW_PRIVATE_WEBHOOK_URLS_ENV: &str = "ALLOW_PRIVATE_WEBHOOK_URLS"; + /// Lets every git call reach hosts on a private network, whoever it is made for. /// Without it, [`private_git_host_allowed`] decides. pub const ALLOW_LOCAL_GIT_REMOTES_ENV: &str = "ALLOW_LOCAL_GIT_REMOTES"; @@ -207,6 +209,12 @@ pub fn allow_private_saml_metadata_urls() -> bool { .is_some_and(|v| v == "true" || v == "1") } +fn allow_private_webhook_urls() -> bool { + std::env::var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV) + .ok() + .is_some_and(|v| v == "true" || v == "1") +} + fn allow_local_git_remotes() -> bool { std::env::var(ALLOW_LOCAL_GIT_REMOTES_ENV) .ok() @@ -321,6 +329,26 @@ pub async fn validate_mcp_server_url(url: &str) -> Result Result { + let parsed = + url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; + + match parsed.scheme() { + "http" | "https" => {} + scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())), + } + + let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?; + + if allow_private_webhook_urls() { + return Ok(ValidatedTarget::unpinned(host)); + } + + validate_url_for_ssrf(url).await +} + /// Validate an MCP-related URL and return the [`ValidatedTarget`] so the caller /// can pin the connect: the OAuth registration/discovery/token requests carry /// secrets, so they must target the validated address (see @@ -358,6 +386,16 @@ pub fn saml_ssrf_error_message(e: &SsrfValidationError) -> String { } } +pub fn webhook_ssrf_error_message(e: &SsrfValidationError) -> String { + match e { + SsrfValidationError::Private { .. } => format!( + "{e}. If you need to use private/internal webhook URLs, \ + set the {ALLOW_PRIVATE_WEBHOOK_URLS_ENV}=true environment variable" + ), + _ => e.to_string(), + } +} + fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ipv4) => is_private_ipv4(ipv4), @@ -684,6 +722,71 @@ mod tests { )); } + struct PrivateWebhookUrlsEnvGuard { + previous: Option, + } + + impl PrivateWebhookUrlsEnvGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV).ok(); + match value { + Some(value) => std::env::set_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV), + } + Self { previous } + } + } + + impl Drop for PrivateWebhookUrlsEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV), + } + } + } + + #[tokio::test] + async fn validate_webhook_url_blocks_private_by_default_with_env_hint() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateWebhookUrlsEnvGuard::set(None); + + let private_error = validate_webhook_url("http://127.0.0.1/hook") + .await + .unwrap_err(); + assert!(matches!( + private_error, + SsrfValidationError::Private { resolved: false } + )); + assert!( + webhook_ssrf_error_message(&private_error).contains("ALLOW_PRIVATE_WEBHOOK_URLS=true") + ); + + let invalid_error = validate_webhook_url("ftp://example.com/hook") + .await + .unwrap_err(); + assert!( + !webhook_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_WEBHOOK_URLS_ENV) + ); + } + + #[tokio::test] + async fn validate_webhook_url_allows_private_when_env_is_set_but_keeps_syntax_guards() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateWebhookUrlsEnvGuard::set(Some("true")); + + assert!(validate_webhook_url("http://127.0.0.1/hook").await.is_ok()); + assert!(validate_webhook_url("http://10.0.0.1/hook").await.is_ok()); + assert!(matches!( + validate_webhook_url("file:///etc/passwd").await, + Err(SsrfValidationError::DisallowedScheme(_)) + )); + assert!(matches!( + validate_webhook_url("not-a-url").await, + Err(SsrfValidationError::InvalidUrl(_)) + )); + } + #[test] fn private_git_hosts_are_refused_to_non_admins_and_on_cloud() { use GitRemoteCaller::{AdminOrSystem, NonAdmin}; diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 29fc9e99f3..b247c66532 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -245,6 +245,9 @@ lazy_static::lazy_static! { /// Reject a path the `proper_id` constraint would reject anyway, so the caller /// gets a plain 400 instead of the raw Postgres constraint-violation string, /// which names the table and constraint and echoes the input back. +/// +/// `app` and the trigger tables have no such constraint: for them this check is +/// the only authority, so loosening it lets malformed paths be stored. pub fn check_proper_path(path: &str) -> Result<()> { // The column is varchar(255); without this an over-long but well-formed path // still reaches Postgres and leaks the same kind of message back. diff --git a/backend/windmill-common/src/webhook.rs b/backend/windmill-common/src/webhook.rs index 02d6f7ddc5..2f0d83f7e2 100644 --- a/backend/windmill-common/src/webhook.rs +++ b/backend/windmill-common/src/webhook.rs @@ -164,7 +164,10 @@ impl WebhookShared { reqwest::Client::builder() .connect_timeout(Duration::from_secs(5)) // TODO: investigate pool timeouts and such if TCP load is high - .timeout(Duration::from_secs(5)), + .timeout(Duration::from_secs(5)) + // The workspace webhook URL is SSRF-checked when saved; a redirect + // would send the POST past that check. + .redirect(reqwest::redirect::Policy::none()), ) .build() .unwrap(); diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index c742a3efcb..ad0a5068e8 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -41,6 +41,8 @@ use crate::{ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct CustomTags { pub global: Vec, + /// Keyed by the entry's name, which may hold placeholders like a global entry: only a name + /// without any can be looked up by the tag it admits, the others through [`custom_tag_matches`]. pub specific: HashMap, } @@ -83,20 +85,7 @@ impl CustomTags { } else { self.specific .iter() - .map(|(tag, tag_data)| { - let separator = tag_data.tag_type.corresponding_separator(); - let mut workspaces = tag_data - .workspaces - .iter() - .map(|w| w.to_string()) - .collect::>() - .join(&*separator.to_string()); - if tag_data.tag_type == SpecificTagType::AllExcluding { - // the AllExcluding tag syntax has a leading separator - workspaces.insert(0, separator); - } - format!("{}({})", tag, workspaces) - }) + .map(|(tag, tag_data)| tag_data.authored(tag)) .collect::>() }; let all_tags = self.global.clone(); @@ -104,6 +93,84 @@ impl CustomTags { } } +/// Whether a job whose tag resolved to `tag` falls under the custom tag `entry`. An entry holding +/// placeholders is a pattern over resolved tags: `$args[...]` and `$flow_expr[...]` match any +/// text, since whoever pushes the job picks their values, and `$workspace` matches +/// `tag_workspace`, what the job's own `$workspace` resolves to. The text around them must match +/// as written: it is what confines `gpu-$args[size]` to the `gpu-` tags. +/// +/// Placeholders whose values are tied, a repeat or one reading inside another, make an entry no +/// wildcard pattern describes (`t-$args[id]-$args[id]` never resolves to `t-a-b`). Such an entry +/// matches nothing here: it admits only a job whose tag is written exactly as the entry is. +pub fn custom_tag_matches(entry: &str, tag: &str, tag_workspace: &str) -> bool { + if !entry.contains('$') { + return entry == tag; + } + let dynamic: Vec<&str> = CUSTOM_TAG_PLACEHOLDER + .find_iter(entry) + .map(|m| m.as_str()) + .filter(|p| *p != "$workspace") + .collect(); + let tied = dynamic + .iter() + .enumerate() + .any(|(i, a)| dynamic[i + 1..].iter().any(|b| placeholders_tied(a, b))); + if tied { + return false; + } + // The literal runs between wildcards, with `$workspace` substituted. + let mut pieces = vec![]; + let mut current = String::new(); + let mut last_end = 0; + for m in CUSTOM_TAG_PLACEHOLDER.find_iter(entry) { + current.push_str(&entry[last_end..m.start()]); + if m.as_str() == "$workspace" { + current.push_str(tag_workspace); + } else { + pieces.push(std::mem::take(&mut current)); + } + last_end = m.end(); + } + current.push_str(&entry[last_end..]); + pieces.push(current); + + let [first, rest @ ..] = pieces.as_slice() else { + return false; + }; + let Some((last, middle)) = rest.split_last() else { + return tag == first; + }; + let Some(mut inner) = tag + .strip_prefix(first.as_str()) + .and_then(|t| t.strip_suffix(last.as_str())) + else { + return false; + }; + for piece in middle { + match inner.find(piece.as_str()) { + Some(i) => inner = &inner[i + piece.len()..], + None => return false, + } + } + true +} + +/// Whether two `$args[...]` / `$flow_expr[...]` placeholders read the same value, or one reads a +/// value inside the other's. +fn placeholders_tied(a: &str, b: &str) -> bool { + let (Some((kind_a, path_a)), Some((kind_b, path_b))) = (a.split_once('['), b.split_once('[')) + else { + return false; + }; + let (path_a, path_b) = (path_a.trim_end_matches(']'), path_b.trim_end_matches(']')); + let inside = |outer: &str, inner: &str| { + inner + .strip_prefix(outer) + .is_some_and(|rest| rest.starts_with('.')) + }; + kind_a == kind_b && (path_a == path_b || inside(path_a, path_b) || inside(path_b, path_a)) +} + /// Marker suffixed to a workspace id inside a custom tag's scope (`mytag(prod*)`) to extend the /// entry to that workspace's forks. `*` cannot appear in a workspace id (the `proper_id` check /// constraint restricts them to `^\w+(-\w+)*$`), so it can never collide with a real id. @@ -183,6 +250,22 @@ impl SpecificTagData { pub fn is_fork_scoped(&self) -> bool { self.workspaces.iter().any(|w| w.include_forks) } + + /// The entry named `name` with this scope, as written in the custom tags: `tag(ws1+ws2)`. + pub fn authored(&self, name: &str) -> String { + let separator = self.tag_type.corresponding_separator(); + let mut workspaces = self + .workspaces + .iter() + .map(|w| w.to_string()) + .collect::>() + .join(&*separator.to_string()); + if self.tag_type == SpecificTagType::AllExcluding { + // the AllExcluding tag syntax has a leading separator + workspaces.insert(0, separator); + } + format!("{}({})", name, workspaces) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum SpecificTagType { @@ -464,7 +547,9 @@ lazy_static::lazy_static! { - // ^([\w-]+) # Group 1: tag name + // ^( # Group 1: tag name, a pattern when it holds placeholders + // (?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+ + // ) // \( # Literal '(' // ( # Group 2: the full workspace list // (?:[\w-]+\*?\+)*[\w-]+\*? # NoneExcept pattern: ws1+ws2* @@ -473,8 +558,12 @@ lazy_static::lazy_static! { // ) // \)$ # Closing ')' // - // The optional `*` after each workspace id is the fork marker, see [`WorkspaceMatcher`]. - static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^([\w-]+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap(); + // The placeholders are CUSTOM_TAG_PLACEHOLDER's. The optional `*` after each workspace id is + // the fork marker, see [`WorkspaceMatcher`]. + static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^((?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap(); + + // The placeholders a job's tag is resolved from when it is pushed, see [`custom_tag_matches`]. + static ref CUSTOM_TAG_PLACEHOLDER: Regex = Regex::new(r"\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\]").unwrap(); pub static ref DISABLE_BUNDLING: bool = std::env::var("DISABLE_BUNDLING") .ok() @@ -3070,6 +3159,92 @@ mod tests { assert_eq!(result, vec!["foo", "legacy(^ws1^ws2)", "urgent(ws1+ws2)"]); } + #[test] + fn test_custom_tag_matches_resolved_tags() { + let matches = |entry, tag| custom_tag_matches(entry, tag, "ws1"); + + assert!(matches("gpu", "gpu")); + assert!(!matches("gpu", "gpu-large")); + + // The text around a placeholder fences what its value can make of the tag. + assert!(matches("gpu-$args[size]", "gpu-large")); + assert!(matches("gpu-$flow_expr[results.a.size]", "gpu-")); + assert!(!matches("gpu-$args[size]", "prod")); + assert!(!matches("gpu-$args[size]", "xgpu-large")); + assert!(matches("$args[region]-gpu", "eu-gpu")); + assert!(!matches("$args[region]-gpu", "eu-gpu-x")); + assert!(matches("a-$args[x]-b-$args[y]-c", "a-1-b-2-c")); + assert!(!matches("a-$args[x]-b-$args[y]-c", "a-1-c")); + assert!(!matches("ab$args[x]ba", "aba")); + + // A bare placeholder admits every tag. + assert!(matches("$flow_expr[results.a.tag]", "anything")); + + // Tied placeholders are no pattern: their entry admits only its own text. + assert!(!matches("t-$args[id]-$args[id]", "t-a-a")); + assert!(!matches("t-$args[a]-$args[a.b]", "t-x-y")); + assert!(matches("t-$args[a.x]-$args[a.y]", "t-x-y")); + assert!(matches("t-$args[id]-$flow_expr[flow_input.id]", "t-x-y")); + + // `$workspace` stands for the job's own workspace only. + assert!(matches("tag-$workspace", "tag-ws1")); + assert!(!matches("tag-$workspace", "tag-ws2")); + assert!(matches("$workspace-$args[size]", "ws1-large")); + assert!(!matches("$workspace-$args[size]", "ws2-large")); + } + + #[test] + fn test_scoped_custom_tag_patterns_parse_and_round_trip() { + let input = vec![ + "gpu-$args[size](ws1+ws2)".to_string(), + "cpu-$flow_expr[results.a.size](^ws1)".to_string(), + "$workspace-$args[x](prod*)".to_string(), + "t-$args[id]-$args[id](ws1)".to_string(), + ]; + let tags = CustomTags::from(input.clone()); + + assert!(tags.global.is_empty()); + assert_eq!( + tags.specific["gpu-$args[size]"], + SpecificTagData { + tag_type: SpecificTagType::NoneExcept, + workspaces: vec![matcher("ws1"), matcher("ws2")], + } + ); + assert_eq!( + tags.specific["cpu-$flow_expr[results.a.size]"], + SpecificTagData { + tag_type: SpecificTagType::AllExcluding, + workspaces: vec![matcher("ws1")], + } + ); + assert!(tags.specific["$workspace-$args[x]"].is_fork_scoped()); + assert!(tags.specific.contains_key("t-$args[id]-$args[id]")); + + let mut result = tags.to_string_vec(None); + result.sort(); + let mut expected = input; + expected.sort(); + assert_eq!(result, expected); + + let mut for_ws1 = tags.to_string_vec(Some(&chain(&["ws1"]))); + for_ws1.sort(); + assert_eq!(for_ws1, vec!["gpu-$args[size]", "t-$args[id]-$args[id]"]); + + // Only the placeholders a tag resolves from make a name a pattern: anything else is no + // scoped entry at all. + for literal in [ + "gpu-$foo(ws1)", + "gpu-$args[](ws1)", + "gpu-$args[a.](ws1)", + "gpu.x(ws1)", + ] { + let tags = CustomTags::from(vec![literal.to_string()]); + assert_eq!(tags.global, vec![literal]); + assert!(tags.specific.is_empty()); + } + } + #[test] fn test_fork_marker_parses_and_round_trips() { let tags = CustomTags::from(vec![ diff --git a/backend/windmill-common/tests/runnable_list_fingerprint.rs b/backend/windmill-common/tests/runnable_list_fingerprint.rs new file mode 100644 index 0000000000..d7438f0008 --- /dev/null +++ b/backend/windmill-common/tests/runnable_list_fingerprint.rs @@ -0,0 +1,54 @@ +use sqlx::{Pool, Postgres}; +use windmill_common::runnables::runnable_list_fingerprint; + +/// MCP clients refresh their tool list when this fingerprint moves, so it must move on every +/// change to what the listing shows and stay put on writes it does not show. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn fingerprint_tracks_the_live_runnable_list(db: Pool) { + let fp = || async { + runnable_list_fingerprint(&db, "test-workspace") + .await + .unwrap() + }; + let exec = |sql: &'static str| { + let db = db.clone(); + async move { sqlx::query(sql).execute(&db).await.unwrap() } + }; + + let mut last = fp().await; + let mut assert_moved = async |what: &str, moved: bool| { + let now = fp().await; + assert_eq!(now != last, moved, "{what}"); + last = now; + }; + + exec( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind) + VALUES ('test-workspace', 1, 'f/test/a', '', '', 'def main(): pass', 'test-user', 'python3', 'script')", + ) + .await; + assert_moved("deploy", true).await; + + exec("UPDATE script SET lock = '' WHERE hash = 1").await; + assert_moved("lock update", false).await; + + exec("UPDATE script SET path = 'f/test/b' WHERE hash = 1").await; + assert_moved("path move", true).await; + + exec("UPDATE script SET archived = true WHERE hash = 1").await; + assert_moved("archive", true).await; + + exec( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, versions) + VALUES ('test-workspace', 'f/test/flow', '', '', '{}'::jsonb, 'test-user', '{}'::json, ARRAY[1]::bigint[])", + ) + .await; + assert_moved("flow create", true).await; + + exec("UPDATE flow SET versions = array_append(versions, 2::bigint) WHERE path = 'f/test/flow'") + .await; + assert_moved("flow redeploy", true).await; + + exec("DELETE FROM flow WHERE path = 'f/test/flow'").await; + assert_moved("flow delete", true).await; +} diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 7ca1eb08a1..4801bfaa99 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -208,6 +208,11 @@ pub trait McpBackend: Send + Sync + Clone + 'static { workspace_id: &str, ) -> BackendResult; + /// An opaque value that changes whenever the workspace's live scripts or flows are + /// added, removed, moved or redeployed, compared across polls to detect tool-list + /// changes. + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult; + // ───────────────────────────────────────────────────────────────── // Endpoint Tools // ───────────────────────────────────────────────────────────────── diff --git a/backend/windmill-mcp/src/server/list_changes.rs b/backend/windmill-mcp/src/server/list_changes.rs new file mode 100644 index 0000000000..8470cff630 --- /dev/null +++ b/backend/windmill-mcp/src/server/list_changes.rs @@ -0,0 +1,104 @@ +//! Detects changes to a workspace's scripts and flows for `subscriptions/listen`. +//! +//! Each process polls a fingerprint of every workspace one of its listeners watches, +//! so a change made on any replica, through any path (UI, CLI, git sync, a user +//! rename), reaches every listener within one poll interval with no cross-replica +//! signalling. + +use crate::server::backend::McpBackend; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, Notify}; + +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +struct Watcher { + changes: broadcast::Sender<()>, + poll_now: Arc, +} + +#[derive(Default)] +pub(crate) struct ListChanges { + watchers: Mutex>, +} + +impl ListChanges { + /// Subscribe to `workspace_id`'s changes, starting its poller if this is the + /// first listener for it in this process. + pub(crate) fn subscribe( + self: &Arc, + backend: &Arc, + workspace_id: &str, + ) -> broadcast::Receiver<()> { + let mut watchers = self.watchers.lock().unwrap(); + if let Some(watcher) = watchers.get(workspace_id) { + return watcher.changes.subscribe(); + } + let (changes, rx) = broadcast::channel(16); + let poll_now = Arc::new(Notify::new()); + watchers.insert( + workspace_id.to_string(), + Watcher { changes: changes.clone(), poll_now: poll_now.clone() }, + ); + tokio::spawn(self.clone().poll( + backend.clone(), + workspace_id.to_string(), + changes, + poll_now, + )); + rx + } + + /// Poll `workspace_id` now rather than at its next interval, after this process + /// changed it: the client that made the change is the one waiting on it. Polling + /// rather than signalling directly keeps the poller's baseline current, so the + /// same change is not reported again on the next tick. + pub(crate) fn poll_now(&self, workspace_id: &str) { + if let Some(watcher) = self.watchers.lock().unwrap().get(workspace_id) { + watcher.poll_now.notify_one(); + } + } + + async fn poll( + self: Arc, + backend: Arc, + workspace_id: String, + changes: broadcast::Sender<()>, + poll_now: Arc, + ) { + let mut interval = tokio::time::interval(POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last = None; + loop { + tokio::select! { + _ = interval.tick() => {} + _ = poll_now.notified() => {} + } + { + // Checked under the lock `subscribe` takes, so a listener cannot join a + // watcher that is about to stop. + let mut watchers = self.watchers.lock().unwrap(); + if changes.receiver_count() == 0 { + watchers.remove(&workspace_id); + return; + } + } + match backend.runnable_list_fingerprint(&workspace_id).await { + Ok(fingerprint) => { + // The first fingerprint also notifies: the listener that started this + // watcher has already sent its opening notification, and a change landing + // before this baseline would otherwise never be announced. + if last.as_ref() != Some(&fingerprint) { + let _ = changes.send(()); + } + last = Some(fingerprint); + } + Err(e) => tracing::warn!( + "MCP tool-list poll failed for workspace {workspace_id}: {}", + e.message + ), + } + } + } +} diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index b97e374e98..ad7c14d320 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -7,6 +7,7 @@ pub mod backend; pub mod endpoints; +mod list_changes; pub mod runner; pub mod tools; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index e27eaa8470..177989a82e 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -16,20 +16,22 @@ use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter}; use crate::server::endpoints::{ endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool, }; +use crate::server::list_changes::ListChanges; use crate::server::tools::create_tool_from_item; use rmcp::handler::server::ServerHandler; use rmcp::model::{ CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, - ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, - ServerCapabilities, ServerInfo, + Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, + ListResourceTemplatesResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ProtocolVersion, ServerCapabilities, ServerInfo, SubscriptionFilter, }; -use rmcp::service::{RequestContext, RoleServer}; +use rmcp::service::{RequestContext, RoleServer, SubscriptionContext}; use rmcp::ErrorData; use serde_json::Value; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use tokio::sync::broadcast; /// Protocol revisions this server is willing to speak. `2026-07-28` is served /// statelessly with per-request metadata; the older revisions keep the @@ -46,15 +48,21 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ /// leaves them unset, and a strict client rejects the response without them. /// /// Zero because nothing here is cacheable: the listing is rebuilt from the -/// workspace's scripts and flows, which change at any time, and this server -/// advertises no `listChanged` capability, so a client that cached a stale list -/// would have no way to learn it had gone stale. +/// workspace's scripts and flows, which change at any time, and `listChanged` +/// covers only some of what the listing depends on (not resources, favorites or +/// permissions), lags by up to a poll interval, and reaches only clients that hold a +/// `subscriptions/listen` stream. const LIST_TTL_MS: u64 = 0; + /// Every listing is filtered by the caller's token scopes and workspace /// membership, so no two callers necessarily see the same tools — a shared /// cache entry would leak one token's view to another. const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; +/// How long a `subscriptions/listen` stream waits after a change before notifying, +/// collecting the rest of the burst into the same notification. +const LIST_CHANGE_COALESCE: std::time::Duration = std::time::Duration::from_millis(500); + // Re-export from http crate for extracting request parts use http::request::Parts as HttpParts; @@ -83,11 +91,12 @@ fn truncate_tool_result(text: String) -> String { /// to perform the actual operations (database queries, job execution, etc.) pub struct Runner { backend: Arc, + list_changes: Arc, } impl Clone for Runner { fn clone(&self) -> Self { - Self { backend: self.backend.clone() } + Self { backend: self.backend.clone(), list_changes: self.list_changes.clone() } } } @@ -113,7 +122,7 @@ struct McpContext { impl Runner { /// Create a new Runner with the given backend pub fn new(backend: B) -> Self { - Self { backend: Arc::new(backend) } + Self { backend: Arc::new(backend), list_changes: Arc::default() } } /// Extract authentication, the workspace mode and the HTTP request itself @@ -232,6 +241,22 @@ fn endpoint_path_policy(endpoint_name: &str) -> Option { } } +/// Endpoint tools that change which scripts/flows a workspace exposes, or their +/// schemas. The process serving the call polls the workspace right away rather than +/// at its next interval, so a client that just deployed a script can call it. +fn changes_runnable_list(endpoint_name: &str) -> bool { + matches!( + endpoint_name, + "createScript" + | "updateScript" + | "deleteScriptByPath" + | "deleteScriptByHash" + | "createFlow" + | "updateFlow" + | "deleteFlowByPath" + ) +} + /// Whether the token restricts `kind` ("script"/"flow") to specific paths. A /// `*` pattern grants every path (see `is_resource_allowed`), so it does not /// count as confinement. @@ -428,12 +453,17 @@ impl ServerHandler for Runner { .with_title("Windmill") .with_website_url("https://windmill.dev"); - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(server_info) - .with_instructions( - "This server provides a list of scripts and flows the user can run on Windmill. \ + InitializeResult::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + .with_server_info(server_info) + .with_instructions( + "This server provides a list of scripts and flows the user can run on Windmill. \ Each flow and script is a tool callable with their respective arguments.", - ) + ) } /// Pinned rather than left to rmcp's default (every version the SDK knows), so a @@ -444,6 +474,84 @@ impl ServerHandler for Runner { Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) } + /// Only clients below `2026-07-28` send `initialize`, and those receive server + /// notifications on a session's standalone stream. The transport is sessionless, so + /// there is none: `listChanged` is withheld from them rather than promised and + /// never delivered. `2026-07-28` clients learn it from `server/discover` and + /// receive it on `subscriptions/listen` (see `listen`). + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + context.peer.set_peer_info(request.clone()); + let mut info = self.get_info(); + if let Some(tools) = info.capabilities.tools.as_mut() { + tools.list_changed = None; + } + if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) { + info.protocol_version = request.protocol_version; + } + Ok(info) + } + + /// `None` for a request that asks for nothing this server sends, so the client is + /// told the subscription is unsupported rather than acked with an empty filter. + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + (requested.tools_list_changed == Some(true)) + .then(|| SubscriptionFilter::builder().tools_list_changed().build()) + } + + async fn listen(&self, subscription: SubscriptionContext) -> Result<(), ErrorData> { + let McpContext { auth, mode, .. } = Self::extract_context(subscription.request_context())?; + // Multi-workspace mode and read-only tokens list no scripts or flows, so their + // tool list never changes. + let workspace_id = match mode { + McpMode::Single(workspace_id) if !auth.read_only() => workspace_id, + _ => { + subscription.cancelled().await; + return Ok(()); + } + }; + + let mut changes = self.list_changes.subscribe(&self.backend, &workspace_id); + // Every stream opens with one notification: a change the watcher recorded between + // this client's `tools/list` and this subscription is never broadcast again. + let mut opening = true; + loop { + if !std::mem::take(&mut opening) { + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + change = changes.recv() => match change { + Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return Ok(()), + }, + } + } + // A client creating several scripts in a row: collapse the burst into one + // notification, so it re-lists once. + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + _ = tokio::time::sleep(LIST_CHANGE_COALESCE) => {} + } + while !matches!( + changes.try_recv(), + Err(broadcast::error::TryRecvError::Empty | broadcast::error::TryRecvError::Closed) + ) {} + if subscription + .sink() + .notify_tool_list_changed() + .await + .is_err() + { + return Ok(()); + } + } + } + async fn list_tools( &self, _request: Option, @@ -714,6 +822,9 @@ impl Runner { .backend .call_endpoint(auth, workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(workspace_id); + } return Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( @@ -969,6 +1080,9 @@ impl Runner { .backend .call_endpoint(&resolved_auth, &workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(&workspace_id); + } Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 27a50ba07a..2c785a4e26 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -16,7 +16,8 @@ use serde::{Deserialize, Serialize}; use sqlx::PgConnection; use std::sync::Arc; use windmill_api_auth::{ - check_scopes, create_token_internal, require_is_writer, ApiAuthed, NewToken, + build_scope_path_filter, check_scopes, create_token_internal, require_is_writer, ApiAuthed, + NewToken, }; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::{ @@ -701,6 +702,7 @@ async fn list_native_triggers_handler( query.per_page, query.path.as_deref(), query.is_flow, + build_scope_path_filter(&authed, "native_triggers", "read"), ) .await?; tx.commit().await?; diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 570319ee0e..6398e8a6d9 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -56,7 +56,7 @@ use windmill_queue::PushArgsOwned; #[cfg(feature = "native_trigger")] use windmill_oauth::{ErrorField, ExecuteError, OClient, RefreshToken, Url, OAUTH_HTTP_CLIENT}; -use windmill_api_auth::ApiAuthed; +use windmill_api_auth::{ApiAuthed, ScopePathFilter}; pub mod handler; pub(crate) mod lock; pub mod sync; @@ -1493,9 +1493,17 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> per_page: Option, path: Option<&str>, is_flow: Option, + scope: ScopePathFilter, ) -> Result> { let offset = (page.unwrap_or(0) * per_page.unwrap_or(100)) as i64; let limit = per_page.unwrap_or(100) as i64; + // In the WHERE, not a retain after the fetch: the result is paginated, and a + // post-fetch filter would let a page's size report how many rows the token + // may not read. + let (scope_all, scope_exact, scope_prefix) = match scope { + ScopePathFilter::AllowAll => (true, Vec::new(), Vec::new()), + ScopePathFilter::Restricted { exact, prefix } => (false, exact, prefix), + }; let triggers = sqlx::query_as!( NativeTrigger, @@ -1532,7 +1540,12 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> WHERE f.workspace_id = nt.workspace_id AND f.path = nt.script_path )) - ) + ) AND + ( $7 + OR nt.script_path = ANY($8) + OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx + WHERE nt.script_path = pfx + OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) ) LIMIT $3 OFFSET $4 "#, @@ -1541,7 +1554,10 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> limit, offset, path, - is_flow + is_flow, + scope_all, + &scope_exact[..], + &scope_prefix[..], ) .fetch_all(db) .await?; diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index bc259f048e..4f75845429 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -214,8 +214,17 @@ pub async fn sync_workspace_triggers( workspace_id ); - let windmill_triggers = - list_native_triggers(db, workspace_id, T::SERVICE_NAME, None, None, None, None).await?; + let windmill_triggers = list_native_triggers( + db, + workspace_id, + T::SERVICE_NAME, + None, + None, + None, + None, + windmill_api_auth::ScopePathFilter::AllowAll, + ) + .await?; if windmill_triggers.is_empty() { tracing::info!( diff --git a/backend/windmill-queue/src/flow_status.rs b/backend/windmill-queue/src/flow_status.rs index e6b64c659d..55d2f6ec90 100644 --- a/backend/windmill-queue/src/flow_status.rs +++ b/backend/windmill-queue/src/flow_status.rs @@ -15,6 +15,9 @@ pub async fn update_flow_status_in_progress( let step = get_step_of_flow_status(db, flow).await?; match step { Step::Step { idx: step, .. } => { + // Children of a parallel module start concurrently and are tracked in `flow_jobs`, so its + // `job` would only hold whichever child started last. Writing it rewrites the parent's + // whole flow_status under its row lock once per child, serializing large parallel loops. sqlx::query!( "UPDATE v2_job_status SET flow_status = jsonb_set( @@ -22,7 +25,11 @@ pub async fn update_flow_status_in_progress( ARRAY['modules', $3::INTEGER::TEXT, 'type'], to_jsonb('InProgress'::text) ) - WHERE id = $2", + WHERE id = $2 + AND NOT COALESCE( + flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}', + false + )", job_in_progress, flow, step as i32 diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 429701f7e0..75733f2454 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1007,7 +1007,7 @@ pub async fn add_completed_job( )); } - // Native script retry: a failed `Script` job that carries a retry policy and + // Native script retry: a failed `Script` or `Script_Hub` job that carries a retry policy and // has attempts left gets its next attempt enqueued here — before the queue // row (which holds the attempt counter) is removed by commit. The failed // attempt is still recorded as a completed job below. `maybe_enqueue_…` @@ -1077,9 +1077,9 @@ pub async fn add_completed_job( // Auto-resolve a retry chain that ultimately worked, from whichever of the two // completions lands last (see resolve_retry_chain_if_succeeded): a success that has a // parent (so is a possible retry attempt), or a failure that just enqueued a retry. - // `retry_pending` already implies a non-flow-step `Script`. + // `retry_pending` already implies a non-flow-step `Script` or `Script_Hub`. let resolve_root = if success && !skipped && !completed_job.is_flow_step() { - matches!(completed_job.kind, JobKind::Script) + matches!(completed_job.kind, JobKind::Script | JobKind::Script_Hub) .then(|| completed_job.parent_job) .flatten() } else if !success && !skipped && retry_pending { @@ -1285,7 +1285,25 @@ async fn commit_completed_job( } let mut _skip_downstream_error_handlers = false; - tx = delete_job(tx, &job_id).warn_after_seconds(10).await?; + let (ntx, canceled_at_delete) = delete_job(tx, &job_id).warn_after_seconds(10).await?; + tx = ntx; + // `canceled_by` is only what the worker last read from the queue row. Deleting the row waits + // for a cancel still being written, so the row it removed is the final word on whether this + // job was canceled. + if canceled_by.is_none() { + if let Some(canceled) = canceled_at_delete { + sqlx::query!( + "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, \ + canceled_reason = $3 WHERE id = $1", + job_id, + canceled.username, + canceled.reason, + ) + .execute(&mut *tx) + .warn_after_seconds(10) + .await?; + } + } // tracing::error!("3 {:?}", start.elapsed()); if completed_job.is_flow_step() { @@ -1611,6 +1629,22 @@ async fn restart_job_if_perpetual_inner( }; if restart { + // Not `canceled_by`: a worker reads the queue row on a widening interval, up to every 5s + // once a job has run for a minute, so a cancel landing after its last read reaches a + // completion carrying none. `commit_completed_job` records it from the queue row it + // deletes, so the completed row is what says whether this loop was stopped. + let canceled = sqlx::query_scalar!( + "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed \ + WHERE id = $1 AND workspace_id = $2", + queued_job.id, + &queued_job.workspace_id + ) + .fetch_optional(db) + .await? + .unwrap_or(false); + if canceled { + return Ok(()); + } let tx = PushIsolationLevel::IsolatedRoot(db.clone()); // perpetual jobs can run one job per 10s max. If the job was faster than 10s, schedule the next one with the appropriate delay @@ -1809,10 +1843,10 @@ async fn eval_retry_if( false } -/// Native script retry. When a failed `Script` job carries a retry policy (via -/// `runnable_settings_handle`) and has attempts left, enqueue a fresh attempt of -/// the same script after the policy's backoff delay — instead of having wrapped -/// it in a one-step flow. Each attempt is a real `Script` job; the attempt +/// Native script retry. When a failed `Script` or `Script_Hub` job carries a retry +/// policy (via `runnable_settings_handle`) and has attempts left, enqueue a fresh +/// attempt of the same script after the policy's backoff delay — instead of having +/// wrapped it in a one-step flow. Each attempt is a job of the same kind; the attempt /// counter lives in the `native_retry_attempt` marker, written here and read only /// on the next failure (never on the hot job-pull path). /// @@ -1833,7 +1867,10 @@ pub async fn maybe_enqueue_native_script_retry( result_fn: &(dyn Fn() -> Option> + Sync), ) -> Result { // Only plain top-level scripts retry natively; cancellation always wins. - if canceled_by.is_some() || !matches!(job.kind, JobKind::Script) || job.is_flow_step() { + if canceled_by.is_some() + || !matches!(job.kind, JobKind::Script | JobKind::Script_Hub) + || job.is_flow_step() + { return Ok(false); } @@ -4696,6 +4733,72 @@ pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> Strin } } +/// The queue an explicit `tag` sends a job pushed with `args` to, or `None` when `push` drops the +/// tag and the job runs on its default one. +pub async fn resolve_push_tag( + tag: &str, + args: &PushArgs<'_>, + workspace_id: &str, + db: &DB, +) -> Option { + // The flow runtime resolves a step's `$flow_expr[...]` before pushing it, so one still here + // was pushed with no flow state to read (a step test, a dependency job) and would name a + // queue no worker serves: the job runs on its default tag instead. + if tag.is_empty() || tag_reads_flow_expr(tag) { + return None; + } + // `$workspace` must resolve the same way the default tags do, or an explicit tag and a default + // tag from the same workspace address two different worker pools. Resolving costs a lookup, + // so pay it only for tags that actually interpolate `$workspace`. + let tag_ws = if tag.contains("$workspace") { + crate::tags::tag_workspace_id(workspace_id, db).await + } else { + workspace_id.to_string() + }; + Some(interpolate_args(tag.to_string(), args, &tag_ws)) +} + +/// Refuses a `tag` the caller chose that the instance's custom tags do not let `w_id` use, +/// judging the queue it resolves to. `args` must be the ones the job is pushed with: resolving +/// with any others checks a queue the job does not land on. +pub async fn check_tag_available_for_push( + db: &DB, + w_id: &str, + tag: &str, + args: &PushArgs<'_>, + is_super_admin: bool, + scope_tags: Option>, +) -> Result<(), Error> { + check_tag_written_as_available_for_push(db, w_id, tag, tag, args, is_super_admin, scope_tags) + .await +} + +/// [`check_tag_available_for_push`] for a `tag` the flow runtime already partly resolved from +/// `written_tag`, the step's tag as its author wrote it. +pub async fn check_tag_written_as_available_for_push( + db: &DB, + w_id: &str, + written_tag: &str, + tag: &str, + args: &PushArgs<'_>, + is_super_admin: bool, + scope_tags: Option>, +) -> Result<(), Error> { + let Some(resolved_tag) = resolve_push_tag(tag, args, w_id, db).await else { + return Ok(()); + }; + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + written_tag, + Some(&resolved_tag), + crate::tags::tag_workspace_id(w_id, db), + is_super_admin, + scope_tags, + ) + .await +} + pub fn fullpath_with_workspace( workspace_id: &str, script_path: Option<&String>, @@ -5087,34 +5190,44 @@ async fn extract_result_from_job_result( } } +/// Also reports the cancellation the deleted row carried, if any. Unlike a plain read of the queue +/// row, this waits for a cancel that is still being written, so it is the last word on one. pub async fn delete_job<'c>( mut tx: Transaction<'c, Postgres>, job_id: &Uuid, -) -> windmill_common::error::Result> { +) -> windmill_common::error::Result<(Transaction<'c, Postgres>, Option)> { #[cfg(feature = "prometheus")] if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { QUEUE_DELETE_COUNT.inc(); } otel_incr_queue_delete_count(); - let job_removed = - sqlx::query_scalar!("DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1", job_id,) - .fetch_optional(&mut *tx) - .await; + let job_removed = sqlx::query!( + "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason", + job_id, + ) + .fetch_optional(&mut *tx) + .await; - if let Err(job_removed) = job_removed { - tracing::error!( - "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" - ); - } else { - let job_removed = job_removed.unwrap().flatten().unwrap_or(0); - if job_removed != 1 { - tracing::error!("Job {job_id} could not be deleted, returned not 1: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); + let canceled = match &job_removed { + Err(job_removed) => { + tracing::error!( + "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" + ); + None } - } + Ok(None) => { + tracing::error!("Job {job_id} could not be deleted, no row was removed. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); + None + } + Ok(Some(row)) => row.canceled_by.as_ref().map(|username| CanceledBy { + username: Some(username.clone()), + reason: row.canceled_reason.clone(), + }), + }; tracing::debug!("Job {job_id} deleted"); - Ok(tx) + Ok((tx, canceled)) } pub async fn job_is_complete(db: &DB, id: Uuid, w_id: &str) -> error::Result { @@ -5744,10 +5857,10 @@ async fn push_inner<'c, 'd>( dedicated_worker, ..Default::default() }, - JobPayload::FlowNode { id, path } => { + JobPayload::FlowNode { id, path, no_inherited_flow_env } => { let data = cache::flow::fetch_flow(db, id).await?; let value = data.value(); - let status = Some(FlowStatus::new(value)); + let status = Some(FlowStatus { no_inherited_flow_env, ..FlowStatus::new(value) }); // Keep inserting `value` if not all workers are updated. // Starting at `v1.440`, the value is fetched on pull from the flow node id. let value_o = if !MIN_VERSION_IS_AT_LEAST_1_440.met().await { @@ -5984,6 +6097,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, } } _ => { @@ -6043,10 +6157,12 @@ async fn push_inner<'c, 'd>( // `quickjs` feature it cannot be evaluated and fails closed (no retry); // the flow path is not a fallback, since the flow runtime needs quickjs // too. + // A hub script has no hash and runs as a `Script_Hub` job. + let is_hub = hash.is_none() && path.starts_with("hub/"); let native_retry = !is_flow && skip_handler.is_none() && error_handler_path.is_none() - && hash.is_some() + && (hash.is_some() || is_hub) && language.is_some() && windmill_common::runnable_settings::min_version_supports_runnable_settings_v0() .await; @@ -6076,7 +6192,11 @@ async fn push_inner<'c, 'd>( break 'ssf JobPayloadUntagged { runnable_id: hash.map(|h| h.0), runnable_path: Some(path), - job_kind: JobKind::Script, + job_kind: if is_hub { + JobKind::Script_Hub + } else { + JobKind::Script + }, language, dedicated_worker, concurrency_settings, @@ -6378,6 +6498,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, }; let value = flow_data.value(); let priority = value.priority; @@ -6535,23 +6656,9 @@ async fn push_inner<'c, 'd>( ); windmill_common::worker::dedicated_worker_tag(workspace_id, &full_path) } else { - // The flow runtime resolves a step's `$flow_expr[...]` before pushing it, so one still here - // was pushed with no flow state to read (a step test, a dependency job) and would name a - // queue no worker serves: the job runs on its default tag instead. - if tag == Some("".to_string()) || tag.as_deref().is_some_and(tag_reads_flow_expr) { - tag = None; - } - - // `$workspace` must resolve the same way the default tags below do, or an explicit tag and - // a default tag from the same workspace address two different worker pools. Resolving costs - // a lookup, so pay it only for tags that actually interpolate `$workspace`. let interpolated_tag = match tag { + Some(x) => resolve_push_tag(&x, &args, workspace_id, db).await, None => None, - Some(x) if x.contains("$workspace") => { - let tag_ws = crate::tags::tag_workspace_id(&workspace_id, db).await; - Some(interpolate_args(x, &args, &tag_ws)) - } - Some(x) => Some(interpolate_args(x, &args, workspace_id)), }; let effective_ws = per_workspace_tag(&workspace_id, db).await; diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 5d5cf005fc..40c1292b74 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -6,6 +6,7 @@ * LICENSE-AGPL for a copy of the license. */ +use crate::jobs::HTTP_CLIENT; use crate::push; use crate::PushIsolationLevel; use anyhow::Context; @@ -19,13 +20,13 @@ use windmill_common::ee_oss::LICENSE_KEY_VALID; use windmill_common::flows::Retry; use windmill_common::get_flow_version_info_from_version; use windmill_common::get_latest_flow_version_id_for_path; -use windmill_common::jobs::check_tag_available_for_workspace_internal; use windmill_common::jobs::JobPayload; use windmill_common::jobs::JobTriggerKind; use windmill_common::jobs::OnBehalfOf; use windmill_common::runnable_settings::ConcurrencySettings; use windmill_common::runnable_settings::DebouncingSettings; use windmill_common::schedule::schedule_to_user; +use windmill_common::scripts::get_full_hub_script_by_path; use windmill_common::scripts::ScriptHash; use windmill_common::triggers::TriggerMetadata; use windmill_common::utils::WarnAfterExt; @@ -82,6 +83,8 @@ async fn get_schedule_metadata<'c>( Some(version), parsed_retry, )) + } else if schedule.script_path.starts_with("hub/") { + Ok((None, None, None, None, None, parsed_retry)) } else { let ( hash, @@ -323,6 +326,48 @@ pub async fn push_scheduled_job<'c>( None, on_behalf_of, ) + } else if schedule.script_path.starts_with("hub/") { + let tag = schedule.tag.clone().filter(|t| !t.is_empty()); + let payload = match &schedule.retry { + // The language is what lets `push` materialize this as a native retry + // instead of a flow wrapper, which would queue the next tick at start. + Some(retry) => JobPayload::SingleStepFlow { + path: schedule.script_path.clone(), + hash: None, + flow_version: None, + language: Some( + get_full_hub_script_by_path( + StripPath(schedule.script_path.clone()), + &HTTP_CLIENT, + Some(db), + ) + .await? + .language, + ), + retry: Some(serde_json::from_value::(retry.clone()).map_err(|e| { + error::Error::internal_err(format!( + "Unable to parse retry information from schedule: {e}" + )) + })?), + error_handler_path: None, + error_handler_args: None, + skip_handler: None, + args: args.clone(), + cache_ttl: None, + cache_ignore_s3_path: None, + priority: None, + tag_override: tag.clone(), + trigger_path: None, + apply_preprocessor: false, + concurrency_settings: ConcurrencySettings::default(), + debouncing_settings: DebouncingSettings::default(), + }, + None => JobPayload::ScriptHub { + path: schedule.script_path.clone(), + apply_preprocessor: false, + }, + }; + (payload, tag, None, None) } else { let ( hash, @@ -511,14 +556,15 @@ pub async fn push_scheduled_job<'c>( if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { let is_super_admin = windmill_common::auth::is_super_admin_email(db, &email).await?; - check_tag_available_for_workspace_internal( + crate::check_tag_available_for_push( db, &schedule.workspace_id, &tag, + &crate::PushArgs::from(&args), is_super_admin, None, // no token for schedules so no scopes so no scope_tags ) - .warn_after_seconds_with_sql(1, "check_tag_available_for_workspace_internal".to_string()) + .warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string()) .await?; } diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index 333d211765..f3ae34251e 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -1757,4 +1757,59 @@ mod schedule_push { assert_eq!(count_queued_jobs(&db).await, 1); Ok(()) } + + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn test_push_hub_script_schedule(db: Pool) -> anyhow::Result<()> { + // Seed the hub cache so the push resolves the script without the network. + let version = "990000001"; + 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 hi","lockfile":null,"language":"bash","schema":{},"summary":null}"#, + ) + .await?; + let hub_path = format!("hub/{version}/test/echo"); + + // A retry must stay a native `script_hub` job: a flow wrapper would queue the + // next tick at start and let slow runs overlap. + let retry = serde_json::json!({ "constant": { "attempts": 2, "seconds": 1 } }); + for (path, retry, dynamic_skip) in [ + ("f/system/hub_plain", None, None), + ("f/system/hub_retry", Some(retry), None), + // A skip handler needs a flow wrapper, so its metadata lookup must not + // go through the `script` table. + ("f/system/hub_skip", None, Some("f/system/skip".to_string())), + ] { + let has_retry = retry.is_some(); + let has_skip = dynamic_skip.is_some(); + let schedule = make_schedule(|s| { + s.path = path.to_string(); + s.script_path = hub_path.clone(); + s.retry = retry; + s.dynamic_skip = dynamic_skip; + }); + let tx = db.begin().await?; + let tx = push_scheduled_job(&db, tx, &schedule, Some(&make_authed()), None).await?; + tx.commit().await?; + + let (job_kind, runnable_path, language, handle) = + sqlx::query_as::<_, (String, Option, Option, Option)>( + "SELECT j.kind::text, j.runnable_path, j.script_lang::text, q.runnable_settings_handle + FROM v2_job j JOIN v2_job_queue q ON j.id = q.id WHERE j.trigger = $1", + ) + .bind(path) + .fetch_one(&db) + .await?; + assert_eq!(runnable_path.as_deref(), Some(hub_path.as_str())); + if has_skip { + assert_eq!(job_kind, "singlestepflow"); + } else { + assert_eq!(job_kind, "script_hub"); + assert_eq!(language.as_deref(), Some("bash")); + assert_eq!(handle.is_some(), has_retry); + } + } + Ok(()) + } } diff --git a/backend/windmill-trigger-http/Cargo.toml b/backend/windmill-trigger-http/Cargo.toml index 9e81e1d0dd..d776a34a00 100644 --- a/backend/windmill-trigger-http/Cargo.toml +++ b/backend/windmill-trigger-http/Cargo.toml @@ -34,6 +34,7 @@ regex.workspace = true matchit.workspace = true hyper.workspace = true futures.workspace = true +rand.workspace = true # http_trigger_auth deps base64.workspace = true diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 01ddfd4a81..539c8e43d2 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -13,6 +13,7 @@ use windmill_common::global_settings::{validate_allowed_origins, HTTP_ROUTE_WORK use windmill_common::{ db::UserDB, error::{Error, Result}, + utils::check_proper_path, worker::CLOUD_HOSTED, DB, }; @@ -269,6 +270,7 @@ pub async fn create_many_http_triggers( check_scopes(&authed, || { format!("http_triggers:write:{}", &new_http_trigger.base.path) })?; + check_proper_path(&new_http_trigger.base.path)?; // This route inserts directly, bypassing the shared create handler. // `error_wrapper` would turn the rejection into a 500. diff --git a/backend/windmill-trigger-http/src/lib.rs b/backend/windmill-trigger-http/src/lib.rs index 40bab102a5..e9dfbd405a 100644 --- a/backend/windmill-trigger-http/src/lib.rs +++ b/backend/windmill-trigger-http/src/lib.rs @@ -394,32 +394,85 @@ pub fn invalidate_routers() { HTTP_ROUTERS_INVALIDATIONS.fetch_add(1, Ordering::Relaxed); } +async fn invalidation_pending() -> bool { + HTTP_ROUTERS_INVALIDATIONS.load(Ordering::Relaxed) + != HTTP_ROUTERS_CACHE.read().await.invalidations +} + +async fn routers_loaded() -> bool { + HTTP_ROUTERS_CACHE.read().await.version != 0 +} + +/// `refresh_routers` for a process that has loaded its routers; a process that has not loads them +/// on its first `/r` request instead, so a change never makes it read `http_trigger`. Returns +/// whether the routers were rebuilt. A failure is marked for the refresh loop to retry. +pub async fn refresh_loaded_routers(db: &DB, force: bool) -> Result { + if !routers_loaded().await { + // A first load running concurrently may have read the rows before this change committed. + invalidate_routers(); + return Ok(false); + } + match refresh_routers(db, force).await { + Ok((rebuilt, _)) => Ok(rebuilt), + Err(err) => { + invalidate_routers(); + Err(err) + } + } +} + +const REFRESH_TICK: std::time::Duration = std::time::Duration::from_secs(60); +/// Trigger changes normally reach every process as a `notify_http_trigger_change` event, which +/// forces a rebuild. A process can skip one: the event poll moves past ids that committed out of +/// order, and a deleted or disabled trigger then stays routed on it until the next forced rebuild. +/// That rebuild has to be forced, since the skipped transaction took its version before the one +/// already cached, so the version gate cannot see it. +const FORCED_REBUILD_EVERY_TICKS: u32 = 5; + +/// `eager` loads the routers at startup and keeps retrying until they load. Otherwise they load +/// on the first `/r` request, and the loop only keeps them fresh from then on. pub async fn refresh_routers_loop( db: &DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>, + eager: bool, ) -> () { - match refresh_routers(db, false).await { - Ok(_) => { - tracing::info!("Loaded HTTP routers"); - } - Err(err) => { - tracing::error!("Error loading HTTP routers: {err:#}"); - } - }; + if eager { + match refresh_routers(db, false).await { + Ok(_) => { + tracing::info!("Loaded HTTP routers"); + } + Err(err) => { + tracing::error!("Error loading HTTP routers: {err:#}"); + } + }; + } let db = db.clone(); + // Spread the forced rebuilds of processes started together. + let mut tick: u32 = rand::random_range(0..FORCED_REBUILD_EVERY_TICKS); tokio::spawn(async move { loop { tokio::select! { _ = killpill_rx.recv() => { break; } - _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { - match refresh_routers(&db, false).await { - Ok((true, _)) => { + _ = tokio::time::sleep(REFRESH_TICK) => { + tick = (tick + 1) % FORCED_REBUILD_EVERY_TICKS; + let loaded = routers_loaded().await; + let force = loaded && tick == 0; + if loaded { + if !force && !invalidation_pending().await { + continue; + } + } else if !eager { + continue; + } + match refresh_routers(&db, force).await { + Ok((true, _)) if !force => { tracing::info!("Refreshed HTTP routers"); } Err(err) => { tracing::error!("Error refreshing HTTP routers: {err:#}"); + invalidate_routers(); } _ => {} } diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 66d6770354..ba40ed2c48 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -22,7 +22,7 @@ use windmill_common::{ fetch_draft_only_list_rows, overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, }, - utils::{paginate, Pagination, StripPath}, + utils::{check_proper_path, paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, }; @@ -547,6 +547,7 @@ async fn create_trigger( &new_trigger.base.path ) })?; + check_proper_path(&new_trigger.base.path)?; if *CLOUD_HOSTED && !T::IS_ALLOWED_ON_CLOUD { return Err(Error::BadRequest(format!( @@ -817,6 +818,9 @@ async fn update_trigger( &edit_trigger.base.path ) })?; + if edit_trigger.base.path != path { + check_proper_path(&edit_trigger.base.path)?; + } edit_trigger.error_handling.validate()?; diff --git a/backend/windmill-trigger/src/trigger_helpers.rs b/backend/windmill-trigger/src/trigger_helpers.rs index 6a37bd0da1..7e47613d1a 100644 --- a/backend/windmill-trigger/src/trigger_helpers.rs +++ b/backend/windmill-trigger/src/trigger_helpers.rs @@ -880,7 +880,8 @@ async fn trigger_script_with_retry_and_error_handler<'c>( delete_after_secs, ); - check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &workspace_id, &tag, &push_args, &authed).await?; let return_tx = tx_o.is_some(); @@ -907,8 +908,6 @@ async fn trigger_script_with_retry_and_error_handler<'c>( ) }; - let push_args = PushArgs { args: &args.args, extra: args.extra }; - let retryable_job_payload = match job_payload { JobPayload::ScriptHash { hash, diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 79890e3c6e..6dff8430b7 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -41,6 +41,11 @@ pub struct FlowStatus { pub chat_input_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory_id: Option, + /// Set at push on a branch/loop sub-flow whose enclosing flow had no `flow_env`, own or + /// inherited, so the worker can skip the ancestor walk for it. `false` only means unknown: + /// the worker then walks the ancestors as usual. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub no_inherited_flow_env: bool, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] @@ -568,6 +573,7 @@ impl FlowStatus { stream_job: None, chat_input_enabled: f.chat_input_enabled, memory_id: None, + no_inherited_flow_env: false, } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 5190efe380..4e0c5340f5 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -519,6 +519,8 @@ pub enum JobPayload { FlowNode { id: FlowNodeId, path: String, + /// See [`crate::flow_status::FlowStatus::no_inherited_flow_env`]. + no_inherited_flow_env: bool, }, FlowScript { id: FlowNodeId, diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 117fbda086..841ea8861a 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2689,13 +2689,13 @@ try {{ /// Resolve a module file from the parent script's modules map. /// For Script jobs, fetches from the `script` table by hash. /// For Preview jobs, fetches from `v2_job.raw_code` (modules stored inline). -fn resolve_parent_module( - modules: &Option>, +fn resolve_parent_module<'a>( + modules: &'a Option>, module_key: &str, -) -> error::Result { +) -> error::Result<&'a windmill_common::scripts::ScriptModule> { if let Some(modules) = modules { if let Some(module) = modules.get(module_key) { - return Ok(module.clone()); + return Ok(module); } } Err(error::Error::ExecutionErr(format!( @@ -2716,7 +2716,7 @@ pub async fn handle_wac_v2_output( ) -> error::Result> { use crate::wac_executor::{ load_checkpoint, parse_wac_output, update_checkpoint_for_dispatch, - wac_cancelled_mid_segment, WacOutput, WacPark, + wac_cancelled_mid_segment, WacOutput, WacPark, WacStepDispatch, }; use serde_json::Value; use windmill_common::get_latest_flow_version_info_for_path; @@ -2724,7 +2724,7 @@ pub async fn handle_wac_v2_output( use windmill_common::runnable_settings::{ ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, }; - use windmill_queue::{push, PushArgs, PushIsolationLevel}; + use windmill_queue::{check_tag_available_for_push, push, PushArgs, PushIsolationLevel}; let output = parse_wac_output(&result)?; @@ -2950,6 +2950,129 @@ pub async fn handle_wac_v2_output( ))), }?; + // Resolve what every task runs, and check any tag it picks, before the parent parks: + // a task that cannot be pushed then fails the dispatch with no sibling queued. The + // parent's code and input are built per child at push time, so a fan-out never holds + // a copy of them for each child at once. + enum ChildRunnable<'a> { + // Re-runs the parent with `_executing_key`. + Parent, + // A `./` module of the parent script. + Module(&'a windmill_common::scripts::ScriptModule), + // A deployed script or flow. + Deployed(JobPayload), + } + struct ResolvedChild<'a> { + runnable: ChildRunnable<'a>, + email: String, + permissioned_as: String, + } + // The input a task with a runnable of its own is pushed with; the parent re-run gets + // the parent's. Both the tag check and the push build it here, so the check resolves + // `$args[...]` against what the child actually receives. + let own_args = |step: &WacStepDispatch| -> HashMap> { + step.args + .iter() + .map(|(k, v)| { + let raw = serde_json::value::to_raw_value(v).unwrap(); + (k.clone(), raw) + }) + .collect() + }; + let mut children: Vec = Vec::with_capacity(num_steps); + for step in &steps { + let (runnable, on_behalf_of) = match step.dispatch_type.as_str() { + "script" if step.script.starts_with("./") => { + // Module-relative path: resolve from parent script's modules + let module_key = step.script.strip_prefix("./").unwrap(); + let module = resolve_parent_module(modules, module_key)?; + // Inline module code, not a separate runnable: it has no + // identity of its own and runs as the parent. + (ChildRunnable::Module(module), None) + } + "script" => { + // Resolve script path to job payload (handles hash, lang, etc.) + let (payload, _, _, _, _, on_behalf_of) = script_path_to_payload( + &step.script, + None, // no authed db for background workers + db.clone(), + &job.workspace_id, + Some(true), // skip preprocessor + ) + .await?; + (ChildRunnable::Deployed(payload), on_behalf_of) + } + "flow" => { + let flow_info = get_latest_flow_version_info_for_path( + None, + db, + &job.workspace_id, + &step.script, + true, + ) + .await?; + let payload = JobPayload::Flow { + path: step.script.clone(), + dedicated_worker: flow_info.dedicated_worker, + apply_preprocessor: false, + version: flow_info.version, + labels: flow_info.labels.clone(), + }; + let on_behalf_of = flow_info.on_behalf_of(&job.workspace_id, db).await?; + (ChildRunnable::Deployed(payload), on_behalf_of) + } + // "inline" — re-run parent with _executing_key + _ => (ChildRunnable::Parent, None), + }; + + // A target runnable that opts into on-behalf-of runs under its own + // identity, never the caller's, so a step that reaches it through a + // workflow cannot widen or narrow its permissions. `created_by` still + // credits the caller, matching how the run API pushes these jobs. + let (email, permissioned_as) = match on_behalf_of { + Some(on_behalf_of) => (on_behalf_of.email, on_behalf_of.permissioned_as), + None => ( + job.permissioned_as_email.clone(), + job.permissioned_as.clone(), + ), + }; + + // A task inheriting the parent's tag skips the check: that tag was + // checked when the parent was pushed, and a dedicated worker's tag is + // never in CUSTOM_TAGS. + if let Some(tag) = step + .tag + .as_deref() + .filter(|t| !t.is_empty() && *t != job.tag.as_str()) + { + let is_super_admin = + windmill_common::auth::is_super_admin_email(db, &email).await?; + let step_args = + (!matches!(runnable, ChildRunnable::Parent)).then(|| own_args(step)); + let args = + PushArgs { args: step_args.as_ref().unwrap_or(&parent_args), extra: None }; + check_tag_available_for_push( + db, + &job.workspace_id, + tag, + &args, + is_super_admin, + None, + ) + .warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string()) + .await + .map_err(|e| match e { + error::Error::BadRequest(msg) => error::Error::BadRequest(format!( + "task '{}' cannot run on tag '{tag}': {msg}", + step.name + )), + e => e, + })?; + } + + children.push(ResolvedChild { runnable, email, permissioned_as }); + } + // Step 1: Save checkpoint, suspend parent, and seed child checkpoints // in a single transaction — all BEFORE children become visible. let segment_ms; @@ -3034,106 +3157,37 @@ pub async fn handle_wac_v2_output( // partial failure (e.g. pushing child 3 of 5 fails). let mut pushed_ids: Vec = Vec::with_capacity(num_steps); let push_result: error::Result<()> = async { - for (step, (_, child_uuid)) in steps.iter().zip(job_ids.iter()) { + for ((step, (_, child_uuid)), child) in + steps.iter().zip(job_ids.iter()).zip(children) + { // A task with a runnable of its own (a deployed script or flow) queues // at that runnable's priority; any other task is the parent's code and // queues at the parent's. let own_runnable = matches!(step.dispatch_type.as_str(), "script" | "flow") && !step.script.starts_with("./"); - // Resolve job payload based on dispatch_type - let (job_payload, child_args, is_external, on_behalf_of) = - match step.dispatch_type.as_str() { - "script" if step.script.starts_with("./") => { - // Module-relative path: resolve from parent script's modules - let module_key = step.script.strip_prefix("./").unwrap(); - let module = resolve_parent_module(modules, module_key)?; - let payload = JobPayload::Code(RawCode { - content: module.content, - path: job.runnable_path.clone(), - hash: None, - language: module.language, - lock: module.lock, - cache_ttl: None, - cache_ignore_s3_path: None, - dedicated_worker: None, - concurrency_settings: ConcurrencySettingsWithCustom::default(), - debouncing_settings: DebouncingSettings::default(), - modules: None, - tag: None, - }); - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - // Inline module code, not a separate runnable: it has no - // identity of its own and runs as the parent. - (payload, step_args, true, None) - } - "script" => { - // Resolve script path to job payload (handles hash, lang, etc.) - let (payload, _, _, _, _, on_behalf_of) = script_path_to_payload( - &step.script, - None, // no authed db for background workers - db.clone(), - &job.workspace_id, - Some(true), // skip preprocessor - ) - .await?; - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - (payload, step_args, true, on_behalf_of) - } - "flow" => { - let flow_info = get_latest_flow_version_info_for_path( - None, - db, - &job.workspace_id, - &step.script, - true, - ) - .await?; - let payload = JobPayload::Flow { - path: step.script.clone(), - dedicated_worker: flow_info.dedicated_worker, - apply_preprocessor: false, - version: flow_info.version, - labels: flow_info.labels.clone(), - }; - let on_behalf_of = - flow_info.on_behalf_of(&job.workspace_id, db).await?; - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - (payload, step_args, true, on_behalf_of) - } - _ => { - // "inline" — re-run parent with _executing_key - ( - job_payload_template.clone(), - parent_args.clone(), - false, - None, - ) - } - }; - - let push_args = PushArgs { args: &child_args, extra: None }; + let is_external = !matches!(child.runnable, ChildRunnable::Parent); + let job_payload = match child.runnable { + ChildRunnable::Parent => job_payload_template.clone(), + ChildRunnable::Module(module) => JobPayload::Code(RawCode { + content: module.content.clone(), + path: job.runnable_path.clone(), + hash: None, + language: module.language, + lock: module.lock.clone(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: ConcurrencySettingsWithCustom::default(), + debouncing_settings: DebouncingSettings::default(), + modules: None, + tag: None, + }), + ChildRunnable::Deployed(payload) => payload, + }; + let step_args = is_external.then(|| own_args(step)); + let push_args = + PushArgs { args: step_args.as_ref().unwrap_or(&parent_args), extra: None }; // Apply step-level overrides to payload (cache, concurrency) let mut job_payload = job_payload; @@ -3181,21 +3235,6 @@ pub async fn handle_wac_v2_output( } } - // A target runnable that opts into on-behalf-of runs under its own - // identity, never the caller's, so a step that reaches it through a - // workflow cannot widen or narrow its permissions. `created_by` still - // credits the caller, matching how the run API pushes these jobs. - let (child_email, child_permissioned_as) = match on_behalf_of.as_ref() { - Some(on_behalf_of) => ( - on_behalf_of.email.as_str(), - on_behalf_of.permissioned_as.clone(), - ), - None => ( - job.permissioned_as_email.as_str(), - job.permissioned_as.clone(), - ), - }; - let (_, mut tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -3203,8 +3242,8 @@ pub async fn handle_wac_v2_output( job_payload, push_args, &job.created_by, - child_email, - child_permissioned_as, + &child.email, + child.permissioned_as, None, None, None, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index dbdba3e6ed..6e01378448 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -44,8 +44,7 @@ use windmill_common::flow_status::{ }; use windmill_common::flows::{add_virtual_items_if_necessary, Branch, FlowNodeId, StopAfterIf}; use windmill_common::jobs::{ - check_tag_available_for_workspace_internal, script_path_to_payload, JobKind, JobPayload, - OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE, + script_path_to_payload, JobKind, JobPayload, OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE, }; use windmill_common::runnable_settings::{ ConcurrencySettingsWithCustom, DebouncingSettings, RunnableSettingsTrait, @@ -68,11 +67,12 @@ use windmill_common::{ }; use windmill_queue::schedule::get_schedule_opt; use windmill_queue::{ - add_completed_job, add_completed_job_error, append_logs, get_mini_pulled_job, - insert_concurrency_key_capped, interpolate_args, render_tag_path, - report_error_to_workspace_handler_or_critical_side_channel, tag_reads_args, - tag_reads_flow_expr, try_schedule_next_job, CanceledBy, FlowRunners, MiniCompletedJob, - MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, WrappedError, RE_FLOW_EXPR_TAG, + add_completed_job, add_completed_job_error, append_logs, + check_tag_written_as_available_for_push, get_mini_pulled_job, insert_concurrency_key_capped, + render_tag_path, report_error_to_workspace_handler_or_critical_side_channel, resolve_push_tag, + tag_reads_args, tag_reads_flow_expr, try_schedule_next_job, CanceledBy, FlowRunners, + MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, WrappedError, + RE_FLOW_EXPR_TAG, }; use windmill_audit::audit_oss::audit_log; @@ -537,15 +537,22 @@ pub async fn update_flow_status_after_job_completion_internal( if let Some(cached) = RESOLVED_FLOW_ENV_CACHE.get(&flow) { Some(cached) } else { - resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value) - .await - .map(|(env, is_cacheable)| { - let arc = Arc::new(env); - if is_cacheable { - RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); - } - arc - }) + resolve_flow_env_for_status_update( + db, + client, + flow, + w_id, + flow_value, + old_status.no_inherited_flow_env, + ) + .await + .map(|(env, is_cacheable)| { + let arc = Arc::new(env); + if is_cacheable { + RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); + } + arc + }) } } else { None @@ -1520,15 +1527,6 @@ pub async fn update_flow_status_after_job_completion_internal( .is_some_and(|ck| ck.contains("$args")) }); let require_args = concurrency_requires_args || has_debouncing; - let mut tag = tag_and_concurrency_key.as_ref().and_then(|x| x.tag.clone()); - // `$workspace` does not depend on the preprocessor's output, so it has to resolve even - // when nothing forced us to fetch args. Leaving it to the `$args` branch below writes a - // `$workspace`-only tag back verbatim, naming a queue no worker serves. - if let Some(t) = tag.as_ref().filter(|t| t.contains("$workspace")) { - let tag_ws = - windmill_queue::tags::tag_workspace_id(&flow_job.workspace_id, db).await; - tag = Some(t.replace("$workspace", &tag_ws)); - } let concurrency_key = tag_and_concurrency_key .as_ref() .and_then(|x| x.concurrency_key.clone()); @@ -1576,10 +1574,6 @@ pub async fn update_flow_status_after_job_completion_internal( ) .await?; } - if let Some(t) = tag { - // `$workspace` is already resolved above; this fills in `$args`. - tag = Some(interpolate_args(t, &args, &flow_job.workspace_id)); - } } else if concurrent_limit.is_some() { insert_concurrency_key_capped( &flow_job.workspace_id, @@ -1594,6 +1588,23 @@ pub async fn update_flow_status_after_job_completion_internal( .await?; } + // Resolved as `push` resolves a tag, so a `$flow_expr[...]` or empty one comes out as + // `None` and the job keeps its current tag instead of queueing on the literal text. + let tag = match tag_and_concurrency_key + .as_ref() + .and_then(|x| x.tag.as_deref()) + { + // `run_flow` checked this tag for whoever ran the flow, an `$args[...]` in it as + // written since only this point knows its values: an entry admitting it as + // written admits every value here. + Some(t) => { + let no_args = HashMap::new(); + let args = PushArgs::from(fetched_args.as_ref().unwrap_or(&no_args)); + resolve_push_tag(t, &args, &flow_job.workspace_id, db).await + } + None => None, + }; + let scheduled_for: Option> = { #[cfg(feature = "private")] { @@ -2517,6 +2528,7 @@ async fn resolve_flow_env_for_status_update( flow_job_id: Uuid, workspace_id: &str, flow_value: &FlowValue, + no_inherited_flow_env: bool, ) -> Option<(HashMap>, bool)> { // Fetch the env source. For the inherited path, we first need to know whether the // flow even has a parent — `fetch_root_flow_env` runs a recursive CTE on `v2_job` @@ -2525,6 +2537,8 @@ async fn resolve_flow_env_for_status_update( let (env, mini): (HashMap>, Option) = if let Some(ref e) = flow_value.flow_env { (e.clone(), None) + } else if no_inherited_flow_env { + return None; } else { let mini = match get_mini_pulled_job(db, &flow_job_id).await { Ok(Some(j)) => j, @@ -2538,7 +2552,13 @@ async fn resolve_flow_env_for_status_update( // No own flow_env and no parent to inherit from — nothing to resolve. return None; } - let env = fetch_root_flow_env(db, flow_job_id, workspace_id).await?; + let env = match fetch_root_flow_env(db, flow_job_id, workspace_id).await { + Ok(env) => env?, + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + return None; + } + }; (env, Some(mini)) }; if env.is_empty() { @@ -2604,7 +2624,7 @@ async fn fetch_root_flow_env( db: &DB, flow_job_id: Uuid, workspace_id: &str, -) -> Option>> { +) -> Result>>, sqlx::Error> { sqlx::query_scalar!( r#"WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS ( SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0 @@ -2641,10 +2661,7 @@ async fn fetch_root_flow_env( ) .fetch_optional(db) .await - .ok() - .flatten() - .flatten() - .map(|json| json.0) + .map(|row| row.flatten().map(|json| json.0)) } struct FailureContext { @@ -2789,18 +2806,31 @@ pub async fn handle_flow( ) -> anyhow::Result<()> { let flow = flow_data.value(); + let status = flow_job + .parse_flow_status() + .with_context(|| "Unable to parse flow status")?; + // Sub-flows spawned by `payload_from_modules` for branches/loops don't // carry the parent's `flow_env` in their own FlowValue. Fall back to the // nearest enclosing scope's `flow_env` so predicates like `skip_if`, // `stop_after_if`, and branch conditions see the same env as input // transforms. - let inherited_env: Option>> = - if flow.flow_env.is_none() && flow_job.parent_job.is_some() { - fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await - } else { - None - }; + let (inherited_env, inherited_env_known) = if flow.flow_env.is_none() + && flow_job.parent_job.is_some() + && !status.no_inherited_flow_env + { + match fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await { + Ok(env) => (env, true), + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + (None, false) + } + } + } else { + (None, true) + }; let env_source = flow.flow_env.as_ref().or(inherited_env.as_ref()); + let no_flow_env = inherited_env_known && env_source.is_none_or(|e| e.is_empty()); // Resolve $var: and $res: references in flow_env. // We resolve into a separate variable to avoid cloning the entire FlowValue @@ -2846,10 +2876,6 @@ pub async fn handle_flow( } } - let status = flow_job - .parse_flow_status() - .with_context(|| "Unable to parse flow status")?; - let schedule_path = flow_job.schedule_path(); if !flow_job.is_flow_step() && status.retry.fail_count == 0 @@ -2993,6 +3019,7 @@ pub async fn handle_flow( status, flow, flow_env, + no_flow_env, db, client, last_result.clone(), @@ -3236,6 +3263,8 @@ async fn push_next_flow_job( mut status: FlowStatus, flow: &FlowValue, flow_env: Option<&HashMap>>, + // Neither this flow nor any ancestor defines `flow_env`. + no_flow_env: bool, db: &sqlx::Pool, client: &AuthedClient, last_job_result: Option>>, @@ -4225,7 +4254,14 @@ async fn push_next_flow_job( let mut tx = db.begin().warn_after_seconds(3).await?; let nargs = args.as_ref(); - for (i, payload_tag) in job_payloads.into_iter().enumerate() { + for (i, mut payload_tag) in job_payloads.into_iter().enumerate() { + // A `FlowNode` here is a branch/loop sub-flow from `payload_from_modules`: it never defines + // its own `flow_env`, so it inherits exactly this flow's. The mark lives in the child's + // status, not its definition: a `RawFlow` child's stored definition is replayed by nested + // restarts, whose root may have gained a `flow_env` since. + if let JobPayload::FlowNode { no_inherited_flow_env, .. } = &mut payload_tag.payload { + *no_inherited_flow_env = no_flow_env; + } if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the @@ -4494,33 +4530,7 @@ async fn push_next_flow_job( ) }; - // Check tag availability for flow substeps to prevent abuse. - // Skip when the substep inherits the parent flow's tag: that tag was already - // validated at flow push time, and for dedicated flows it is the auto-generated - // `{workspace_id}:flow/{path}` tag which is never in CUSTOM_TAGS. - if let Some(tag_str) = tag - .as_deref() - .filter(|t| !t.is_empty() && *t != flow_job.tag.as_str()) - { - // A step with its own on-behalf-of carries a cached dispatch address, up to one - // notify poll stale; accepted, see `get_email_from_permissioned_as`. - let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?; - check_tag_available_for_workspace_internal( - db, - &flow_job.workspace_id, - tag_str, - is_super_admin, - None, // no token for flow substeps so no scopes so no scope_tags - ) - .warn_after_seconds_with_sql( - 1, - "check_tag_available_for_workspace_internal".to_string(), - ) - .await?; - } - - // Resolved only after the check: CUSTOM_TAGS allows the template, so its value may name - // any queue, as the value of an `$args[...]` tag does. + let written_tag = tag.clone(); let mut tag_err = None; let tag = match tag { Some(t) if err.is_none() && tag_reads_flow_expr(&t) => { @@ -4543,6 +4553,31 @@ async fn push_next_flow_job( }; let err = err.or(tag_err.as_ref()); + // Check tag availability for flow substeps to prevent abuse. It runs on the tag with its + // `$flow_expr[...]` filled in, as `push` fills in the rest, since that is what picks the + // queue. Skip when the substep inherits the parent flow's tag: that tag was already + // validated at flow push time, and for dedicated flows it is the auto-generated + // `{workspace_id}:flow/{path}` tag which is never in CUSTOM_TAGS. + if let Some(tag_str) = tag + .as_deref() + .filter(|t| !t.is_empty() && *t != flow_job.tag.as_str()) + { + // A step with its own on-behalf-of carries a cached dispatch address, up to one + // notify poll stale; accepted, see `get_email_from_permissioned_as`. + let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?; + check_tag_written_as_available_for_push( + db, + &flow_job.workspace_id, + written_tag.as_deref().unwrap_or(tag_str), + tag_str, + &push_args, + is_super_admin, + None, // no token for flow substeps so no scopes so no scope_tags + ) + .warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string()) + .await?; + } + let evaluated_timeout = if let Some(timeout_transform) = &module.timeout { let ctx = get_transform_context(&flow_job, &previous_id, &status); @@ -5215,7 +5250,7 @@ fn payload_from_modules<'a>( } if let Some(id) = modules_node { - return Some(JobPayload::FlowNode { id, path: path() }); + return Some(JobPayload::FlowNode { id, path: path(), no_inherited_flow_env: false }); } add_virtual_items_if_necessary(&mut modules); diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index ed07c4c140..1e6cfdd474 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.815.0"; +export const VERSION = "v1.817.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 36407f964f..ff470d4ffe 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.815.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.815.0", + "version": "1.817.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 2c42ca3565..5655adc64e 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.815.0", + "version": "1.817.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 9032cee397..8a433d69d4 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -140,6 +140,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ type: "object", properties: { relative_path: { type: "string", description: "Path to the codebase directory" }, + extra_digest_paths: { type: "array", items: { type: "string" }, description: "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" }, includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" }, excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" }, format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" }, @@ -159,6 +160,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ example: [ "# codebases:", '# - relative_path: ./shared # path to the codebase', + '# # extra_digest_paths: ["../packages/utils/src"] # imported paths outside relative_path, hashed recursively (skip node_modules/dist)', '# includes: ["**/*.ts"] # files to include in bundle', '# excludes: ["node_modules/**"] # files to exclude', '# format: esm # bundle format: "cjs" or "esm"', diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index fe193e5d85..06db3db1bf 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -61,7 +61,11 @@ import { readConfigFile, readEffectiveSyncBehavior, } from "../../core/conf.ts"; -import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts"; +import { + SyncCodebase, + listSyncCodebases, + uncoveredBundleInputs, +} from "../../utils/codebase.ts"; import { pollJobWithQueueLogging } from "../../utils/job_polling.ts"; import fs from "node:fs"; import { createTarBlob, type TarEntry } from "../../utils/tar.ts"; @@ -446,8 +450,19 @@ export async function handleFile( target: format == "cjs" ? "node20.15.1" : "esnext", banner: codebase.banner, // ...(codebase.banner != null && { banner: codebase.banner }), + metafile: true, }); const endTime = performance.now(); + const uncovered = uncoveredBundleInputs( + codebase, + path, + Object.keys(out.metafile?.inputs ?? {}) + ); + if (uncovered.length > 0) { + log.warnAlways( + `${path} bundles files outside codebase ${codebase.relative_path}; edits to them won't be detected as changes unless listed in extra_digest_paths: ${uncovered.join(", ")}` + ); + } bundleContent = out.outputFiles[0].text; outputFiles = out.outputFiles ?? []; if (outputFiles.length == 0) { diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 32cff4a3cb..6185decc5a 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1153,7 +1153,7 @@ export function ZipFSElement( ? "script" : p.endsWith(".resource.json") ? "resource" - : p.startsWith("dependencies/") + : isWorkspaceDependencies(p) ? "dependencies" : "other"; @@ -2752,7 +2752,7 @@ export async function compareDynFSElement( if (skipMetadata) { continue; } - if (k.startsWith("dependencies/")) { + if (isWorkspaceDependencies(k)) { if (!workspaceDependenciesPathToLanguageAndFilename(k)) { log.warn(`Skipping unrecognized workspace dependencies file: ${k}`); continue; diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 0c746d2261..7c6eb13274 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -115,6 +115,7 @@ export interface SyncOptions { export interface Codebase { relative_path: string; + extra_digest_paths?: string[]; includes?: string[]; excludes?: string[]; assets?: { diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index ab1bcf0a87..9728b6edd7 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.815.0"; +export const VERSION = "1.817.0"; diff --git a/cli/src/types.ts b/cli/src/types.ts index 5713d06390..866b2be21e 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -391,7 +391,7 @@ export function getTypeStrFromPath( if (isSharedLockPath(p)) { return "shared_lock"; } - if (p.startsWith("dependencies" + SEP)) { + if (isWorkspaceDependencies(p)) { return "workspace_dependencies"; } if (isFileResource(p) || isFilesetResource(p)) { diff --git a/cli/src/utils/codebase.ts b/cli/src/utils/codebase.ts index e665d060b2..2eb63caeec 100644 --- a/cli/src/utils/codebase.ts +++ b/cli/src/utils/codebase.ts @@ -1,6 +1,44 @@ import { Codebase, SyncOptions } from "../core/conf.ts"; import * as log from "../core/log.ts"; -import { digestDir } from "./utils.ts"; +import { digestDir, generateHash, generateHashFromBuffer } from "./utils.ts"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +// A bare string is accepted, like `includes`/`excludes` in findCodebase. +function extraDigestPaths(codebase: Codebase): string[] { + return ([] as string[]).concat(codebase.extra_digest_paths ?? []); +} + +async function digestPath(p: string): Promise { + let s; + try { + s = await stat(p); + } catch { + throw new Error( + `Codebase extra_digest_paths entry not found: ${p} (resolved to ${path.resolve(p)})` + ); + } + return s.isDirectory() + ? await digestDir(p, "") + : await generateHashFromBuffer(await readFile(p)); +} + +/** Bundle inputs that no codebase digest covers, so edits to them would not trigger a re-push. */ +export function uncoveredBundleInputs( + codebase: Codebase, + entry: string, + inputs: string[] +): string[] { + const entryAbs = path.resolve(entry); + const roots = [codebase.relative_path, ...extraDigestPaths(codebase)] + .map((r) => path.resolve(r)); + return inputs.filter((i) => { + // non-file namespaces ("", "(disabled):x", "ns:path") and installed packages + if (/^[<(]|^[a-z-]{2,}:/i.test(i) || i.includes("node_modules")) return false; + const abs = path.resolve(i); + return abs != entryAbs && !roots.some((r) => abs == r || abs.startsWith(r + path.sep)); + }); +} export type SyncCodebase = Codebase & { getDigest: (forceTar?: boolean) => Promise; @@ -23,13 +61,20 @@ export function listSyncCodebases(options: SyncOptions): SyncCodebase[] { forceTar?: boolean ) => { if (_digest == undefined) { - _digest = await digestDir( + // Concurrent callers read `_digest` across these awaits: assign it only once complete. + let digest = await digestDir( codebase.relative_path, JSON.stringify(codebase) ); - if (codebase.format == "esm") { - _digest += ".esm"; + const extra = extraDigestPaths(codebase); + if (extra.length > 0) { + const hashes = await Promise.all(extra.map(digestPath)); + digest = await generateHash(digest + hashes.join("")); } + if (codebase.format == "esm") { + digest += ".esm"; + } + _digest = digest; if (!alreadyPrinted) { alreadyPrinted = true; log.info(`Codebase ${codebase.relative_path}, digest: ${_digest}`); diff --git a/cli/src/utils/script_common.ts b/cli/src/utils/script_common.ts index d804318491..611268c27c 100644 --- a/cli/src/utils/script_common.ts +++ b/cli/src/utils/script_common.ts @@ -45,7 +45,9 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [ ] as const; export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined { - const relativePath = path.replace("dependencies/", ""); + // Sync paths carry the platform separator, while a set's name is always + // forward-slashed. + const relativePath = path.replaceAll("\\", "/").replace("dependencies/", ""); for (const { filename, language } of workspaceDependenciesLanguages) { if (relativePath.endsWith(filename)) { return { diff --git a/cli/src/utils/utils.ts b/cli/src/utils/utils.ts index 0801c03911..526dffbde4 100644 --- a/cli/src/utils/utils.ts +++ b/cli/src/utils/utils.ts @@ -223,8 +223,9 @@ export function isRawAppFile(path: string): boolean { return isRawAppPath(path); } +/** Sync paths carry the platform separator: `dependencies\x` on Windows. */ export function isWorkspaceDependencies(path: string): boolean { - return path.startsWith("dependencies/"); + return path.startsWith("dependencies/") || path.startsWith("dependencies\\"); } export function printSync(input: string | Uint8Array) { diff --git a/cli/test/codebase_digest_unit.test.ts b/cli/test/codebase_digest_unit.test.ts new file mode 100644 index 0000000000..f12b928de4 --- /dev/null +++ b/cli/test/codebase_digest_unit.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + listSyncCodebases, + uncoveredBundleInputs, +} from "../src/utils/codebase.ts"; + +test("extra_digest_paths edits change the codebase digest", async () => { + const root = await mkdtemp(join(tmpdir(), "wmill-codebase-")); + await mkdir(join(root, "f")); + await mkdir(join(root, "packages/utils"), { recursive: true }); + await writeFile(join(root, "f/main.ts"), "export * from '../packages/utils/a'"); + await writeFile(join(root, "packages/utils/a.ts"), "export const a = 1"); + + const digest = () => + listSyncCodebases({ + codebases: [ + { + relative_path: join(root, "f"), + extra_digest_paths: [join(root, "packages/utils")], + }, + ], + } as any)[0].getDigest(); + + try { + const before = await digest(); + await writeFile(join(root, "packages/utils/a.ts"), "export const a = 2"); + expect(await digest()).not.toBe(before); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("uncoveredBundleInputs reports only files outside the digested roots", () => { + const codebase = { relative_path: "f", extra_digest_paths: ["packages/a"] }; + expect( + uncoveredBundleInputs(codebase, "g/entry.ts", [ + "g/entry.ts", + "f/main.ts", + "packages/a/x.ts", + "packages/ab/y.ts", + "node_modules/pg/index.js", + "", + ]) + ).toEqual(["packages/ab/y.ts"]); +}); diff --git a/cli/test/gitsync_deploy_branch_config.test.ts b/cli/test/gitsync_deploy_branch_config.test.ts index 39c435d872..eeab7a0120 100644 --- a/cli/test/gitsync_deploy_branch_config.test.ts +++ b/cli/test/gitsync_deploy_branch_config.test.ts @@ -63,7 +63,10 @@ test("git-sync fork deploy follows the fork branch's wmill.yaml", async () => { git(seed, "commit", "-m", "dedupe"); git(seed, "push", "origin", forkBranch); - git(work, "clone", `file://${bare}`, "."); + // Git for Windows defaults to autocrlf=true, which checks the pulled LF + // files out as CRLF; the deploy's pull would rewrite every one of them + // and the clean-tree assertion below would read that as a change. + git(work, "clone", "-c", "core.autocrlf=false", `file://${bare}`, "."); const deployed = await backend.runCLICommand( [ "sync", diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 24c4a282a3..fa52510e6c 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -2185,18 +2185,20 @@ describe("settings sync", () => { const files = await listFilesRecursive(tempDir); expect(files).toContain("settings.yaml"); - // Read and modify a safe setting (webhook URL) + // Read and modify a safe setting (webhook URL). The server resolves the + // webhook host when it is saved and refuses one that does not resolve to a + // public address, so this must be a real public hostname. + const webhook = "https://example.com/hook"; const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); let modifiedSettings: string; if (settingsContent.includes("webhook:")) { modifiedSettings = settingsContent.replace( /webhook:.*/, - 'webhook: "https://test-webhook.example.com/hook"' + `webhook: "${webhook}"` ); } else { - modifiedSettings = - settingsContent + '\nwebhook: "https://test-webhook.example.com/hook"\n'; + modifiedSettings = settingsContent + `\nwebhook: "${webhook}"\n`; } await writeFile(`${tempDir}/settings.yaml`, modifiedSettings, "utf-8"); @@ -2213,7 +2215,7 @@ describe("settings sync", () => { ); expect(apiResp.status).toEqual(200); const settingsData = await apiResp.json(); - expect(settingsData.webhook).toEqual("https://test-webhook.example.com/hook"); + expect(settingsData.webhook).toEqual(webhook); }); }); }); diff --git a/cli/test/workspace_deps_path_unit.test.ts b/cli/test/workspace_deps_path_unit.test.ts new file mode 100644 index 0000000000..45d725c6e5 --- /dev/null +++ b/cli/test/workspace_deps_path_unit.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { isWorkspaceDependencies } from "../src/utils/utils.ts"; +import { workspaceDependenciesPathToLanguageAndFilename } from "../src/utils/script_common.ts"; + +// Sync paths carry the platform separator. A Windows path parsed to a different +// set name pushes the file to a set the workspace does not have. +test("workspace dependency paths parse the same with either separator", () => { + for (const sep of ["/", "\\"]) { + expect(isWorkspaceDependencies(`dependencies${sep}requirements.in`)).toBe(true); + expect( + workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}requirements.in`), + ).toEqual({ name: undefined, language: "python3" }); + expect( + workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}team.package.json`), + ).toEqual({ name: "team", language: "bun" }); + expect( + workspaceDependenciesPathToLanguageAndFilename( + `dependencies${sep}team${sep}python.requirements.in`, + ), + ).toEqual({ name: "team/python", language: "python3" }); + } + expect(isWorkspaceDependencies("f\\dependencies\\requirements.in")).toBe(false); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json index b4b10087a8..022156e5c9 100644 --- a/cli/wmill.schema.json +++ b/cli/wmill.schema.json @@ -147,6 +147,13 @@ "type": "string", "description": "Path to the codebase directory" }, + "extra_digest_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" + }, "includes": { "type": "array", "items": { diff --git a/docker/DockerfileCuda b/docker/DockerfileCuda index 098670f9a3..031921070b 100644 --- a/docker/DockerfileCuda +++ b/docker/DockerfileCuda @@ -1,4 +1,5 @@ -FROM ghcr.io/windmill-labs/windmill-ee:dev +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill-ee:dev +FROM ${BASE_IMAGE} # Install any necessary dependencies for managing repositories and packages RUN apt-get update && apt-get install -y curl gnupg2 diff --git a/docker/DockerfileFull b/docker/DockerfileFull index b827208c1d..8ef6be78bb 100644 --- a/docker/DockerfileFull +++ b/docker/DockerfileFull @@ -1,4 +1,5 @@ -FROM ghcr.io/windmill-labs/windmill:dev +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill:dev +FROM ${BASE_IMAGE} # Rust COPY --from=rust:1.97.0 /usr/local/cargo /usr/local/cargo diff --git a/docker/DockerfileFullEe b/docker/DockerfileFullEe index 5b5347558a..88ed619c19 100644 --- a/docker/DockerfileFullEe +++ b/docker/DockerfileFullEe @@ -1,3 +1,5 @@ +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill-ee:dev + FROM alpine:3.14 AS oracledb-client ARG TARGETPLATFORM @@ -17,7 +19,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ mv /opt/oracle/23/amd64/lib /opt/oracle/23/lib; \ fi -FROM ghcr.io/windmill-labs/windmill-ee:dev +FROM ${BASE_IMAGE} # Rust COPY --from=rust:1.97.0 /usr/local/cargo /usr/local/cargo diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 64fd1b735e..6418a2cfda 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -1,4 +1,7 @@ ARG DEBIAN_IMAGE=debian:trixie-slim +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill:dev + +FROM ${BASE_IMAGE} AS windmill_image FROM debian:trixie-slim AS nsjail @@ -103,7 +106,7 @@ RUN arch="$(dpkg --print-architecture)"; \ WORKDIR ${APP} -COPY --from=ghcr.io/windmill-labs/windmill:dev --chmod=755 ${APP}/windmill ${APP}/windmill +COPY --from=windmill_image --chmod=755 ${APP}/windmill ${APP}/windmill RUN ln -s ${APP}/windmill /usr/local/bin/windmill diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index de0d61a780..24ef941b87 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -1,4 +1,7 @@ ARG DEBIAN_IMAGE=debian:trixie-slim +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill-ee:dev + +FROM ${BASE_IMAGE} AS windmill_image FROM debian:trixie-slim AS nsjail @@ -103,7 +106,7 @@ RUN arch="$(dpkg --print-architecture)"; \ WORKDIR ${APP} -COPY --from=ghcr.io/windmill-labs/windmill-ee:dev --chmod=755 ${APP}/windmill ${APP}/windmill +COPY --from=windmill_image --chmod=755 ${APP}/windmill ${APP}/windmill RUN ln -s ${APP}/windmill /usr/local/bin/windmill diff --git a/docs/auth-surface.md b/docs/auth-surface.md index 7a8cbd76ea..15f2538cc5 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -46,15 +46,65 @@ Symbols, not line numbers, are cited: they drift less. (`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs `users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes. +- **Write access to a folder steers its readers' AI chat.** An `ai_instruction` resource's body is + delivered to the global chat of everyone who can read the resource, with no opt-in, whenever the + chat touches a path under its folder (`copilot/chat/folderInstructions.ts`); the chat then acts + with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play + for every reader until turned off. The resource ACL is the only control, so granting write on a + folder grants that influence. +- **A remote deploy token is a credential for another instance**, held per account and workspace + (`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`). + Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or + renames an account takes the token along and a recycled address cannot inherit it; removal from + a workspace deletes it explicitly (`delete_workspace_user_internal`, both `leave_workspace`). The + row records the target it was granted for, and + `remote_deploy::proxy` only sends it to a target still matching the workspace setting, so + re-pointing the setting cannot redirect anyone's token to a URL of the admin's choosing. + `require_own_credentials` refuses job, scoped and read-only tokens (on `set_target` too): the + stored token carries none of their restrictions. The proxy turns the local session into a remote + bearer credential, so every ambient-cookie vector becomes one on the remote: its URL carries the + row's random `proxy_key` (a link riding the `SameSite=Lax` cookie cannot know it; a header would + do, but the frontend's only per-call hook is the global `OpenAPI.HEADERS`, whose mere presence + switches every download to in-memory blobs). The key is served `no-store`, withheld from the + credentials `require_own_credentials` refuses, and masked in this instance's own request logs + (`RedactedUri`, used by the request span and the log context); a reverse proxy in front still + writes the full path to its access log. `connect` names the target the token was obtained for + and is refused, before the token is sent anywhere, if the workspace now points elsewhere; the + token only ever goes to that target. It takes no lock against what clears these rows (a removal + from the workspace, a target change, a key rotation): their writers take the account, membership, + key and settings rows in every order, so any lock held there could close a deadlock. A row can + therefore land after one of them ran, however long its remote call took, and every read + (`load_connection`) voids it instead, by identity rather than by comparing times: the connect + records, before its remote call, the target setting's version (`remote_deploy_target_changed_at`) + and the `created_at` of the owner's membership, and the row counts only while both are still + the same (or the owner is a superadmin) and it decrypts. So a target set A → B → A, or a + removal and re-add, voids it whatever the clocks say. A superadmin with no membership is bound + through the credential making the request instead: the insert requires its `token` row to + still exist, and `delete_user`, offboarding and SCIM removal delete an account's tokens, so an + address deleted that way and re-created during the call does not inherit the connection (a JWT, + having no row, cannot connect a workspace its owner is not a member of). `leave_instance` + deletes only the `password` row, leaving the tokens and the memberships, which is not covered. + Neither is a member's connect across an account re-creation: an address is not expected to pass + to another person. A disconnect empties and stamps the row + (inserting one if needed) rather than deleting it, `connected_at` is when a connect started, + and the connect's upsert leaves a row stamped after that alone, so an older connect cannot undo + a disconnect or a newer connect. Connecting by redirect: the remote's + `/user/remote_deploy_authorize` page + mints a token bound to the one remote workspace (`remote-deploy:`, a label + reserved in `is_user_token` so its expiry emails nobody) only on an + explicit Authorize, only for a callback whose path is `/remote_deploy/callback`, and refuses to + render inside a frame; the token travels in the fragment, and the callback checks a single-use + `state` the drawer stored, so no other page can plant a token as the user's. The proxy also refuses a path the URL parser would rewrite, + serves every response under `CSP: sandbox` + `nosniff`, forwards only the method, query, body, + content-type and accept — never this instance's cookie or token — and turns the target's 401 + into a 502, because the browser logs the user out of *this* instance on an unhandled 401. - **`login_type`** (`password` table) is a free-form `VARCHAR(50)`. Password login and password reset require `login_type = 'password'`; `set_password` also accepts `pending_oauth` and turns the account into a `password` one in the same statement (an account created ahead of its owner gets its first credential that way, or through the OAuth claim below). - **Login links** (`login_link` table, `POST /users/login_links` superadmin-only, - `GET /auth/login_link/{token}` unauthenticated): single-use, ≤2 h, a session cookie and a - 302 to a same-origin `rd`. A link minted with `confirm` is the `/user/login_link` page - instead, which spends it only on a click (`POST` to the same path, answering `{location}`), so - a mail scanner opening it does not. `require_login_type` on the mint refuses (409) an account whose + `GET /auth/login_link/{token}` unauthenticated): single-use, ≤15 min, a session cookie and a + 302 to a same-origin `rd`. `require_login_type` on the mint refuses (409) an account whose `login_type` has moved on — the way a caller re-entering an account it created stops being able to once the owner has a password or a provider. - **Pre-approved trial offer** (`cloud_trial_offer`, cloud-only routes under diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index ae62d46b94..9dacfc1cbd 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,10 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 51 registered actions across nineteen features (`ai_session`, `ai_chat`, -`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`, -`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, -`usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the +It currently carries 58 registered actions across twenty features (`ai_session`, `ai_chat`, +`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`, +`flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, +`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0427688011..611cd0c91f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.815.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.815.0", + "version": "1.817.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 96d4862677..46db11793f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.815.0", + "version": "1.817.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 87ce066c87..68c1c558b1 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "1522e43", - "sha256": "0c44db3a52e02e6530860d0e9e9031fcdad7a09a98897b6c97e6f522c1d4754c" + "version": "60b6904", + "sha256": "85b68e8418c97d96f3a419b3e345ef8a1987976943b8439a7df841f9217a9b34" } diff --git a/frontend/src/lib/accentColor.test.ts b/frontend/src/lib/accentColor.test.ts new file mode 100644 index 0000000000..6d3b855240 --- /dev/null +++ b/frontend/src/lib/accentColor.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { accentStylesheet } from './accentColor' + +describe('accentStylesheet', () => { + it('refuses anything but #rrggbb, since the value lands in a stylesheet', () => { + for (const bad of ['#000;}body{display:none', '#fff', 'red', '1f9d55', 123, null]) { + expect(accentStylesheet(bad)).toBeUndefined() + } + }) + + it('overrides every accent token with an in-range r g b triplet in each theme', () => { + // A saturated color exercises the gamut clamp, a grey the zero-chroma path. + for (const color of ['#ff0000', '#808080']) { + const rules = accentStylesheet(color)!.split('\n') + expect(rules).toHaveLength(3) + for (const rule of rules) { + const triplets = [...rule.matchAll(/--color-[a-z-]+: (\d+) (\d+) (\d+);/g)] + expect(triplets).toHaveLength(10) + for (const [, ...channels] of triplets) { + for (const c of channels) expect(Number(c)).toBeLessThanOrEqual(255) + } + } + } + }) +}) diff --git a/frontend/src/lib/accentColor.ts b/frontend/src/lib/accentColor.ts new file mode 100644 index 0000000000..f11a96bb79 --- /dev/null +++ b/frontend/src/lib/accentColor.ts @@ -0,0 +1,138 @@ +import tokensFile from './assets/tokens/tokens.json' +import githubDarkTokens from './assets/tokens/githubDark.json' +import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' + +/** Instance-wide accent color, `#rrggbb` (mirrors `ACCENT_COLOR_SETTING` in the backend). */ +export const ACCENT_COLOR_SETTING = 'accent_color' + +const ACCENT_TOKENS = [ + 'surface-accent-primary', + 'surface-accent-hover', + 'surface-accent-clicked', + 'surface-accent-selected', + 'surface-accent-secondary', + 'surface-accent-secondary-hover', + 'surface-accent-secondary-clicked', + 'text-accent', + 'border-accent', + 'border-selected' +] as const + +const STYLE_ID = 'wm-accent-color' +const ACCENT_ATTR = 'data-wm-accent' + +/** + * The stored setting as a color, or `undefined` for "use the default theme". + * + * The backend already rejects anything but `#rrggbb`, but the value is interpolated into a + * stylesheet, so this is the last place that can refuse a row written around the validator. + */ +export function parseAccentColor(value: unknown): string | undefined { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) + ? value.toLowerCase() + : undefined +} + +type Oklch = [l: number, c: number, h: number] + +function srgbToLinear(v: number): number { + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) +} + +function linearToSrgb(v: number): number { + return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055 +} + +function hexToOklch(hex: string): Oklch { + const [r, g, b] = [1, 3, 5].map((i) => srgbToLinear(parseInt(hex.slice(i, i + 2), 16) / 255)) + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + return [L, Math.hypot(A, B), Math.atan2(B, A)] +} + +/** Linear sRGB channels, possibly out of [0, 1] when the color is outside the gamut. */ +function oklchToLinearRgb([L, C, h]: Oklch): [number, number, number] { + const A = C * Math.cos(h) + const B = C * Math.sin(h) + const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3 + const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3 + const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3 + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + ] +} + +/** `r g b` triplet (the format the `--color-*` variables use), chroma reduced into gamut. */ +function oklchToRgbTriplet([L, C, h]: Oklch): string { + const inGamut = (c: number) => + oklchToLinearRgb([L, c, h]).every((v) => v >= -1e-4 && v <= 1 + 1e-4) + let lo = 0 + let hi = C + if (!inGamut(C)) { + for (let i = 0; i < 20; i++) { + const mid = (lo + hi) / 2 + if (inGamut(mid)) lo = mid + else hi = mid + } + } else lo = C + return oklchToLinearRgb([L, lo, h]) + .map((v) => Math.round(Math.min(1, Math.max(0, linearToSrgb(v))) * 255)) + .join(' ') +} + +// Below this chroma the chosen color reads as grey, and the accents fade with it. +const FULL_CHROMA = 0.1 + +/** + * Move each accent token to the chosen hue while keeping its own lightness, so every + * text/background pairing keeps the contrast the default palette was designed with. + * Taking the chosen color verbatim would put white text on a pale yellow button. + */ +function recolor(tokens: Record, accent: Oklch): string { + const chromaScale = Math.min(1, accent[1] / FULL_CHROMA) + return ACCENT_TOKENS.map((name) => { + const [l, c] = hexToOklch(tokens[name]) + return `--color-${name}: ${oklchToRgbTriplet([l, c * chromaScale, accent[2]])};` + }).join('') +} + +/** The accent overrides for every theme, or `undefined` when `value` is not `#rrggbb`. */ +export function accentStylesheet(value: unknown): string | undefined { + const color = parseAccentColor(value) + if (color == undefined) return undefined + const accent = hexToOklch(color) + // `:root[attr]` outranks the `html`, `html.dark` and `html.dark.github-dark` rules + // that define the default tokens, whatever order the stylesheets load in. + return [ + `:root[${ACCENT_ATTR}]{${recolor(tokensFile.tokens[lightModeName], accent)}` + + `--sidebar-bg-light:color-mix(in oklab, ${color} 18%, var(--sidebar-bg-light-base));}`, + `:root[${ACCENT_ATTR}].dark{${recolor(tokensFile.tokens[darkModeName], accent)}` + + `--sidebar-bg-dark:color-mix(in oklab, ${color} 22%, var(--sidebar-bg-dark-base));}`, + `:root[${ACCENT_ATTR}].dark.github-dark{${recolor(githubDarkTokens, accent)}}` + ].join('\n') +} + +/** Apply the instance accent to the whole document, or restore the default with `undefined`. */ +export function applyAccentColor(value: string | undefined): void { + const root = document.documentElement + let style = document.getElementById(STYLE_ID) + const css = accentStylesheet(value) + if (css == undefined) { + style?.remove() + root.removeAttribute(ACCENT_ATTR) + return + } + if (!style) { + style = document.createElement('style') + style.id = STYLE_ID + document.head.appendChild(style) + } + style.textContent = css + root.setAttribute(ACCENT_ATTR, '') +} diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte index 0388ab0845..ad84cc263b 100644 --- a/frontend/src/lib/components/AgentResultDisplay.svelte +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -5,7 +5,6 @@ import AgentTrace from './AgentTrace.svelte' import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte' import LabeledDivider from './LabeledDivider.svelte' - import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte' import { buildAgentTrace, splitFinalAnswer } from './agentTrace' import { runPane } from './agentScroll' import { createBottomSticker } from './stickToBottom' @@ -32,9 +31,8 @@ // The turn that produced `output` is not a trace row: the output block below is // that same text, and printing it twice in one scroll reads as the agent having - // answered itself. Its citations move down with it. - let answer = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output)) - let trace = $derived(answer.trace) + // answered itself. Its citations stay in the trace, on the search that found them. + let trace = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output)) let reasoning = $derived(result.reasoning?.trim()) let reasoningExpanded = $state(false) @@ -85,11 +83,6 @@ {:else} {@render structuredOutput(result.output)} {/if} - {#if answer.sources} -

- {/if} diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte index 91b87fea67..7f2a90e621 100644 --- a/frontend/src/lib/components/AgentTrace.svelte +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -6,6 +6,7 @@ import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte' import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte' import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte' + import { webSearchResultOf, type WebSearchResult } from './copilot/chat/shared' import GfmMarkdown from './GfmMarkdown.svelte' import type { AgentTraceEntry } from './agentTrace' import { SvelteMap, SvelteSet } from 'svelte/reactivity' @@ -21,11 +22,14 @@ let { entries, workspaceId }: Props = $props() let expanded = new SvelteSet() + // A search opens on its sources, so for it the reader's toggle records a close. + let collapsedSearches = new SvelteSet() // Row state is keyed by position, which means nothing once the viewer is handed // another run: row 0 would stay open showing the previous run's job. $effect(() => { entries expanded.clear() + collapsedSearches.clear() jobs.clear() }) // A tool's own job holds what the envelope does not: its logs, how long it @@ -67,12 +71,18 @@ return typeof job === 'object' ? job : undefined } - function toolLabel(entry: Extract): string { - const job = jobOf(entry.jobId) - const duration = job?.['duration_ms'] - return duration === undefined - ? entry.name - : `${entry.name} · ${msToReadableTimeShort(duration)}` + function toolLabel( + entry: Extract, + search: WebSearchResult | undefined + ): string { + const duration = jobOf(entry.jobId)?.['duration_ms'] + return [ + entry.name, + search?.query !== undefined ? `"${search.query}"` : undefined, + duration !== undefined ? msToReadableTimeShort(duration) : undefined + ] + .filter((part) => part !== undefined) + .join(' · ') } @@ -81,25 +91,29 @@ {#if entry.kind === 'assistant'}
- {#if entry.sources} -
- -
- {/if}
{:else if entry.kind === 'search'} - + {}} - /> + label="Searched the web" + expanded={Boolean(entry.sources) && !collapsedSearches.has(index)} + toggleable={Boolean(entry.sources)} + onToggle={() => { + if (!collapsedSearches.delete(index)) collapsedSearches.add(index) + }} + > + {#if entry.sources} + + {/if} + {:else} {@const job = jobOf(entry.jobId)} + {@const failed = job?.type === 'CompletedJob' && !job.success} + {@const search = webSearchResultOf(entry.result)} toggle(index, entry)} contentClass="space-y-3" @@ -110,11 +124,15 @@ {#if job?.logs} {/if} - + {#if search && !failed} + + {:else} + + {/if} {#if entry.resourcePath}
diff --git a/frontend/src/lib/components/AssignableTagsInner.svelte b/frontend/src/lib/components/AssignableTagsInner.svelte index 33a51be87c..fd5859b784 100644 --- a/frontend/src/lib/components/AssignableTagsInner.svelte +++ b/frontend/src/lib/components/AssignableTagsInner.svelte @@ -2,7 +2,8 @@ import { preventDefault, stopPropagation } from 'svelte/legacy' import { Button } from './common' - import { ExternalLink, Loader2, X } from 'lucide-svelte' + import { AlertTriangle, ExternalLink, Loader2, X } from 'lucide-svelte' + import Popover from './meltComponents/Popover.svelte' import { SettingService, WorkerService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { superadmin, devopsRole } from '$lib/stores' @@ -38,7 +39,8 @@ const dispatch = createEventDispatcher() // Mirrors CUSTOM_TAG_REGEX in backend/windmill-common/src/worker.rs — keep both in sync. - const customTagRegex = /^([\w-]+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$/ + const customTagRegex = + /^((?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$/ // Mirrors RE_ARG_TAG and RE_FLOW_EXPR_TAG in backend/windmill-queue/src/jobs.rs. const dynamicTagRegex = /\$(args|flow_expr)\[((?:\w+\.)*\w+)\]/ @@ -46,13 +48,56 @@ return w.includeForks ? `${w.id} (and its forks)` : w.id } + // What an entry matches job tags against: a workspace specific entry's name, which may be + // dynamic like any other entry. + function nameOf(entry: string) { + return entry.match(customTagRegex)?.[1] ?? entry + } + let tagName = $derived(nameOf(newTag.trim())) + let dynamicTag = $derived.by(() => { - let r = newTag.trim() - if (r == '') return undefined - let matched = r.match(dynamicTagRegex) + if (tagName == '') return undefined + let matched = tagName.match(dynamicTagRegex) return matched ? { kind: matched[1], path: matched[2] } : undefined }) + // Mirrors custom_tag_matches in backend/windmill-common/src/worker.rs: a job's tag is judged on + // what it resolves to, and a placeholder in a custom tag stands for any text, unless two of them + // are tied (same kind, and the same path or one inside the other). A tied entry admits only a + // tag written exactly like it; an untied one is a pattern fenced only by its fixed text. + function placeholdersOf(entry: string) { + return [...entry.matchAll(new RegExp(dynamicTagRegex.source, 'g'))].map((m) => ({ + kind: m[1], + path: m[2], + start: m.index, + end: m.index + m[0].length + })) + } + function isTied(entry: string) { + const placeholders = placeholdersOf(entry) + return placeholders.some((a, i) => + placeholders + .slice(i + 1) + .some( + (b) => + a.kind == b.kind && + (a.path == b.path || a.path.startsWith(b.path + '.') || b.path.startsWith(a.path + '.')) + ) + ) + } + // With nothing fixed at its start or its end, a pattern reaches almost any tag. + function reachesAnyTag(entry: string) { + const placeholders = placeholdersOf(entry) + return ( + placeholders.length > 0 && + !isTied(entry) && + placeholders[0].start == 0 && + placeholders[placeholders.length - 1].end == entry.length + ) + } + let dynamicTagTied = $derived(isTied(tagName)) + let dynamicTagReachesAnyTag = $derived(reachesAnyTag(tagName)) + let extractedCustomTag = $derived.by(() => { let r = newTag.trim() if (r == '') return undefined @@ -126,6 +171,42 @@ +{#snippet reachesAnyTagWarning(scoped: boolean)} + Nothing fixed at its start or its end, so it allows anyone{scoped ? ' in its workspaces' : ''} to reach + almost any tag. We highly recommend a prefix or a suffix, ideally both, to limit its reach: for instance + gpu-$args[size] + or gpu-$args[size]-eu. +{/snippet} + +{#snippet dynamicTagInfo(scoped: boolean)} + {#if tagName.includes('$workspace')} +
Interpolated tag based on workspace id the job was created in
+ {/if} + {#if dynamicTag?.kind == 'flow_expr'} +
+ Interpolated tag based on the flow value at {dynamicTag.path}, resolved when the flow + step starts +
+ {:else if dynamicTag} +
Interpolated tag based on args input of {dynamicTag.path}
+ {/if} + {#if dynamicTagTied} +
+ Its placeholders read the same value, or one reads inside the other, so it allows only jobs + whose tag is written exactly like this +
+ {:else if dynamicTagReachesAnyTag} +
+ {@render reachesAnyTagWarning(scoped)} +
+ {:else if dynamicTag} +
+ Allows any tag it resolves to{scoped ? ' in these workspaces' : ''}, with the text around the + placeholder as written +
+ {/if} +{/snippet} +
{#each customTags as customTag} + {@const name = nameOf(customTag)} {customTag} + {#if reachesAnyTag(name)} + + {#snippet trigger()} + + {/snippet} + {#snippet content()} +
+ {@render reachesAnyTagWarning(name != customTag)} +
+ {/snippet} +
+ {/if} {#if tagEditor}
{#if extractedCustomTag} + {@const dynamic = tagName.includes('$workspace') || dynamicTag != undefined}
-
Workspace specific tag
+
+ Workspace specific {#if dynamic}dynamic tag{:else}tag{/if} +
Tag: {extractedCustomTag.tag} @@ -182,6 +284,20 @@ All workspaces except {extractedCustomTag.workspaces?.map(formatWorkspace).join(', ')} {/if}
+ {@render dynamicTagInfo(true)} + + {#if !dynamic && tagEditor} + {#if (customTags ?? []).includes(tagName)} +
+ {tagName} is also listed without workspaces, which keeps it open to every + workspace +
+ {:else} +
+ Workspaces outside this scope cannot use it, even through a dynamic tag it fits +
+ {/if} + {/if}
{:else if newTag.trim()} {#if newTag.includes('(') || newTag.includes(')') || newTag.includes('+') || newTag.includes('^') || newTag.includes('*') || ((newTag.includes('.') || newTag.includes('$args[') || newTag.includes('$flow_expr[')) && !dynamicTag)} @@ -205,17 +321,7 @@ Tag: {newTag.trim()}
- {#if newTag.includes('$workspace') && !dynamicTag} -
Interpolated tag based on workspace id the job was created in
- {/if} - {#if dynamicTag?.kind == 'flow_expr'} -
- Interpolated tag based on the flow value at {dynamicTag.path}, resolved when - the flow step starts -
- {:else if dynamicTag} -
Interpolated tag based on args input of {dynamicTag.path}
- {/if} + {@render dynamicTagInfo(false)} {/if} {/if} @@ -269,6 +375,15 @@
a
is the step id, or
$flow_expr[flow_input.a.b.c]
and
$flow_expr[flow_env.a.b.c]
. +
{#if variant !== 'drawer'}
{/if} + A dynamic tag is checked on the tag it resolves to: it is allowed when that tag is listed here, + or fits a listed dynamic tag such as +
gpu-$args[size]
, which allows any tag starting with +
gpu-
. Scope it like any other tag, e.g. +
gpu-$args[size](workspace1+workspace2)
, to allow those + tags only in some workspaces. A tag listed by its own name for some workspaces, e.g. +
gpu-secret(workspace1)
, stays limited to them even + when a dynamic tag fits it, unless it is also listed without workspaces. {/if} diff --git a/frontend/src/lib/components/ContextualVariableEditor.svelte b/frontend/src/lib/components/ContextualVariableEditor.svelte index 43277787ba..9adde76e74 100644 --- a/frontend/src/lib/components/ContextualVariableEditor.svelte +++ b/frontend/src/lib/components/ContextualVariableEditor.svelte @@ -1,7 +1,6 @@ + +{:else} +
+{/if} diff --git a/frontend/src/lib/components/RemoteDeployTargetSetting.svelte b/frontend/src/lib/components/RemoteDeployTargetSetting.svelte new file mode 100644 index 0000000000..91f876ec17 --- /dev/null +++ b/frontend/src/lib/components/RemoteDeployTargetSetting.svelte @@ -0,0 +1,111 @@ + + + + {#if !$enterpriseLicense} + + Deploying to another instance from the web UI is only available with an enterprise license + + {:else} +
+ + +
+ + {#if saved} + + {/if} +
+ {#if saved} +

+ Changing the instance URL or workspace drops every token stored for the current target, so + everyone connects again. +

+ {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index ce4d1eea5a..b37ebf356f 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -12,7 +12,6 @@ import OpenInSessionButton from './sessions/OpenInSessionButton.svelte' import { clearPageDrawerAnchor, - handOffPageDrawer, pageDrawerSessionSource, setPageDrawerAnchor } from './sessions/pageDrawerSession' @@ -102,7 +101,6 @@ * dedicated editor elsewhere: the generic form would render its configuration field by field, * and materialize a default into every one the value leaves out. */ export async function initEdit(p: string, opts?: { json?: boolean }): Promise { - if (handOffPageDrawer(RESOURCES_PATH, p)) return // A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag // would still be standing when the next drawer session ends and would swallow that one's // anchor clear. Every session starts having to clear its own. diff --git a/frontend/src/lib/components/ScriptPicker.svelte b/frontend/src/lib/components/ScriptPicker.svelte index 5bc317919a..781cb5155e 100644 --- a/frontend/src/lib/components/ScriptPicker.svelte +++ b/frontend/src/lib/components/ScriptPicker.svelte @@ -12,7 +12,9 @@ import FlowPathViewer from './flows/content/FlowPathViewer.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' - import { Code, Code2, ExternalLink, Pen, RefreshCw } from 'lucide-svelte' + import { Code, Code2, ExternalLink, Globe2, Pen, RefreshCw } from 'lucide-svelte' + import PickHubScript from './flows/pickers/PickHubScript.svelte' + import { disableHubStore } from '$lib/stores' import type { SupportedLanguage } from '$lib/common' import FlowIcon from './home/FlowIcon.svelte' import DarkModeObserver from './DarkModeObserver.svelte' @@ -32,6 +34,9 @@ allowEdit?: boolean allowView?: boolean clearable?: boolean + /** Offer picking a script from the Hub; the picked path is `hub/...`. Browses plain + * scripts only, whatever `kinds` is. */ + allowHub?: boolean /** Workspace to list runnables from. Defaults to the operating workspace (see * `useOperatingWorkspace`). */ workspace?: string @@ -48,9 +53,15 @@ allowEdit = true, allowView = true, clearable = false, + allowHub = false, workspace = undefined }: Props = $props() + let isHubPath = $derived(itemKind == 'script' && !!scriptPath?.startsWith('hub/')) + let drawerHub: Drawer | undefined = $state() + let filterText = $state('') + let hubFilter = $state('') + let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace) // Edit/View routes open in the workspace listed here, not wherever the tab lands. let wsParam = $derived( @@ -115,6 +126,21 @@ +{#if allowHub} + + + { + scriptPath = e.detail.path + dispatch('select', { path: e.detail.path, itemKind }) + drawerHub?.closeDrawer() + }} + /> + + +{/if} +
{#if options.length > 1}
@@ -145,9 +171,11 @@ } } class="grow shrink max-w-full" - {items} + items={isHubPath ? [{ value: scriptPath!, label: scriptPath! }, ...items] : items} {clearable} + bind:filterText placeholder="Pick {itemKind === 'app' ? 'an' : 'a'} {itemKind}" + bottomSnippet={allowHub && itemKind == 'script' && !$disableHubStore ? hubHint : undefined} /> {/if} @@ -212,7 +240,7 @@
{:else}
- {#if allowEdit} + {#if allowEdit && !isHubPath} +{/snippet} diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 471ed74099..da6cc9a674 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -10,7 +10,7 @@ } from '$lib/gen' import type { Folder } from '$lib/gen/types.gen' import { createEventDispatcher } from 'svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { Alert, Button, Drawer } from './common' import DrawerContent from './common/drawer/DrawerContent.svelte' import { sendUserToast } from '$lib/toast' @@ -22,11 +22,22 @@ import Toggle from './Toggle.svelte' import { Trash } from 'lucide-svelte' import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud' + import { + useOperatingWorkspace, + useOperatingUser + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() const dispatch = createEventDispatcher() let restricted = $derived( - isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin) + isDemoWorkspaceRestricted( + $operatingWorkspace, + operatingUser.current?.is_admin, + operatingUser.current?.is_super_admin + ) ) type Kind = @@ -81,7 +92,7 @@ defaultPermsLabel = `Folder f/${folderName} permissions` try { const folder: Folder = await FolderService.getFolder({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, name: folderName }) if (path !== currentPath) return @@ -134,7 +145,7 @@ const currentPath = path try { const resource = await ResourceService.getResource({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path: currentPath }) if (path !== currentPath) return @@ -164,27 +175,27 @@ } async function loadOwner() { - own = isOwner(path, $userStore!, $workspaceStore!) + own = isOwner(path, operatingUser.current!, $operatingWorkspace!) } async function loadAcls() { acls = Object.entries( - await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind }) + await GranularAclService.getGranularAcls({ workspace: $operatingWorkspace!, path, kind }) ) } async function loadGroups(): Promise { - groups = await GroupService.listGroupNames({ workspace: $workspaceStore! }) + groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! }) } async function loadUsernames(): Promise { - usernames = await UserService.listUsernames({ workspace: $workspaceStore! }) + usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! }) } async function deleteAcl(owner: string) { try { await GranularAclService.removeGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path, kind, requestBody: { owner } @@ -193,7 +204,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.removeGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path: varPath, kind: 'variable', requestBody: { owner } @@ -212,7 +223,7 @@ async function addAcl(owner: string, write: boolean) { await GranularAclService.addGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path, kind, requestBody: { owner, write } @@ -221,7 +232,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.addGranularAcls({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path: varPath, kind: 'variable', requestBody: { owner, write } @@ -251,9 +262,7 @@
{/if}
- Extra members ({acls?.length ?? 0}) + Extra members ({acls?.length ?? 0}) {#if linkedVarPaths.length > 0}
+
+
diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index b5df55e87d..fe6ccd1f27 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -7,7 +7,6 @@ import OpenInSessionButton from './sessions/OpenInSessionButton.svelte' import { clearPageDrawerAnchor, - handOffPageDrawer, pageDrawerSessionSource, setPageDrawerAnchor } from './sessions/pageDrawerSession' @@ -389,7 +388,6 @@ } export function editVariable(edit_path: string): void { - if (handOffPageDrawer(VARIABLES_PATH, edit_path)) return reset() editPath = edit_path selected = curWs! diff --git a/frontend/src/lib/components/WorkerTagSelect.svelte b/frontend/src/lib/components/WorkerTagSelect.svelte index 624aeb1bcd..503c8f7f66 100644 --- a/frontend/src/lib/components/WorkerTagSelect.svelte +++ b/frontend/src/lib/components/WorkerTagSelect.svelte @@ -92,6 +92,8 @@ } loading = false } + // A dynamic tag is filled in when the job runs, so no worker can be looked up for its text. + const dynamicTagRegex = /\$(workspace|args\[|flow_expr\[)/ let items = $derived([ // ...(tag ? ['reset to default'] : [nullTag ? `default: ${nullTag}` : '']), ...(tag && tag != '' && !(currentTags ?? []).includes(tag) ? [tag] : []), @@ -99,23 +101,25 @@ ]) let lastCheck: number | undefined = undefined - async function loadTagsToWorkerExists(tags: string[]) { - if (lastCheck && Date.now() - lastCheck < 5000) { - return - } + // Each run reads the list as it is then, so a tag typed in since the last run is looked up + // too, and a run too soon after the last one is deferred rather than dropped. + async function loadTagsToWorkerExists() { if (timeout) { clearTimeout(timeout) } + const wait = lastCheck ? 5000 - (Date.now() - lastCheck) : 0 + if (wait > 0) { + timeout = setTimeout(loadTagsToWorkerExists, wait) + return + } if (open) { tagsToWorkerExists = await WorkerService.existsWorkersWithTags({ - tags: tags.join(','), + tags: items.filter((t) => !dynamicTagRegex.test(t)).join(','), workspace: effectiveWorkspace }) lastCheck = Date.now() if (visible) { - timeout = setTimeout(() => { - loadTagsToWorkerExists(tags) - }, 5000) + timeout = setTimeout(loadTagsToWorkerExists, 5000) } } } @@ -136,7 +140,7 @@ $effect(() => { if (currentTags && open) { - loadTagsToWorkerExists(currentTags) + loadTagsToWorkerExists() } }) @@ -144,7 +148,8 @@ {#snippet startSnippet({ item })} - {#if tagsToWorkerExists} + + {#if tagsToWorkerExists && !item.__is_create && item.value in tagsToWorkerExists} {#if tagsToWorkerExists[item.value]} {#snippet text()} @@ -162,7 +167,7 @@ {/if} {/if} {/snippet} -
+
{#if !noLabel}
{placeholder ?? 'tag'}
{/if} @@ -176,22 +181,30 @@ placeholder={nullTag ? nullTag : (placeholder ?? 'lang default')} items={safeSelectItems(items)} bind:value={() => tag, (value) => ((tag = value), dispatch('change', value))} + onCreateItem={(value) => ((tag = value), dispatch('change', value))} + createText="Press Enter to use this tag" {startSnippet} bottomSnippet={refreshAll} />
{#snippet refreshAll()} - +
+ + $workspace, $args[…] and $flow_expr[…] are filled in when + the job runs, and must land on an allowed tag + + +
{/snippet} diff --git a/frontend/src/lib/components/agentTrace.test.ts b/frontend/src/lib/components/agentTrace.test.ts index 3297a99d82..7d1dcf3429 100644 --- a/frontend/src/lib/components/agentTrace.test.ts +++ b/frontend/src/lib/components/agentTrace.test.ts @@ -45,7 +45,7 @@ describe('buildAgentTrace', () => { result: '{"eu-central-1":0.184}', jobId: '0199-job' }, - { kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined } + { kind: 'assistant', content: 'eu-central-1 is down.' } ]) }) @@ -76,8 +76,8 @@ describe('buildAgentTrace', () => { // The worker splits a search the same way: a `tool` message tagged web_search // carrying a constant sentence, then the assistant turn that carries the - // citations. The search row therefore has nothing of its own to show. - it('records the search and puts its citations on the turn that follows', () => { + // citations, which move onto the search so it shows what it found. + it('puts the citations of the turn that follows on the search', () => { const entries = buildAgentTrace([ { role: 'tool', @@ -92,12 +92,11 @@ describe('buildAgentTrace', () => { } ]) expect(entries).toEqual([ - { kind: 'search' }, { - kind: 'assistant', - content: 'Postgres 17 changed the default.', + kind: 'search', sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] - } + }, + { kind: 'assistant', content: 'Postgres 17 changed the default.' } ]) }) @@ -118,7 +117,7 @@ describe('buildAgentTrace', () => { }) describe('splitFinalAnswer', () => { - it('moves the answering turn out of the trace, with its citations', () => { + it('moves the answering turn out of the trace, leaving its citations on the search', () => { const entries = buildAgentTrace([ { role: 'tool', content: 'Used websearch tool', agent_action: { type: 'web_search' } }, { @@ -128,10 +127,12 @@ describe('splitFinalAnswer', () => { agent_action: { type: 'message' } } ]) - expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual({ - trace: [{ kind: 'search' }], - sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] - }) + expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual([ + { + kind: 'search', + sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] + } + ]) }) // A run whose last turn returned a tool call and no text leaves its answer @@ -152,12 +153,12 @@ describe('splitFinalAnswer', () => { } } ]) - expect(splitFinalAnswer(entries, 'Let me check.').trace).toEqual([entries[1]]) + expect(splitFinalAnswer(entries, 'Let me check.')).toEqual([entries[1]]) }) it('leaves the trace whole when the output is not a turn of its own', () => { const entries = buildAgentTrace(messages) - expect(splitFinalAnswer(entries, { rows: 3 })).toEqual({ trace: entries }) + expect(splitFinalAnswer(entries, { rows: 3 })).toEqual(entries) }) }) diff --git a/frontend/src/lib/components/agentTrace.ts b/frontend/src/lib/components/agentTrace.ts index ba0cb78d4e..392e8b00c9 100644 --- a/frontend/src/lib/components/agentTrace.ts +++ b/frontend/src/lib/components/agentTrace.ts @@ -8,10 +8,10 @@ import type { AgentMessage } from './aiAgentResult' * are shown as inputs, while the trace is what the agent did with them. */ export type AgentTraceEntry = - | { kind: 'assistant'; content: string; sources?: WebSearchSource[] } - /** A search records only that one happened: the worker writes a constant - * sentence, and the citations ride on the assistant turn that follows. */ - | { kind: 'search' } + | { kind: 'assistant'; content: string } + /** The worker records only that a search happened. Its citations ride on the assistant + * turn that follows, and are moved here so the search shows what it found. */ + | { kind: 'search'; sources?: WebSearchSource[] } | { kind: 'tool' name: string @@ -99,8 +99,14 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { continue } const content = contentText(message.content) + // The worker writes a provider call's search right before that call's answer, and + // only a call that searched carries annotations. + const previous = entries[entries.length - 1] + if (message.role === 'assistant' && previous?.kind === 'search' && !previous.sources) { + previous.sources = sourcesOf(message) + } if (message.role === 'assistant' && content !== '') { - entries.push({ kind: 'assistant', content, sources: sourcesOf(message) }) + entries.push({ kind: 'assistant', content }) } } return entries @@ -108,27 +114,21 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { /** * Separates the turn that produced the output from the rest of the trace, so the - * answer is rendered once with the citations that belong to it. Found by content - * and searched from the end: a run whose last turn returned a tool call leaves its - * answer mid-trace, where inspecting only the final entry prints it twice. + * answer is rendered once. Found by content and searched from the end: a run whose + * last turn returned a tool call leaves its answer mid-trace, where inspecting only + * the final entry prints it twice. */ -export function splitFinalAnswer( - entries: AgentTraceEntry[], - output: unknown -): { trace: AgentTraceEntry[]; sources?: WebSearchSource[] } { +export function splitFinalAnswer(entries: AgentTraceEntry[], output: unknown): AgentTraceEntry[] { if (typeof output !== 'string') { // A schema-shaped output is rendered by the result viewer itself and matches // no turn, so the whole trace stands. - return { trace: entries } + return entries } for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] if (entry.kind === 'assistant' && entry.content === output) { - // The citations are an annotation on that turn, so moving the turn without - // them would leave a run that shows a web search ran and no source for - // what it answered. - return { trace: [...entries.slice(0, i), ...entries.slice(i + 1)], sources: entry.sources } + return [...entries.slice(0, i), ...entries.slice(i + 1)] } } - return { trace: entries } + return entries } diff --git a/frontend/src/lib/components/apps/components/display/table/utils.dom.test.ts b/frontend/src/lib/components/apps/components/display/table/utils.dom.test.ts new file mode 100644 index 0000000000..0e039139b0 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/table/utils.dom.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import type { ICellRendererParams } from 'ag-grid-community' +import { defaultCellRenderer } from './utils' + +function render(value: unknown) { + const renderer = defaultCellRenderer('link')! + return renderer({ value } as ICellRendererParams) as HTMLElement +} + +// AG Grid writes a renderer's string output to innerHTML, and link cells hold run results. +describe('link cell renderer', () => { + it('renders a link without parsing its label or href as markup', () => { + const el = render({ href: 'https://x.test/">', label: 'hi' }) + expect(el.tagName).toBe('A') + expect(el.textContent).toBe('hi') + expect(el.getAttribute('href')).toBe('https://x.test/">') + expect(el.children).toHaveLength(0) + }) + + it.each([ + { href: 'javascript:alert(1)', label: 'click' }, + 'javascript:alert(1)', + { href: 'data:text/html,', label: 'click' } + ])('renders a non-navigating href %j as plain text', (value) => { + const el = render(value) + expect(el.tagName).toBe('SPAN') + expect(el.hasAttribute('href')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/apps/components/display/table/utils.ts b/frontend/src/lib/components/apps/components/display/table/utils.ts index 015cdc54db..8b85a5894c 100644 --- a/frontend/src/lib/components/apps/components/display/table/utils.ts +++ b/frontend/src/lib/components/apps/components/display/table/utils.ts @@ -8,6 +8,7 @@ import type { TableAction } from '$lib/components/apps/editor/component' import { mount, unmount } from 'svelte' import { Button } from '$lib/components/common' import { Trash2 } from 'lucide-svelte' +import { safeHref } from '$lib/utils/safeHref' export type WindmillColumnDef = ColDef & { _isActionsColumn?: boolean @@ -110,15 +111,25 @@ export function isLinkObject(value: any): value is LinkObject { export function defaultCellRenderer(cellRendererType: string) { if (cellRendererType === 'link') { + // AG Grid assigns a string returned by a cell renderer to innerHTML, and cell values are + // run results, so the link is built as a DOM node that never parses them as markup. return (params: ICellRendererParams) => { - if (isLinkObject(params.value)) { - const value = params.value - return `${value.label}` - } else if (params.value) { - return `${params.value}` - } else { + if (!params.value) { return params.value } + const { href, label } = isLinkObject(params.value) + ? params.value + : { href: params.value, label: params.value } + const allowedHref = safeHref(String(href), window.location.href) + const el = document.createElement(allowedHref ? 'a' : 'span') + el.textContent = String(label) + if (allowedHref) { + el.setAttribute('href', allowedHref) + el.setAttribute('target', '_blank') + el.setAttribute('rel', 'noopener noreferrer') + el.className = 'underline' + } + return el } } else { return undefined diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 96d341dc72..4090a39dbb 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -149,6 +149,7 @@ JOBS: 'jobs', JOBS_CANCEL: 'jobs.cancel', JOBS_FORCE_CANCEL: 'jobs.force_cancel', + JOBS_RUN_NOW: 'jobs.run_now', JOBS_DISAPPROVAL: 'jobs.disapproval', JOBS_DELETE: 'jobs.delete', JOBS_SHARE_PUBLICLY: 'jobs.share_publicly', diff --git a/frontend/src/lib/components/common/actionRow/ActionRow.svelte b/frontend/src/lib/components/common/actionRow/ActionRow.svelte index 811137d5dd..5a38513028 100644 --- a/frontend/src/lib/components/common/actionRow/ActionRow.svelte +++ b/frontend/src/lib/components/common/actionRow/ActionRow.svelte @@ -42,7 +42,7 @@ {@render middle?.()} {/if}
-
+
{#if right} {@render right?.()} {/if} diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 642d1366f6..886c14042b 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -71,6 +71,16 @@ } } + // The entry follows the prop for as long as the overlay is open: a dialog opened before + // the AI chat still has to rise above it when the chat is opened underneath it. + $effect(() => { + const wanted = open && minZIndex > 0 ? minZIndex : undefined + untrack(() => { + if (wanted !== undefined) minZIndexEntries[id] = wanted + else delete minZIndexEntries[id] + }) + }) + // A disposable can be unmounted while still open, by an ancestor that tears its whole // subtree down. Its id would then sit on the stack forever, and since the topmost entry // arbitrates Escape, every overlay opened afterwards would stop answering it. @@ -84,10 +94,10 @@ offset = initialOffset if (stack.val.includes(id)) { stack.val = stack.val.filter((drawer) => drawer !== id) - if (minZIndex > 0) { - delete minZIndexEntries[id] - } } + // Unconditionally: `minZIndex` may have fallen to 0 since the entry was written (the + // chat closed), and a leftover entry keeps every later overlay elevated for good. + delete minZIndexEntries[id] } export function isOpen() { diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index b9e57740b9..6d7c8fdd0f 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -10,6 +10,9 @@ import { fade } from 'svelte/transition' import { zIndexes } from '$lib/zIndexes' import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte' + import Disposable from '../drawer/Disposable.svelte' + import { overlayHostActive, setTopmostSurface } from '../overlayHost.svelte' + import { untrack } from 'svelte' interface Props { title: string @@ -30,6 +33,9 @@ * window, so a stacked pair would both close on one press; set it * false on the underlying modal while its child is up. */ closeOnEscape?: boolean + /** Blocks every way out — the header ✕, Escape and an outside click — for a dialog + * holding work in flight that a dismissal would orphan. */ + preventDismiss?: boolean /** Wider side padding and a lighter title, for a dialog whose body is a form rather * than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */ formStyling?: boolean @@ -51,10 +57,11 @@ contentClasses = '', closeOnOutsideClick = true, closeOnEscape = true, + preventDismiss = false, formStyling = false, headerLeft, headerRight, - children + children: body }: Props = $props() const widthMap = { @@ -84,8 +91,30 @@ isOpen = true } + // Registered in the disposable stack so a drawer opened from inside the dialog (a + // resource picker's editor) stacks above it, and so Escape and outside clicks aimed + // at that drawer do not close the dialog underneath. + let disposable: Disposable | undefined = $state(undefined) + const hostActive = overlayHostActive() + setTopmostSurface(() => disposable?.isTopmost() ?? true) + $effect(() => { + isOpen + untrack(() => (isOpen ? disposable?.openDrawer() : disposable?.closeDrawer())) + }) + + /** Whether this dialog was the overlay on top when the interaction started. `clickOutside` + * awaits its async `exclude` before reporting, and an overlay above closes itself in that + * gap — by the time the report lands, the dialog looks topmost and would close with it. */ + let topmostAtPress = true + function snapshotTopmost() { + topmostAtPress = disposable?.isTopmost() ?? true + } + function handleKeyDown(event: KeyboardEvent) { - if (!isOpen || !closeOnEscape) return + if (!isOpen || !closeOnEscape || preventDismiss) return + // Hidden hosts stay mounted and still receive window keys — see overlayHost. + if (!hostActive()) return + if (!(disposable?.isTopmost() ?? true)) return if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() @@ -97,79 +126,94 @@ return fade(node, { duration: 200 }) } - // Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so - // the dialog isn't hidden behind it; otherwise keep the default modal - // stacking just above disposables (zIndexes.disposables). - const overlayZIndex = $derived( - chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10 - ) + // Elevate above the AI chat panel while chat is open so the dialog isn't hidden behind it. + const minZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : 0) - + { + // A keyboard-activated control emits a click with no pointer event before it (`detail` + // 0), so the pointerdown snapshot would be whatever a previous interaction left. + if (e.detail === 0) snapshotTopmost() + }} +/> -{#if isOpen} - -
-
+ + {#snippet children({ zIndex })} + {#if isOpen} +
closeOnOutsideClick && close() - }} + class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'} + style="z-index: {zIndex}" + transition:fadeFast|local > - -
- -

{title}

-
+
+
+ closeOnOutsideClick && !preventDismiss && topmostAtPress && close(), + // A dropdown opened from inside the dialog portals its menu out of it, so a + // click on one of its items lands outside this node and would close the + // dialog under the menu. + exclude: async () => + Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[] + }} + > + +
-
- {@render headerLeft?.()} -
-
- - {@render headerRight?.()} -
- +

{title}

+
+ +
+ {@render headerLeft?.()} +
+
+ + {@render headerRight?.()} +
+ +
+
+ + + +
{})} + > + {@render body?.()} +
- - - -
{})} - > - {@render children?.()} -
-
+
-
-
- -{/if} + + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/common/stepper/Stepper.svelte b/frontend/src/lib/components/common/stepper/Stepper.svelte index de6303cedc..5b13d9ddfc 100644 --- a/frontend/src/lib/components/common/stepper/Stepper.svelte +++ b/frontend/src/lib/components/common/stepper/Stepper.svelte @@ -12,6 +12,9 @@ allowStepNavigation?: boolean /** Compact variant, for steering a dialog rather than a full page. */ small?: boolean + /** Steps this run does not go through, greyed out and not clickable — a step the + * chosen path skips rather than one not reached yet. */ + disabledIndices?: number[] } let { @@ -21,7 +24,8 @@ statusByStep = [], hasValidations = false, allowStepNavigation = false, - small = false + small = false, + disabledIndices = [] }: Props = $props() const dispatch = createEventDispatcher() @@ -75,15 +79,20 @@ {#each tabs ?? [] as step, index} + {@const disabled = disabledIndices.includes(index)}
  • { - dispatch('click', { index }) + if (!disabled) dispatch('click', { index }) }} > {#if statusByStep[index] === 'pending'} @@ -94,7 +103,9 @@ small ? 'h-4 w-4 rounded-full flex items-center justify-center text-2xs' : 'h-6 w-6 rounded-full flex items-center justify-center text-xs', - getStepColor(index, selectedIndex, statusByStep, maxReachedIndex) + disabled + ? 'bg-gray-200' + : getStepColor(index, selectedIndex, statusByStep, maxReachedIndex) )} class:font-bold={selectedIndex === index} > @@ -105,7 +116,11 @@ {step} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index cf27a45d89..2d88d1deb7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -3,6 +3,7 @@ import AIChatMessage from './AIChatMessage.svelte' import AppAvailableContextList from './AppAvailableContextList.svelte' import ChatContextPicker from './ChatContextPicker.svelte' + import WorkspaceMentionPicker from './WorkspaceMentionPicker.svelte' import { type Snippet } from 'svelte' import { AlertTriangle, @@ -270,6 +271,33 @@ // Shared with the agent run viewer, which needs the same programmatic-scroll // guard for the same reason. const sticker = createBottomSticker() + + // Per message: whether it is the last answer of its turn — per flow step run, since each + // run's last answer carries the only link to that run. Keyed on the answer's own job (a step + // label repeats when a step runs in a loop), and on answers rather than the next row: a + // flow's tool rows have their own job, and a stopped turn can end on a tool row. + // None in a turn still running, paused on the user included (their reply lands as a tool + // result, so a row shown during the pause would vanish again): which answer ends the turn is + // unknown until it does, and one followed by a tool call would leave the row's blank gap above + // it. A manual compaction loads without a turn of its own, so the last turn keeps its row. + const showsAnswerActions = $derived.by(() => { + const shows: boolean[] = new Array(messages.length).fill(false) + const answeredLater = new Set() + let inRunningTurn = chatHost.loading && !chatHost.compacting + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role === 'user' || message.role === 'summary') { + answeredLater.clear() + inRunningTurn = false + } else if (message.role === 'assistant' && message.content) { + const run = message.jobId ?? message.stepName + shows[i] = !inRunningTurn && !answeredLater.has(run) + answeredLater.add(run) + } + } + return shows + }) + function scrollDown() { sticker.scrollToEnd(scrollElement) } @@ -548,6 +576,11 @@ await imageWork } + function mentionWorkspaceItem(element: ContextElement) { + void aiChatInput?.addContextToSelection(element) + aiChatInput?.insertMention(element.title) + } + function onFolderInputChange(e: Event) { const input = e.currentTarget as HTMLInputElement // webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups @@ -825,6 +858,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {availableContext} bind:editingMessageIndex isLast={messageIndex === messages.length - 1} + showAnswerActions={showsAnswerActions[messageIndex]} /> {/each} {#if freeTierExhausted} @@ -1055,6 +1089,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> } ] : []), + ...(inGlobal + ? [ + { + displayName: 'Mention file', + icon: AtSign, + customSubmenu: WorkspaceMentionPicker, + customSubmenuProps: { + onSelect: mentionWorkspaceItem, + onAfterClose: () => aiChatInput?.focusInput() + } + } + ] + : []), ...(skillItems ? [ { diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 6f15937cf2..bcea3a26d6 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -14,7 +14,7 @@ import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext' import { getChatViewHost } from './chatViewHost' import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox' - import { formatMention } from './mention' + import { formatMention, hasMention, removeMentionFromText } from './mention' import { twMerge } from 'tailwind-merge' import { tick, untrack, type Snippet } from 'svelte' import Portal from '$lib/components/Portal.svelte' @@ -588,40 +588,33 @@ const contextKey = contextElementKey - /** Append `@title` to the textarea so the button-picker path stays in - * sync with the inline `@` mention path — both leave a visible - * token tied to the selectedContext entry, which the textarea diffs on - * to auto-remove items when the user deletes them. No-op when the - * mention is already present so re-picking the same item doesn't - * leave duplicate tokens. */ + /** Insert `@title` so button/menu picker paths stay in sync with + * the inline `@` mention path — both leave a visible token tied + * to the selectedContext entry, which the textarea diffs on to + * auto-remove items when the user deletes them. No-op when the same + * standalone mention is already present, so picking the same item + * again doesn't leave duplicate tokens. */ export function insertMention(title: string) { - const target = `@${title}` - if (draft.text.split(/\s+/).includes(target)) return + if (hasMention(draft.text, title)) return + if (contextTextareaComponent) { + void contextTextareaComponent.insertMention(title) + return + } const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' ' - draft.text = `${draft.text}${sep}${target} ` + draft.text = `${draft.text}${sep}${formatMention(title)} ` } /** Strip every `@title` token from the textarea — used when the user * deletes the corresponding badge so the badge X-button mirrors the - * inverse (text-delete-to-badge-remove) sync. Only matches `@title` as a - * standalone token (boundary on both sides) so substring matches don't - * bleed into other words; only the whitespace adjacent to the removed - * mention is collapsed so unrelated double-spaces stay intact. */ + * inverse (text-delete-to-badge-remove) sync. Only matches tokens whose + * `@` starts a word, so embedded text like `owner@app.ts` is left alone + * while punctuation and non-spacing text after the token still work. */ export function removeMention(title: string) { // Pre-zap the textarea's mention diff snapshot so the upcoming strip // doesn't refire the removal effect on a same-title sibling — the host // has already mutated `selectedContext` to drop the targeted entry. contextTextareaComponent?.unsyncMention(title) - const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`(^|\\s)@${escaped}(\\s|$)`, 'g') - draft.text = draft.text.replace(re, (_m, lead, trail) => { - // Boundary on at least one side → drop the mention entirely. - if (!lead || !trail) return '' - // Middle of text: keep ONE of the bracketing whitespace chars so - // the surviving tokens are still separated; preserve the leading - // one verbatim so newlines/tabs aren't downgraded to spaces. - return lead - }) + draft.text = removeMentionFromText(draft.text, title) } export function focusInput() { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 922f64ad93..6e51f80601 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -110,6 +110,7 @@ import type { Selection } from 'monaco-editor' import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop' +import { FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE, OutputTokenLimitError } from './outputTokenLimit' import { sanitizeToolCallArguments } from './toolCallArguments' import { billedTokens, normalizeContextUsage, type ChatTokenUsage } from './tokenUsage' import { logAiUsage } from '$lib/utils/aiUsageReporter' @@ -124,28 +125,34 @@ import { isWebSearchEnabledForProvider } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' +import { resolveSessionAccess } from './global/sessionAccess' +import type { SessionAccess } from './sessionCapabilities' +import { filterSessionTools } from './global/sessionToolset' +import { + listFolderInstructions, + type FolderInstruction, + type FolderInstructionsDelivery +} from './folderInstructions' import { - globalToolsFor, loadWorkspaceSkills, - prepareGlobalSystemMessage, resolveGlobalPromptIdentity, type GlobalPromptIdentity, prepareGlobalUserMessage, type AiSkillListItem, type ChatCommandItem, type SessionPromptContext, - getSessionContextPromptSection, type GlobalToolHelpers, type GlobalActivePreviewContext } from './global/core' +import { + assembleGlobalSystemMessage, + assembleGlobalTools, + type GlobalAssemblyOpts +} from './global/globalAssembly' import { formatChatJobCompletion } from './datatableTools' import { isGlobalAiEnabled } from './global/gate' -import { createMcpTools, loadMcpServers, type McpServer } from './global/mcpTools' -import { - pipelineTools, - getPipelinePromptSection, - type PipelineAIChatHelpers -} from './pipeline/core' +import { loadMcpServers, type McpServer } from './global/mcpTools' +import type { PipelineAIChatHelpers } from './pipeline/core' import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userScopedStorage' import { getLocalSetting, storeLocalSetting } from '$lib/utils' import { AttachedFilesStore } from './files/attachedFiles.svelte' @@ -377,6 +384,10 @@ function isImageRejection(err: unknown, models: (string | undefined)[] = []): bo } function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean): string { + // The request went through; only its response was cut short. + if (err instanceof OutputTokenLimitError) { + return get(copilotInfo).freeTier ? FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE : err.message + } const errorMessage = err instanceof Error ? err.message : typeof err === 'string' ? err : undefined const message = errorMessage @@ -693,6 +704,10 @@ export class AIChatManager implements ChatViewHost { // needs the preview pane; the global side-panel chat leaves it false. Reactive because // `planModeAvailable` derives from it. isSessionChat = $state(false) + // Undefined until a send or the assistant settings modal resolves it. Reactive: both + // tool views derive from it. + private sessionAccess = $state(undefined) + private sessionAccessGeneration = 0 autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) autoAcceptEditsActive = $derived( this.autoAcceptEditsAvailable && @@ -714,7 +729,23 @@ export class AIChatManager implements ChatViewHost { role: 'system', content: '' }) - tools = $state[]>([]) + /** The sources the current mode assembled, concatenated. Private, because neither view + * over it is this list: a consumer reading it would advertise a tool set no request ever + * carries. Raw: every write replaces the list, and a deep proxy would reach the tool + * defs, which the session filter copies with `structuredClone` — that throws on a proxy. */ + #assembledTools = $state.raw[]>([]) + /** Both views narrow the same way — concatenate every source, then filter — so a tool + * reaching either one can never arrive unfiltered. A session withholds what its user's + * capabilities do not cover; elsewhere `sessionAccess` is unset and nothing is dropped. */ + #shipped = (planTools: Tool[]): Tool[] => + filterSessionTools([...this.#assembledTools, ...planTools], this.sessionAccess) + /** What the request carries: the assembled tools plus the plan-mode transition the current + * posture offers. Read by the request path and by the YOLO disclosure. */ + tools: Tool[] = $derived(this.#shipped(this.planMode.tools)) + /** What the assistant can call in this session, posture-independent: both plan-mode + * transitions, whichever one is offered right now. A reference answer, so flipping the + * autonomy picker must not change it. */ + availableTools: Tool[] = $derived(this.#shipped(this.planMode.availableTools)) helpers = $state(undefined) scriptEditorOptions = $state(undefined) @@ -1323,6 +1354,13 @@ export class AIChatManager implements ChatViewHost { globalSkills = $state([]) private globalSkillsRefreshId = 0 + // The `ai_instruction` resources readable in the operating workspace, loaded + // alongside skills. The prompt names their folders; the tool loop delivers each + // one's body the first time a call touches its folder, recording which call did. + private globalFolderInstructions: FolderInstruction[] = [] + private globalFolderInstructionsRefreshId = 0 + private folderInstructionsDeliveredBy = new Map() + // External MCP servers the user connected (resources of type `mcp`). Loaded // asynchronously alongside skills; the MCP tools are only registered when // this is non-empty, so a workspace with no connection pays no schema cost @@ -1414,10 +1452,12 @@ export class AIChatManager implements ChatViewHost { typeof this.systemMessage.content === 'string' ? this.systemMessage.content.length / tokenPerCharacter : 0 + const tools = this.tools + // Counts each tool's shared `def`, not the per-chat one `schemaFor` builds at send time, + // so a flow's input schema is missed. Accepted: the estimate only stands in until the + // provider reports usage, and the compaction trigger's headroom absorbs the gap. const toolTokens = - this.tools.length > 0 - ? JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter - : 0 + tools.length > 0 ? JSON.stringify(tools.map((t) => t.def)).length / tokenPerCharacter : 0 return systemTokens + toolTokens } @@ -1761,7 +1801,7 @@ export class AIChatManager implements ChatViewHost { try { this.apiTools = await loadApiTools() if (this.mode === AIMode.API) { - this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] + this.#assembledTools = [searchDocsTool, readDocsPageTool, ...this.apiTools] } } catch (err) { console.error('Error loading api tools', err) @@ -2319,8 +2359,9 @@ export class AIChatManager implements ChatViewHost { } ) { if (!isAIModeVisible(mode)) return - // A session chat is GLOBAL for its whole life, and the plan gate reads that mode: moving - // it lifts the gate on a session the user still has set to Plan. + // A session chat is GLOBAL for its whole life, and two things read that mode: moving it + // lifts the plan gate on a session the user still has set to Plan, and hands the + // capability filter tools that declare no `requires`, so it fails closed to nothing. if (this.isSessionChat && mode !== AIMode.GLOBAL) { console.error(`Refusing to move a session chat to ${mode} mode: sessions are GLOBAL-only.`) return @@ -2343,7 +2384,7 @@ export class AIChatManager implements ChatViewHost { customPrompt ) this.systemMessage.content = this.systemMessage.content - this.tools = [...prepareScriptTools(currentModel, lang, context)] + this.#assembledTools = [...prepareScriptTools(currentModel, lang, context)] this.helpers = { getScriptOptions: () => { return { @@ -2375,7 +2416,7 @@ export class AIChatManager implements ChatViewHost { this.systemMessage = prepareFlowSystemMessage(customPrompt) this.systemMessage.content = this.systemMessage.content this.appendFlowAiAgentProviders(this.systemMessage) - this.tools = [...flowTools] + this.#assembledTools = [...flowTools] this.helpers = { ...(this.flowAiChatHelpers ?? {}), getWorkspaceMutationTarget: this.getFlowWorkspaceMutationTarget @@ -2383,52 +2424,37 @@ export class AIChatManager implements ChatViewHost { } else if (mode === AIMode.NAVIGATOR) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareNavigatorSystemMessage(customPrompt) - this.tools = [this.changeModeTool, ...navigatorTools] + this.#assembledTools = [this.changeModeTool, ...navigatorTools] this.helpers = {} } else if (mode === AIMode.ASK) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAskSystemMessage(customPrompt) - this.tools = [...askTools] + this.#assembledTools = [...askTools] this.helpers = {} } else if (mode === AIMode.API) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareApiSystemMessage(customPrompt) - this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] + this.#assembledTools = [searchDocsTool, readDocsPageTool, ...this.apiTools] this.helpers = {} } else if (mode === AIMode.GLOBAL) { this.configureGlobalMode() void this.refreshGlobalIdentity() void this.refreshGlobalSkills() + void this.refreshGlobalFolderInstructions() void this.refreshMcpServers() } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) - this.tools = [...getAppTools()] + this.#assembledTools = [...getAppTools()] this.helpers = this.appAiChatHelpers } } - // Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild - // the system message so the next chat-loop iteration advertises them. Ignore - // stale resolves so workspace changes cannot overwrite newer skills. - // Build the global-mode system message, tools, and helpers, layering on the - // pipeline surface when a /pipeline editor has registered helpers. Centralized - // so changeMode, refreshGlobalSkills, and setPipelineHelpers stay consistent — - // each rebuild would otherwise drop the pipeline augmentation the others added. - // // Public because it is purely local, unlike `changeMode(GLOBAL)`, which also // fires the three network refreshes. configureGlobalMode = () => { - const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { - previewTools: this.isSessionChat, - user: this.globalIdentity, - skills: this.globalSkills, - mcpServers: this.mcpServers - }) - const sessionCtx = this.sessionContextResolver?.() - if (sessionCtx) { - systemMessage.content += getSessionContextPromptSection(sessionCtx) - } + const pipeline = this.pipelineAiChatHelpers + const opts = this.globalAssemblyOpts() const baseHelpers: GlobalToolHelpers = { // A session targets its own fixed (possibly forked) workspace, so capture it for // permission gating. The global side-panel chat follows the live navigation @@ -2438,13 +2464,13 @@ export class AIChatManager implements ChatViewHost { sessionId: this.sessionId, operatingWorkspace: this.operatingWorkspace, artifacts: this.artifacts, + access: this.sessionAccess, getChatId: () => this.historyManager.getCurrentChatId(), openArtifact: this.openArtifact } : {}), testActiveFlow: async (storagePath: string, args?: Record, memoryId?: string) => this.flowEditorFor(storagePath)?.testFlow(args, memoryId), - getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined), attachedFiles: this.attachedFiles, getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '', setUserInstructions: (instructions: string) => { @@ -2458,24 +2484,26 @@ export class AIChatManager implements ChatViewHost { this.rebuildGlobalSystemMessage() } } - const pipeline = this.pipelineAiChatHelpers - const mcpTools = createMcpTools(this.mcpServers) - if (pipeline) { - systemMessage.content += getPipelinePromptSection(pipeline.getPipelineContext()) - this.tools = [ - ...globalToolsFor({ sessionPreview: this.isSessionChat }), - ...pipelineTools, - ...mcpTools - ] - this.helpers = { ...baseHelpers, pipeline } - } else { - this.tools = [...globalToolsFor({ sessionPreview: this.isSessionChat }), ...mcpTools] - this.helpers = baseHelpers - } - this.systemMessage = systemMessage + this.#assembledTools = assembleGlobalTools(opts) + this.helpers = pipeline ? { ...baseHelpers, pipeline } : baseHelpers + this.systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), opts) this.syncArtifactsSession() } + private globalAssemblyOpts = (): GlobalAssemblyOpts => ({ + previewTools: this.isSessionChat, + user: this.globalIdentity, + skills: this.globalSkills, + folderInstructions: this.globalFolderInstructions, + mcpServers: this.mcpServers, + access: this.sessionAccess, + sessionContext: this.sessionContextResolver?.(), + pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext() + }) + + // Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild + // the system message so the next chat-loop iteration advertises them. Ignore + // stale resolves so workspace changes cannot overwrite newer skills. refreshGlobalSkills = async (workspace = this.operatingWorkspace ?? '') => { const refreshId = ++this.globalSkillsRefreshId const skills = await loadWorkspaceSkills(workspace) @@ -2492,6 +2520,25 @@ export class AIChatManager implements ChatViewHost { } } + // Same shape as refreshGlobalSkills. + refreshGlobalFolderInstructions = async (workspace = this.operatingWorkspace ?? '') => { + const refreshId = ++this.globalFolderInstructionsRefreshId + let instructions: FolderInstruction[] = [] + try { + instructions = await listFolderInstructions(workspace) + } catch (e) { + console.error('Failed to load folder instructions', e) + } + if (refreshId !== this.globalFolderInstructionsRefreshId) { + return + } + this.globalFolderInstructions = + workspace === (this.operatingWorkspace ?? '') ? instructions : [] + if (this.mode === AIMode.GLOBAL) { + this.configureGlobalMode() + } + } + // Same shape as refreshGlobalSkills. An identity that resolves after the operating // workspace moved describes the workspace left behind, so it is dropped, not installed. refreshGlobalIdentity = async (workspace = this.operatingWorkspace ?? '') => { @@ -2543,6 +2590,23 @@ export class AIChatManager implements ChatViewHost { this.systemMessage = { ...target, content: `${target.content}\n\n${section}` } } + // Same shape as refreshGlobalSkills. Re-resolved per send rather than once for the + // session's life: the operating workspace can change between sends, and a transient + // failure resolves fail-open, so the next message re-asks rather than keeping that answer. + refreshSessionAccess = async (workspace = this.operatingWorkspace ?? '') => { + if (!this.isSessionChat || !workspace) { + this.sessionAccess = undefined + return + } + const generation = ++this.sessionAccessGeneration + const access = await resolveSessionAccess(workspace) + if (generation !== this.sessionAccessGeneration) return + this.sessionAccess = workspace === (this.operatingWorkspace ?? '') ? access : undefined + if (this.mode === AIMode.GLOBAL) { + this.configureGlobalMode() + } + } + // Rebuild the GLOBAL system message in place so an updated user instruction (persisted by // the update_user_instructions tool) is picked up on the next chat-loop iteration, which // re-reads this.systemMessage via a getter. @@ -2550,24 +2614,10 @@ export class AIChatManager implements ChatViewHost { if (this.mode !== AIMode.GLOBAL) { return } - const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { - previewTools: this.isSessionChat, - user: this.globalIdentity, - skills: this.globalSkills, - mcpServers: this.mcpServers - }) - // Preserve the session-state and active pipeline-editor augmentations that - // configureGlobalMode adds — otherwise update_user_instructions (which calls - // this) would drop them mid-session. - const sessionCtx = this.sessionContextResolver?.() - if (sessionCtx) { - systemMessage.content += getSessionContextPromptSection(sessionCtx) - } - const pipeline = this.pipelineAiChatHelpers - if (pipeline) { - systemMessage.content += getPipelinePromptSection(pipeline.getPipelineContext()) - } - this.systemMessage = systemMessage + this.systemMessage = assembleGlobalSystemMessage( + getCustomPromptParts(AIMode.GLOBAL), + this.globalAssemblyOpts() + ) } private expandGlobalSkillCommand = (instructions: string): string => { @@ -2968,7 +3018,7 @@ export class AIChatManager implements ChatViewHost { try { // Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) - // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. + // which reassigns #assembledTools, this.helpers, this.systemMessage mid-loop. const self = this // Pinned for the whole turn, like the `workspace` the loop routes through: // the global chat's operating workspace follows workspaceStore, so a switch @@ -2993,7 +3043,7 @@ export class AIChatManager implements ChatViewHost { return base }, get tools() { - return [...self.tools, ...self.planMode.tools] + return self.tools }, get helpers() { return self.helpers @@ -3060,13 +3110,8 @@ export class AIChatManager implements ChatViewHost { console.error('Failed to record AI usage', e) } }, - onBeforeIteration: async (tools, _helpers, modelProvider) => { + onBeforeIteration: async (modelProvider) => { this.lastIterationModel = modelProvider - for (const tool of tools) { - if (tool.setSchema) { - await tool.setSchema(this.helpers) - } - } } }) if (this.isSessionChat && this.sessionId && result.tokenUsage.total > 0) { @@ -3570,15 +3615,18 @@ export class AIChatManager implements ChatViewHost { return false } } - // Session chats commit their workspace in beforeSend; the identity, skills and - // MCP servers must all match the committed workspace before the system prompt is - // sent. Settling them here rather than mid-turn also keeps the prompt — the - // cached prefix of every iteration — stable for the whole request. + // Session chats commit their workspace in beforeSend; the identity, skills, folder + // instructions, MCP servers and capabilities must all match the committed workspace + // before the system prompt is sent. Settling them here rather than mid-turn also + // keeps the prompt — the cached prefix of every iteration — stable for the whole + // request. if (this.mode === AIMode.GLOBAL) { await Promise.all([ this.refreshGlobalIdentity(this.operatingWorkspace ?? ''), this.refreshGlobalSkills(this.operatingWorkspace ?? ''), - this.refreshMcpServers(this.operatingWorkspace ?? '') + this.refreshGlobalFolderInstructions(this.operatingWorkspace ?? ''), + this.refreshMcpServers(this.operatingWorkspace ?? ''), + this.refreshSessionAccess(this.operatingWorkspace ?? '') ]) } // Stop/Escape during the beforeSend pre-flight aborted this send before any @@ -4036,7 +4084,11 @@ export class AIChatManager implements ChatViewHost { ? { onJobStarted: (job) => this.registerJob(job), onJobStatus: (jobId, update) => this.updateJob(jobId, update), - onJobDetached: (jobId) => this.markJobDetached(jobId) + onJobDetached: (jobId) => this.markJobDetached(jobId), + folderInstructions: { + list: () => this.globalFolderInstructions, + deliveredBy: this.folderInstructionsDeliveredBy + } } : {}), removeToolStatus: (id) => { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index ef15df4e8d..3b8470fc2e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { writable } from 'svelte/store' import type { FlowAIChatHelpers } from './flow/core' import type { PipelineAIChatHelpers } from './pipeline/core' @@ -1679,7 +1679,7 @@ describe('AIChatManager queued messages', () => { mocks.tryGetCurrentModel.mockReturnValue(a) mocks.runChatLoop.mockImplementation(async (config: any) => { // an iteration starts on B... - await config.onBeforeIteration?.([], config.helpers, b) + await config.onBeforeIteration?.(b) // ...the user switches to C while B's request is in flight... mocks.getCurrentModel.mockReturnValue(c) mocks.tryGetCurrentModel.mockReturnValue(c) @@ -1792,7 +1792,7 @@ describe('AIChatManager queued messages', () => { // mid-loop switch to a model the deny-list doesn't know... mocks.getCurrentModel.mockReturnValue(unlistedBlind) mocks.tryGetCurrentModel.mockReturnValue(unlistedBlind) - await config.onBeforeIteration?.([], config.helpers, unlistedBlind) + await config.onBeforeIteration?.(unlistedBlind) // ...its request carries the images and the provider rejects them throw new Error('400 this model does not support image input') }) @@ -4559,3 +4559,113 @@ describe('AIChatManager cross-tab run seams', () => { expect(manager.queuedMessage).toBe('') }) }) + +describe('AIChatManager tool views', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' }) + mocks.tryGetCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' }) + clearWorkspaceRoleCache() + }) + + // Every mode assigns the private base separately. A missed site leaves it empty on a fresh + // manager, which is what these catch — silently, since nothing throws on an empty tool set. + it.each([ + AIMode.SCRIPT, + AIMode.FLOW, + AIMode.NAVIGATOR, + AIMode.ASK, + AIMode.API, + AIMode.GLOBAL, + AIMode.APP + ])('assembles tools for %s', async (mode) => { + const manager = new AIChatManager() + await manager.changeMode(mode) + expect(manager.tools.length).toBeGreaterThan(0) + expect(manager.availableTools.length).toBeGreaterThan(0) + }) + + const OPERATOR_WHOAMI = { + username: 'op', + email: 'admin@test', + is_admin: false, + is_super_admin: false, + operator: true, + groups: [], + folders: [], + folders_read: [] + } + + // The capability filter is unit-tested against a profile handed to it directly; what is + // untested there is that the manager ever hands it one. This drives a real send and reads + // the toolset off the request, so moving the resolve out of the pre-flight, or dropping + // the rebuild when it lands, fails here rather than shipping an operator write tools. + it('withholds write and preview tools from an operator for the whole request', async () => { + onTestFinished(() => mocks.whoami.mockReset()) + mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI) + let sent: { tools: string[]; prompt: string; deployKinds: string[] } | undefined + mocks.runChatLoop.mockImplementation(async (config: any) => { + const deploy = config.tools.find((t: any) => t.def.function.name === 'deploy_workspace_item') + sent = { + tools: config.tools.map((t: any) => t.def.function.name), + prompt: config.systemMessage.content, + deployKinds: deploy?.def.function.parameters.properties.type.enum ?? [] + } + return { + addedMessages: [], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + hitMaxIterations: false + } + }) + + const manager = new AIChatManager() + manager.isSessionChat = true + await manager.sendRequest({ instructions: 'add a script', mode: AIMode.GLOBAL }) + + expect(sent?.tools).toEqual(expect.arrayContaining(['list_workspace_items', 'run_script'])) + // Deploying ships — a draft can predate the role change — but only for the kinds an + // operator's token can land. + expect(sent?.deployKinds).toContain('schedule') + expect(sent?.deployKinds).not.toContain('script') + for (const withheld of ['write_script', 'test_run_script']) { + expect(sent?.tools).not.toContain(withheld) + // The prompt ships beside the tools, so the rebuild must have run after the + // profile landed — otherwise it still instructs the model to call these. + expect(sent?.prompt).not.toContain(withheld) + } + }) + + // The assistant settings modal resolves the profile on open, before any send, so the + // list it shows is the one the first request will carry, prompt included. + it('narrows the toolset and prompt when the profile resolves outside a send', async () => { + onTestFinished(() => mocks.whoami.mockReset()) + mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI) + const manager = new AIChatManager() + manager.mode = AIMode.GLOBAL + manager.isSessionChat = true + manager.configureGlobalMode() + expect(manager.availableTools.map((t) => t.def.function.name)).toContain('write_script') + + await manager.refreshSessionAccess() + + expect(manager.availableTools.map((t) => t.def.function.name)).not.toContain('write_script') + expect(manager.systemMessage.content).not.toContain('write_script') + }) + + // Which transition each posture offers is planModeController.test.ts's; what this pins is + // that only `tools` follows the picker. `isSessionChat` is what offers plan mode at all. + it('holds availableTools steady across the autonomy picker', async () => { + const manager = new AIChatManager() + manager.isSessionChat = true + await manager.changeMode(AIMode.GLOBAL) + + const names = (tools: { def: { function: { name: string } } }[]) => + tools.map((t) => t.def.function.name) + const before = names(manager.availableTools) + expect(before).toEqual(expect.arrayContaining(['enter_plan_mode', 'exit_plan_mode'])) + + manager.setAutonomyMode(AIAutonomyMode.YOLO) + expect(names(manager.tools)).not.toContain('enter_plan_mode') + expect(names(manager.availableTools)).toEqual(before) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte index e7cfa270ee..d639255c93 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte @@ -46,6 +46,7 @@ messageIndex: number editingMessageIndex: number | null isLast?: boolean + showAnswerActions?: boolean } let { @@ -53,7 +54,8 @@ messageIndex, availableContext, editingMessageIndex = $bindable(null), - isLast = false + isLast = false, + showAnswerActions = true }: Props = $props() // The edit box edits a copy of THIS message's original context, not the live @@ -82,8 +84,8 @@ {:else}
    0 && 'mt-4 mb-6', isLast && '!mb-12', message.role !== 'user' || !chatHost.supportsMessageEditing @@ -138,10 +140,22 @@ />
    {:else} -
    + +
    {#if message.role === 'assistant'}
    {:else if message.role === 'tool'}
    {#if stepName}
    @@ -151,7 +155,7 @@ expanded={reasoningExpanded} onToggle={() => (reasoningToggled = !reasoningExpanded)} shimmer={reasoningStreaming} - class="mb-2" + class={message.content ? 'mb-1' : ''} labelClass="truncate" contentClass="font-main text-secondary {markdownProse.xs}" > @@ -160,16 +164,18 @@ {/if} {#if s3Object} - +
    + +
    {:else if message.content} -
    +
    {/if} -{#if message.content} +{#if message.content && showActions} + below. A thinking-only row has no answer to copy, and its run's last answer links the run. -->
    @@ -185,7 +191,7 @@ class="inline-flex items-center gap-1 hover:text-primary hover:underline" title="Open this run" > - job {jobId?.slice(0, 8)} + job {truncateRev(jobId ?? '', 8)} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte index c9def4f9e6..8e299a4d3e 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte @@ -94,9 +94,9 @@ callers that already know which section they mean, such as the "+" menu's Manage return aiChatManager.operatingWorkspace ?? active ?? '' }) - // Exactly what the chat loop sends — plan mode's transition tool is registered - // alongside `tools` there, so listing only `tools` would under-report. - let tools = $derived(summarizeTools([...aiChatManager.tools, ...aiChatManager.planMode.tools])) + // The session's capability surface, not this turn's: the heading answers what the + // assistant can call here, so it must not shift when the autonomy picker does. + let tools = $derived(summarizeTools(aiChatManager.availableTools)) // `SidebarNavigation`'s item shape, which is what the instance and workspace settings // navs are built from too. @@ -149,6 +149,7 @@ callers that already know which section they mean, such as the "+" menu's Manage if (!aiChatManager.loading && !aiChatManager.sendInFlight) { void aiChatManager.refreshGlobalSkills() void aiChatManager.refreshMcpServers() + void aiChatManager.refreshSessionAccess() } } diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 976b969b3f..dd3a011da2 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -10,7 +10,13 @@ import { zIndexes } from '$lib/zIndexes' import { twMerge } from 'tailwind-merge' import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext' - import { MENTION_RE, mentionTitle, formatMention } from './mention' + import { + MENTION_RE, + mentionTitle, + formatMention, + hasMentionLeadingBoundary, + mentionTitlesInText + } from './mention' import { createFloatingActions, createVirtualElement } from 'svelte-floating-ui' import { flip, offset, shift } from 'svelte-floating-ui/dom' import { @@ -65,17 +71,11 @@ const aiChatManager = getAiChatManager() - function extractMentions(text: string): Set { - const out = new Set() - for (const m of text.matchAll(MENTION_RE)) out.add(mentionTitle(m[0])) - return out - } - // Titles currently appearing as `@title` mentions in the textarea. Compared // against the previous snapshot in a $effect (NOT inside handleInput — // the picker mutates `value` programmatically via `updateInstructionsWithContext`, // which doesn't fire `oninput`, so a handleInput-only diff goes stale). - const mentionedTitles = $derived(extractMentions(value)) + const mentionedTitles = $derived(mentionTitlesInText(value)) let prevMentionedTitles = $state>(new Set()) let showContextTooltip = $state(false) @@ -268,7 +268,11 @@ if (!att) return match return `${match}` }) - html = html.replace(MENTION_RE, (match) => { + html = html.replace(MENTION_RE, (match, ...args) => { + const offset = args[args.length - 2] + if (typeof offset !== 'number' || !hasMentionLeadingBoundary(html, offset)) { + return match + } const title = unescapeHtml(mentionTitle(match)) const inContext = availableContext.find((c) => c.title === title) || @@ -566,6 +570,23 @@ showContextTooltip = false } + export async function insertMention(title: string) { + const token = formatMention(title) + const selectionStart = textarea?.selectionStart ?? value.length + const selectionEnd = textarea?.selectionEnd ?? selectionStart + const { from, to, ids } = tokensOverlapping(selectionStart, selectionEnd) + const before = value.slice(0, from) + const after = value.slice(to) + const prefix = before.length === 0 || /\s$/.test(before) ? '' : ' ' + const suffix = after.length === 0 ? ' ' : /^\s/.test(after) ? '' : ' ' + const inserted = `${prefix}${token}${suffix}` + replacePasteRange(from, to, ids, inserted) + await tick() + const pos = from + inserted.length + textarea?.setSelectionRange(pos, pos) + textarea?.focus() + } + function refreshCommandSkills() { if (commandSkillsRefreshInFlight) return commandSkillsRefreshInFlight = true diff --git a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte index adc3fe6184..bce53625bb 100644 --- a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte +++ b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte @@ -13,7 +13,7 @@ type WindmillItemKind, type WorkspaceItemTargetKind } from './workspaceItems.svelte' - import { safeHref } from './safeHref' + import { safeHref } from '$lib/utils/safeHref' type Props = { href?: string diff --git a/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte new file mode 100644 index 0000000000..6f74ff7a80 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte @@ -0,0 +1,170 @@ + + + + +
    + {#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)} + {#if row.kind === 'collapsed' || row.kind === 'omitted'} + + {:else} +
    + {row.oldLine ?? ''} + + {row.newLine ?? ''}{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''} + + + {#each row.changedRanges ?? [] as range} + + {/each} + {@html highlightedLine(row) || ' '} + +
    + {/if} + {/each} +
    + + diff --git a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte new file mode 100644 index 0000000000..a2ab9d341c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte @@ -0,0 +1,103 @@ + + +{#snippet headerRight()} +
    + {#if counts && (counts.added > 0 || counts.removed > 0)} + + +{counts.added} + −{counts.removed} + + {/if} + {#if showPreviewChip && message.previewCard} + + {/if} +
    +{/snippet} + + (toggled = { id: toggleKey, open: !expanded })} + toggleable={hasBody} + shimmer={isRunning} + class={message.isQueued && !message.error + ? 'opacity-60 hover:opacity-100 transition-opacity' + : ''} + labelClass="truncate" + contentClass="p-0 overflow-hidden space-y-0" + {headerRight} +> + {#if diff && hasDiff && expanded} + + {/if} + {#if message.error} +
    + {message.error} +
    + {/if} +
    diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 5405a6ea25..aa8dc47cec 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -10,9 +10,11 @@ FileText, PanelRight, Lock, - ExternalLink + ExternalLink, + BookOpen } from 'lucide-svelte' import { base } from '$lib/base' + import { truncateRev } from '$lib/utils' import { EXIT_PLAN_MODE_TOOL, isPlanCardTool, @@ -26,7 +28,7 @@ import { getChatViewHost } from './chatViewHost' const chatHost = getChatViewHost() - import { isActiveUserQuestion, type ToolDisplayMessage } from './shared' + import { isActiveUserQuestion, webSearchResultOf, type ToolDisplayMessage } from './shared' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -40,6 +42,8 @@ import ToolPreviewCard from './ToolPreviewCard.svelte' import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' import RunScriptCard from './RunScriptCard.svelte' + import ToolDiffCard from './ToolDiffCard.svelte' + import { hasToolCodeDiff } from './toolCodeDiff' import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte' @@ -132,6 +136,7 @@ // A call that inspected a run rather than starting one gets the same card, bound to // the job it named — what happened in a run reads the same either way. const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun)) + const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName)) // The preview chip sits on the header row (to the right of the tool-call text); // shown once the tool settled, never while loading/erroring/awaiting confirmation. @@ -140,6 +145,18 @@ message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation ) ) + + // A provider-side search sets `webSearchSources` and words its own header; any other tool + // gets the same card by returning the web search result shape. + const searchResult = $derived( + message.webSearchSources || message.error ? undefined : webSearchResultOf(message.result) + ) + const sources = $derived(message.webSearchSources ?? searchResult?.sources) + const label = $derived( + searchResult?.query !== undefined + ? `${message.content} · "${searchResult.query}"` + : message.content + ) {#if activeUserQuestion} @@ -156,8 +173,22 @@ {message.toolName} {/if}
    +{:else if message.heldForFolderInstructions} + +
    + + + {message.content} + + {#if message.toolName} + {message.toolName} held until read + {/if} +
    {:else if isRunCard} +{:else if isDiffCard} + {:else if planState} @@ -261,7 +292,7 @@ class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline" title="Open this run" > - job {message.jobId?.slice(0, 8)} + job {truncateRev(message.jobId ?? '', 8)} {/snippet} @@ -281,7 +312,7 @@ weight alone: queued calls (waiting their turn behind the executing tool) are faded, the running one sweeps, a settled one is plain. --> (isExpanded = !isExpanded)} toggleable={detailsAvailable || message.isStreamingArguments === true} @@ -343,8 +374,8 @@ {#if visibleActions.length > 0} - {:else if message.webSearchSources?.length && !message.error} - + {:else if sources?.length && !message.error} + {:else} import { Globe } from 'lucide-svelte' import { SvelteSet } from 'svelte/reactivity' - import type { WebSearchSource } from './shared' + import { isRenderableSourceUrl, type WebSearchSource } from './shared' import { isOfflineReplay } from '$lib/components/recording/offlineReplay.svelte' interface Props { sources: WebSearchSource[] + /** Whether a hostname may be sent to the favicon service. False for the sources a + * workspace's own tool returned: those can name internal services, and disclosing + * them to a third party is the tool author's call to make, not this card's. */ + favicons?: boolean } - let { sources }: Props = $props() + let { sources, favicons = true }: Props = $props() - // The URLs come from the provider's response: only render absolute http(s) - // ones — anything else (javascript:, data:, relative) must not become an - // href. Also dedupes; providers can surface the same page several times. + // The URLs come from the provider's response or from a tool's own result: only render + // absolute http(s) ones — anything else (javascript:, data:, relative) must not become + // an href. Also dedupes; providers can surface the same page several times. const uniqueSources = $derived( Array.from( - new Map( - sources - .filter((s) => { - try { - return ['http:', 'https:'].includes(new URL(s.url).protocol) - } catch { - return false - } - }) - .map((s) => [s.url, s]) - ).values() + new Map(sources.filter((s) => isRenderableSourceUrl(s.url)).map((s) => [s.url, s])).values() ) ) @@ -43,7 +37,7 @@ // to issue none — a recording comes from an arbitrary origin, so its cited // hostnames must not leak from a viewer's browser either. Degrades to the same // Globe the blocked/failed case already uses. - const noFavicons = $derived(isOfflineReplay()) + const noFavicons = $derived(!favicons || isOfflineReplay()) // Favicons come from Google's public favicon service, which discloses each // consulted hostname to a third party from the user's browser — an accepted @@ -62,6 +56,9 @@
    {#each uniqueSources as source (source.url)} {@const hostname = hostnameOf(source.url)} + + {@const title = source.title?.trim() || undefined} failedFavicons.add(hostname)} /> {/if} - {source.title ?? hostname} - {#if source.title} + {title ?? hostname} + {#if title} {hostname} {/if} diff --git a/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte b/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte new file mode 100644 index 0000000000..0fb21f684f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte @@ -0,0 +1,148 @@ + + + +{#snippet leafIcon(leaf: DrillLeaf)} + +{/snippet} + +{#snippet branchIcon(branch: DrillBranch)} + {#if branch.icon} + {@const Icon = branch.icon} + + {:else} + + {/if} +{/snippet} + + +
    + relativizeWorkspacePath(workspaceItemDisplayPath(leaf.data), scope)} + onScopeChange={handleScopeChange} + onFilterChange={loader.onFilterChange} + rootLoading={WORKSPACE_KINDS.some((k) => !loader.loaded[k] && loader.loadingKind[k])} + /> +
    diff --git a/frontend/src/lib/components/copilot/chat/anthropic.test.ts b/frontend/src/lib/components/copilot/chat/anthropic.test.ts index 1ac413973f..9f6b49a9e9 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.test.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' -import { convertOpenAIToAnthropicMessages, partialWebSearchQuery } from './anthropic' +import { + convertOpenAIToAnthropicMessages, + parseAnthropicCompletion, + partialWebSearchQuery +} from './anthropic' +import { OutputTokenLimitError } from './outputTokenLimit' // anthropic.ts pulls in the chat client/registry layer at import time; the // converter under test is pure, so stub those side-effecting modules away. @@ -217,3 +222,28 @@ describe('partialWebSearchQuery', () => { expect(partialWebSearchQuery('{"query": ')).toBeUndefined() }) }) + +describe('parseAnthropicCompletion output token limit', () => { + it('fails a message that stopped at max_tokens instead of ending the turn', async () => { + const stream = { + on: () => undefined, + done: async () => undefined, + finalMessage: async () => ({ + content: [{ type: 'thinking', thinking: 'I need to plan the flow so each' }], + stop_reason: 'max_tokens', + usage: { input_tokens: 20000, output_tokens: 64000 } + }) + } + + const parsed = parseAnthropicCompletion( + stream as any, + { onNewToken: vi.fn(), onMessageEnd: vi.fn(), setToolStatus: vi.fn() } as any, + [], + [], + [], + {} + ) + + await expect(parsed).rejects.toBeInstanceOf(OutputTokenLimitError) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 8d38f10916..ab69d0b1a3 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -24,6 +24,7 @@ import { type ToolCallbacks, type WebSearchSource } from './shared' +import { OutputTokenLimitError } from './outputTokenLimit' import { anthropicUsageToChatTokenUsage, type ChatTokenUsage } from './tokenUsage' import { parseImageDataUrl } from './imageUtils' @@ -140,7 +141,8 @@ export async function getAnthropicCompletion( const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, - forceModelProvider: options?.forceModelProvider + forceModelProvider: options?.forceModelProvider, + reasoningEffort: options?.reasoningEffort }) const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) let anthropicTools = convertOpenAIToolsToAnthropic(tools) @@ -444,7 +446,8 @@ export async function parseAnthropicCompletion( toolCall, helpers, toolCallbacks: callbacks, - workspace: options?.workspace + workspace: options?.workspace, + messages }) messages.push(messageToAdd) addedMessages.push(messageToAdd) @@ -453,6 +456,9 @@ export async function parseAnthropicCompletion( return { shouldContinue: true, tokenUsage } } + if (finalMessage.stop_reason === 'max_tokens') { + throw new OutputTokenLimitError() + } return { shouldContinue: false, tokenUsage } } diff --git a/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts b/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts index f5ef707a7c..221c60b84f 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts +++ b/frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts @@ -1,5 +1,6 @@ import { z } from 'zod' -import { createToolDef, type Tool, type ToolCallbacks } from '../shared' +import { createToolDef, type ToolCallbacks } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import { artifactOverflowBytes, MAX_ARTIFACT_BYTES, normalizeChangeNote } from './artifactLimits' import { currentVersion, @@ -98,8 +99,9 @@ function targetsPlan(args: unknown, h: ArtifactToolHelpers): boolean { ) } -export const artifactTools: Tool<{}>[] = [ +export const artifactTools: SessionTool<{}>[] = [ { + requires: NONE, def: createToolDef( createArtifactSchema, 'create_artifact', @@ -156,6 +158,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( updateArtifactSchema, 'update_artifact', @@ -208,6 +211,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listArtifactsSchema, 'list_artifacts', @@ -242,6 +246,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readArtifactSchema, 'read_artifact', @@ -303,6 +308,7 @@ export const artifactTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listArtifactVersionsSchema, 'list_artifact_versions', diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 251c2ff858..282e7e1aa6 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -8,6 +8,7 @@ import { type ChatLoopConfig } from './chatLoop' import type { ReasoningProviderModel } from '../reasoningRegistry' +import { OutputTokenLimitError } from './outputTokenLimit' const mocks = vi.hoisted(() => ({ getCompletion: vi.fn(), @@ -540,6 +541,23 @@ describe('runChatLoop lastIterationUsage', () => { }) }) +describe('runChatLoop output token limit', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.resolveRequestReasoning.mockReturnValue(undefined) + }) + + it('fails a truncated Responses iteration instead of replaying it on the Completions API', async () => { + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + mocks.parseOpenAIResponsesCompletion.mockRejectedValue(new OutputTokenLimitError()) + + await expect( + runChatLoop(createConfig({ workspace: `workspace-${randomUUID()}` })) + ).rejects.toBeInstanceOf(OutputTokenLimitError) + expect(mocks.getCompletion).not.toHaveBeenCalled() + }) +}) + // Builders for the message shapes the chat loop accumulates. const assistant = (content: string): ChatCompletionMessageParam => ({ role: 'assistant', content }) const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({ @@ -551,6 +569,32 @@ const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({ function: { name: 'do_thing', arguments: '{}' } })) }) +describe('runChatLoop per-chat tool schemas', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.resolveRequestReasoning.mockReturnValue(undefined) + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ shouldContinue: false, tokenUsage }) + }) + + // The def `schemaFor` returns is the chat's own: it must be what the provider is sent and + // what the tool is dispatched with, while the shared tool object keeps its own. + it('sends and dispatches the def schemaFor returns, leaving the shared tool alone', async () => { + const base = { type: 'function', function: { name: 'dyn', parameters: {} } } as any + const perChat = { + type: 'function', + function: { name: 'dyn', parameters: { marked: 1 } } + } as any + const shared = { def: base, fn: async () => '', schemaFor: async () => perChat } + + await runChatLoop({ ...createConfig({ workspace: `ws-${randomUUID()}` }), tools: [shared] }) + + expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][2]).toEqual([perChat]) + expect(mocks.parseOpenAIResponsesCompletion.mock.calls[0][4][0].def).toBe(perChat) + expect(shared.def).toBe(base) + }) +}) + const tool = (id: string): ChatCompletionMessageParam => ({ role: 'tool', tool_call_id: id, diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index 110e4f1154..47159d205d 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -20,6 +20,7 @@ import { parseOpenAIResponsesCompletion } from './openai-responses' import type { Tool, ToolCallbacks } from './shared' +import { OutputTokenLimitError } from './outputTokenLimit' import { sanitizeToolCallArguments } from './toolCallArguments' import { addChatTokenUsage, emptyChatTokenUsage, type ChatTokenUsage } from './tokenUsage' @@ -70,13 +71,8 @@ export interface ChatLoopConfig { * lets the caller recover partial output if the loop throws or is aborted. */ addedMessages?: ChatCompletionMessageParam[] - /** Called before each iteration (e.g. to refresh tool schemas, or to record - * which model the iteration is about to use). */ - onBeforeIteration?: ( - tools: Tool[], - helpers: any, - modelProvider: ReasoningProviderModel - ) => Promise + /** Called before each iteration (e.g. to record which model it is about to use). */ + onBeforeIteration?: (modelProvider: ReasoningProviderModel) => Promise /** Fired for each completed provider response, before the loop continues. The * loop can fail or be aborted at any iteration, so spend has to be handed over * as it happens — a callback only at the end would discard everything the @@ -398,7 +394,6 @@ export async function runChatLoop(config: ChatLoopConfig): Promise (t.schemaFor ? { ...t, def: await t.schemaFor(helpers) } : t)) + ) const pendingUserMessage = getPendingUserMessage?.() @@ -501,6 +499,11 @@ export async function runChatLoop(config: ChatLoopConfig): Promise[] + /** What this chat's next request carries, the current posture's plan-mode transition + * included. A consumer narrows it by tool metadata; assembling its own list from the + * sources behind it is how the advertised set and the sent set drift apart. */ + readonly tools: readonly Tool[] autonomyMode: AIAutonomyMode setAutonomyMode: (mode: AIAutonomyMode) => void readonly autoAcceptEditsActive: boolean diff --git a/frontend/src/lib/components/copilot/chat/datatableTools.ts b/frontend/src/lib/components/copilot/chat/datatableTools.ts index bb7976d396..0656bf43a9 100644 --- a/frontend/src/lib/components/copilot/chat/datatableTools.ts +++ b/frontend/src/lib/components/copilot/chat/datatableTools.ts @@ -6,10 +6,10 @@ import { datatableReference } from '$lib/components/dbTypes' import { createToolDef, executeTestRun, - type Tool, type ToolDisplayMessage, type ChatJobResultFormat } from './shared' +import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities' /** * Workspace-scoped datatable tools, with no app whitelist and no creation policy. @@ -246,9 +246,10 @@ export function formatChatJobCompletion( * The unrestricted workspace datatable tools, for registration in global mode. * Helper-free: each tool reads `workspace` directly from the tool call params. */ -export function getDatatableTools(): Tool<{}>[] { +export function getDatatableTools(): SessionTool<{}>[] { return [ { + requires: NONE, def: getListDatatablesToolDef(), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { @@ -298,6 +299,7 @@ export function getDatatableTools(): Tool<{}>[] { } }, { + requires: NONE, def: getGetDatatableTableSchemaToolDef(), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { @@ -337,6 +339,8 @@ export function getDatatableTools(): Tool<{}>[] { } }, { + // `runScript` below goes through /jobs/run/preview, which jobs.rs refuses operators. + requires: RUN_PREVIEW, def: getExecDatatableSqlToolDef(), requiresConfirmation: true, confirmationMessage: 'Execute SQL on datatable', diff --git a/frontend/src/lib/components/copilot/chat/docs/core.ts b/frontend/src/lib/components/copilot/chat/docs/core.ts index 9ae9d0c988..ee0c04983f 100644 --- a/frontend/src/lib/components/copilot/chat/docs/core.ts +++ b/frontend/src/lib/components/copilot/chat/docs/core.ts @@ -1,4 +1,4 @@ -import type { Tool } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import type { ChatCompletionTool } from 'openai/resources/index.mjs' import { DocumentationService } from '$lib/gen' @@ -33,7 +33,8 @@ const READ_DOCS_PAGE_TOOL: ChatCompletionTool = { } } -export const readDocsPageTool: Tool<{}> = { +export const readDocsPageTool: SessionTool<{}> = { + requires: NONE, def: READ_DOCS_PAGE_TOOL, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { @@ -85,7 +86,8 @@ const SEARCH_DOCS_TOOL: ChatCompletionTool = { } } -export const searchDocsTool: Tool<{}> = { +export const searchDocsTool: SessionTool<{}> = { + requires: NONE, def: SEARCH_DOCS_TOOL, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { diff --git a/frontend/src/lib/components/copilot/chat/ducklakeTools.ts b/frontend/src/lib/components/copilot/chat/ducklakeTools.ts index dfadc58db9..3e19789e96 100644 --- a/frontend/src/lib/components/copilot/chat/ducklakeTools.ts +++ b/frontend/src/lib/components/copilot/chat/ducklakeTools.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { DataMetricService, WorkspaceService } from '$lib/gen' -import { createToolDef, type Tool } from './shared' +import { createToolDef } from './shared' +import { NONE, type SessionTool } from './sessionCapabilities' /** * Workspace-scoped DuckLake tools, the pipeline counterpart to `list_datatables` @@ -69,9 +70,10 @@ const NO_DATA_METRICS_NOTE = const DEFAULT_DATA_METRICS_LIMIT = 200 /** The workspace DuckLake tools, for registration in global mode. */ -export function getDucklakeTools(): Tool<{}>[] { +export function getDucklakeTools(): SessionTool<{}>[] { return [ { + requires: NONE, def: listDataMetricsToolDef, planModeSafe: true, showDetails: true, @@ -106,6 +108,7 @@ export function getDucklakeTools(): Tool<{}>[] { } }, { + requires: NONE, def: listDucklakesToolDef, planModeSafe: true, fn: async ({ workspace, toolId, toolCallbacks }) => { diff --git a/frontend/src/lib/components/copilot/chat/files/fileTools.ts b/frontend/src/lib/components/copilot/chat/files/fileTools.ts index 246bf814d6..b0d5554173 100644 --- a/frontend/src/lib/components/copilot/chat/files/fileTools.ts +++ b/frontend/src/lib/components/copilot/chat/files/fileTools.ts @@ -8,7 +8,8 @@ */ import { z } from 'zod' import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' -import { createToolDef, type Tool } from '../shared' +import { createToolDef } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import { readFile, searchFilesInWorker, @@ -95,7 +96,8 @@ const searchFilesToolDef = createToolDef( 'Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.' ) -export const searchFilesTool: Tool<{}> = { +export const searchFilesTool: SessionTool<{}> = { + requires: NONE, def: searchFilesToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { @@ -164,7 +166,8 @@ const readFileToolDef = createToolDef( 'Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`→`) plus a pagination note. Files are not in context, so use this to inspect their contents.' ) -export const readFileTool: Tool<{}> = { +export const readFileTool: SessionTool<{}> = { + requires: NONE, def: readFileToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { @@ -193,7 +196,7 @@ export const readFileTool: Tool<{}> = { } } -export const fileTools: Tool<{}>[] = [searchFilesTool, readFileTool] +export const fileTools: SessionTool<{}>[] = [searchFilesTool, readFileTool] function rosterLine(f: AttachedFile): string { // Message rows are addressed by their stable id (names may collide); session diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 51fea34d58..96b2858957 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -302,7 +302,7 @@ const patchFlowJsonToolDef = createToolDef( 'patch_flow_json', 'Make a quick exact text edit in the current compact flow JSON. Prefer this for small localized changes; use set_flow_json for larger structural rewrites.' ) -// Will be overridden by setSchema +// Replaced per chat by schemaFor const testRunFlowSchema = z.object({ args: z .object({}) @@ -435,11 +435,10 @@ export const flowTools: Tool[] = [ contextName: 'flow' }) }, - setSchema: async function (helpers: FlowAIChatHelpers) { - await buildSchemaForTool(this.def, async () => { - const flowInputsSchema = await helpers.getFlowInputsSchema() - return flowInputsSchema - }) + schemaFor: async (helpers: FlowAIChatHelpers) => { + const def = structuredClone(testRunFlowToolDef) + await buildSchemaForTool(def, () => helpers.getFlowInputsSchema()) + return def }, requiresConfirmation: true, confirmationMessage: 'Run a test of the current flow', @@ -465,6 +464,18 @@ export const flowTools: Tool[] = [ const stepId = args.stepId const stepArgs = args.args || {} + // An agent step runs on the step's own configuration with this run's changes on top, which + // the global chat's run form assembles; this tool sends only what the model named. + if (findModuleInFlow(flow.value, stepId)?.value.type === 'aiagent') { + toolCallbacks.setToolStatus(toolId, { + content: `Step "${stepId}" is an AI agent step`, + error: 'AI agent steps cannot be tested from this chat' + }) + throw new Error( + 'AI agent steps cannot be tested from this chat. Test it from the step\'s "Test this step" tab.' + ) + } + return executeFlowStepTestRun({ flowValue: flow.value, stepId, diff --git a/frontend/src/lib/components/copilot/chat/folderInstructions.ts b/frontend/src/lib/components/copilot/chat/folderInstructions.ts new file mode 100644 index 0000000000..c07c85cbbd --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/folderInstructions.ts @@ -0,0 +1,220 @@ +import { ResourceService } from '$lib/gen' +import { truncateForPrompt } from './skills/skillResources' + +/** + * Folder instructions are resources of this type: a markdown file resource + * (`format_extension = 'md'`) whose `value.content` the assistant follows for + * every item under the directory the resource lives in, like an AGENTS.md. + */ +export const FOLDER_INSTRUCTIONS_RESOURCE_TYPE = 'ai_instruction' + +/** Bytes of one instruction body a tool result may carry. */ +export const MAX_FOLDER_INSTRUCTIONS_LENGTH = 32 * 1024 +/** Bytes all the bodies one tool result carries may add up to, so nested folders + * cannot stack several full-size bodies onto a single result. */ +export const MAX_FOLDER_INSTRUCTIONS_TOTAL = 48 * 1024 + +/** Scopes the system prompt names. Past this the list is cut, not the delivery: + * a scope left out still has its instructions delivered on first touch. */ +export const MAX_LISTED_FOLDER_INSTRUCTIONS = 50 + +export type FolderInstruction = { + /** The `ai_instruction` resource. */ + path: string + /** The directory it covers, with a trailing `/`: `f/billing/AGENTS` covers `f/billing/`. */ + scope: string +} + +export function folderInstructionScope(path: string): string { + return path.slice(0, path.lastIndexOf('/') + 1) +} + +const PAGE_SIZE = 100 +/** Guard against a paging bug looping forever, not a product cap. */ +const MAX_PAGES = 20 + +/** Every folder instruction the user can read in the workspace. Read access is the + * resource's own ACL, so a folder's instructions reach exactly who can see them. */ +export async function listFolderInstructions(workspace: string): Promise { + if (!workspace) return [] + const rows: FolderInstruction[] = [] + for (let page = 1; page <= MAX_PAGES; page++) { + const resources = await ResourceService.listResource({ + workspace, + resourceType: FOLDER_INSTRUCTIONS_RESOURCE_TYPE, + page, + perPage: PAGE_SIZE + }) + rows.push(...resources.map((r) => ({ path: r.path, scope: folderInstructionScope(r.path) }))) + if (resources.length < PAGE_SIZE) break + } + return rows +} + +/** Workspace paths a tool call names: every string argument called `path` or + * `*_path` that is a `u/`, `f/` or `g/` path, nested ones included — a trigger's target + * sits in `config.path`, and missing it would let that write through unheld. */ +export function workspacePathsInArgs(args: unknown, depth = 0): string[] { + if (!args || typeof args !== 'object' || depth > 4) return [] + if (Array.isArray(args)) return args.flatMap((v) => workspacePathsInArgs(v, depth + 1)) + return Object.entries(args as Record).flatMap(([key, value]) => + typeof value === 'string' + ? (key === 'path' || key.endsWith('_path')) && /^[ufg]\/[^/]+/.test(value) + ? [value] + : [] + : workspacePathsInArgs(value, depth + 1) + ) +} + +/** The instructions covering any of `paths`, outermost scope first, so a nested + * folder's instructions come last and read as the more specific ones. A path naming + * the folder itself (`f/billing`, as a pipeline's is) is covered too. */ +export function instructionsCovering( + instructions: readonly FolderInstruction[], + paths: readonly string[] +): FolderInstruction[] { + return instructions + .filter((i) => paths.some((p) => `${p}/`.startsWith(i.scope))) + .sort((a, b) => a.scope.length - b.scope.length || a.path.localeCompare(b.path)) +} + +/** What a chat hands the tool loop: the instructions in play, and which tool call + * delivered which of them. + * + * A delivery counts only while its tool message is still in the conversation and + * carries the text: the id is recorded because a script quoting the tag in a result + * must not pass for a delivery, and nothing is marked on the message object because + * the completions path sends that to the provider verbatim. So compaction or a + * restored chat gets the instructions again. */ +export type FolderInstructionsContext = { + list: () => readonly FolderInstruction[] + deliveredBy: Map +} + +/** Keyed by workspace too: the chat keeps its conversation across a switch of + * operating workspace, and the same path there can hold different instructions. */ +export type FolderInstructionsDelivery = { workspace: string; paths: readonly string[] } + +/** Instructions delivered by the tool results in `messages`, split by whether the + * model has read them. Results after the latest assistant message answer the batch + * still being processed: the model has not seen those yet, so a delivery there + * must not let a change later in the same batch through. */ +function deliveredIn( + ctx: FolderInstructionsContext, + messages: readonly unknown[], + workspace: string +): { read: Set; thisBatch: Set } { + const read = new Set() + const thisBatch = new Set() + let lastAssistant = -1 + messages.forEach((m, i) => { + if ((m as { role?: unknown } | undefined)?.role === 'assistant') lastAssistant = i + }) + messages.forEach((m, i) => { + const { tool_call_id: id, content } = (m ?? {}) as { tool_call_id?: unknown; content?: unknown } + if (typeof id !== 'string' || typeof content !== 'string') return + const delivery = ctx.deliveredBy.get(id) + if (delivery?.workspace !== workspace) return + for (const p of delivery.paths) { + // The id alone is not enough: a call stopped mid-run is answered with a + // placeholder under the same id, which carries no instructions. + if (content.includes(openingTag(p))) (i < lastAssistant ? read : thisBatch).add(p) + } + }) + return { read, thisBatch } +} + +/** The instructions covering the paths a tool call names that the model has not + * read yet, as the text to hand it, or undefined when there are none. `paths` are + * the bodies the text carries; one already carried earlier in the same batch is + * pointed to rather than repeated. A body that no longer reads (deleted or moved + * since the listing) is left out rather than failing the call it rides on. */ +export async function pendingFolderInstructions( + ctx: FolderInstructionsContext | undefined, + messages: readonly unknown[], + args: unknown, + workspace: string +): Promise<{ paths: string[]; text: string } | undefined> { + if (!ctx || !workspace) return undefined + const paths = workspacePathsInArgs(args) + if (paths.length === 0) return undefined + const { read, thisBatch } = deliveredIn(ctx, messages, workspace) + const pending = instructionsCovering(ctx.list(), paths).filter((i) => !read.has(i.path)) + const inBatch = pending.filter((i) => thisBatch.has(i.path)) + const blocks = ( + await Promise.all( + pending + .filter((i) => !thisBatch.has(i.path)) + .map(async (instruction) => { + try { + return { + instruction, + body: await readFolderInstructionBody(workspace, instruction.path) + } + } catch (e) { + console.error(`Failed to read folder instructions ${instruction.path}`, e) + return undefined + } + }) + ) + ).filter((b) => b !== undefined) + if (blocks.length === 0 && inBatch.length === 0) return undefined + const pointers = inBatch.map( + (i) => + `The instructions for \`${i.scope}\` (${i.path}) are in an earlier result of this same batch of tool calls.` + ) + return { + paths: blocks.map((b) => b.instruction.path), + text: [formatFolderInstructions(blocks), ...pointers].filter(Boolean).join('\n\n') + } +} + +export async function readFolderInstructionBody(workspace: string, path: string): Promise { + const value = (await ResourceService.getResourceValue({ workspace, path })) as + | { content?: unknown } + | undefined + if (typeof value?.content !== 'string') { + throw new Error(`resource ${path} has no string "content"`) + } + return value.content +} + +function openingTag(path: string): string { + return ` { + const cut = truncateForPrompt(body, Math.min(MAX_FOLDER_INSTRUCTIONS_LENGTH, remaining)) + remaining = Math.max(0, remaining - new TextEncoder().encode(cut).byteLength) + return cut + }) + bodies.reverse() + return blocks + .map( + ({ instruction }, i) => + `${openingTag(instruction.path)} scope="${instruction.scope}">\n${bodies[i]}\n` + ) + .join('\n\n') +} + +/** The system prompt section naming the scopes that carry instructions. */ +export function getFolderInstructionsPromptSection( + instructions: readonly FolderInstruction[] +): string { + if (instructions.length === 0) return '' + const scopes = [...new Set(instructions.map((i) => i.scope))].sort() + const listed = scopes.slice(0, MAX_LISTED_FOLDER_INSTRUCTIONS) + const more = scopes.length > listed.length ? ` (and ${scopes.length - listed.length} more)` : '' + return `\n\nFOLDER INSTRUCTIONS: +These folders carry instructions (ai_instruction resources) for every item under them: ${listed.map((s) => `\`${s}\``).join(', ')}${more}. +- The first time a tool call names a path under one of them, its instructions are delivered in a block of the tool result. A call that would change something is held back until you have them: read them, then make the call again. +- Follow them for all work under that folder, together with any WORKSPACE INSTRUCTIONS and USER INSTRUCTIONS above: they add to those, they do not replace them. +- Only where two of them conflict on the same point, the more specific one wins: a nested folder's instructions over its parent folder's, a folder's over the WORKSPACE INSTRUCTIONS, and both over the USER INSTRUCTIONS, which are personal preferences. The user's explicit requests in this chat come before all of them.` +} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 7a86e0ba76..e0df5c4a98 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2024,6 +2024,24 @@ describe('global AI tools', () => { }) }) + it('records a creation diff for a new script draft', async () => { + const statuses: any[] = [] + + await callGlobalTool( + 'write_script', + { + path: 'f/scripts/new-script', + language: 'python3', + content: 'print("hello")' + }, + { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) } + ) + + expect(statuses).toContainEqual({ + codeDiff: { before: '', after: 'print("hello")', lang: 'python' } + }) + }) + it('tells a code app from a drag-and-drop app', async () => { vi.mocked(AppService.listApps).mockResolvedValueOnce([ { path: 'f/apps/code', summary: 'Code app', raw_app: true }, @@ -3080,12 +3098,17 @@ describe('global AI tools', () => { kind: 'script' } as any) - await callGlobalTool('write_script', { - path: 'f/scripts/existing', - summary: 'new summary', - language: 'bun', - content: 'new content' - }) + const statuses: any[] = [] + await callGlobalTool( + 'write_script', + { + path: 'f/scripts/existing', + summary: 'new summary', + language: 'bun', + content: 'new content' + }, + { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) } + ) expect( getBackendDraft('script', 'f/scripts/existing', { workspace: WORKSPACE }) @@ -3097,6 +3120,9 @@ describe('global AI tools', () => { content: 'new content', language: 'bun' }) + expect(statuses).toContainEqual({ + codeDiff: { before: 'old deployed content', after: 'new content', lang: 'typescript' } + }) }) describe('stale-draft deploy guard and rebase', () => { @@ -6060,6 +6086,237 @@ describe('global AI tools', () => { }) }) + // An agent step has no job of its own, so it runs as a flow preview of itself alone, opened on + // the same rows and the same values as the flow editor's own step test. A setting the step + // leaves alone is not offered at all, so a mounted widget cannot invent a value for it: an + // `enabled_tools` the form synthesized empty would advertise none of the agent's tools. + it("test_run_step opens an aiagent step on the step's own configuration", async () => { + const providerValue = { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' } + let opened: RunFormDisplay | undefined + await callGlobalTool('write_flow', { + path: 'f/flows/agent-step', + summary: 'Flow with an agent step', + modules: JSON.stringify([ + { + id: 'ask', + value: { + type: 'aiagent', + tools: [ + { + id: 'b', + summary: 'get_code', + value: { + tool_type: 'flowmodule', + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return 1 }', + input_transforms: {} + } + } + ], + input_transforms: { + provider: { type: 'static', value: providerValue }, + user_message: { type: 'static', value: 'authored question' } + } + } + } + ]) + }) + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_step', + { + path: 'f/flows/agent-step', + stepId: 'ask', + args: { user_message: 'What is the capital of France?' } + }, + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => { + opened = form + return form.args + } + } + ) + ) + + // The step configures these two, so the form offers them, filled in with what the step + // holds — and the model's own message wins over the authored one. + expect(opened?.args).toMatchObject({ + provider: providerValue, + user_message: 'What is the capital of France?' + }) + // Those two plus the rows the editor always shows, and nothing else the step is silent + // about: `enabled_tools` above all, whose synthesized empty value would advertise no tools. + expect(Object.keys(opened?.schema?.properties ?? {})).toEqual([ + 'provider', + 'user_message', + 'system_prompt' + ]) + + expect(JobService.runFlowPreview).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: { + value: { + modules: [ + { + id: 'ask', + value: expect.objectContaining({ + type: 'aiagent', + input_transforms: { + provider: { type: 'javascript', expr: 'flow_input.provider' }, + user_message: { type: 'javascript', expr: 'flow_input.user_message' }, + // Every row of the form runs from it, the blank ones too. + system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' } + } + }) + } + ] + }, + args: { provider: providerValue, user_message: 'What is the capital of France?' }, + // Filed under the flow, as the editor's step test does: a managed memory keyed on a + // named memory id is found under it. + path: 'f/flows/agent-step' + } + }) + }) + + // What the form submits is what runs, as in the editor's step test: a field cleared over the + // step's expression runs cleared rather than falling back to the expression, whether the + // mounted form sends it as the empty string or the bypass posture sends the form unchanged. + it.each([ + { + answer: 'the mounted form', + // A field the form could not fill in comes back blank, never absent: text as the empty + // string, a list as `[]`. + callbacks: { + requestRunArgs: async (_toolId: string, form: any) => ({ + ...form.args, + user_message: '', + enabled_tools: form.args.enabled_tools ?? [] + }) + } + }, + { + answer: 'the bypass posture', + callbacks: { + shouldAutoAcceptToolConfirmations: () => true, + requestRunArgs: async (_toolId: string, form: any) => form.args + } + } + ])( + 'test_run_step runs an aiagent field left blank by $answer as blank', + async ({ callbacks }) => { + await callGlobalTool('write_flow', { + path: 'f/flows/agent-expr', + summary: 'Agent fed by an earlier step', + modules: JSON.stringify([ + { + id: 'first', + value: { + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return { question: "hi" } }', + input_transforms: {} + } + }, + { + id: 'ask', + value: { + type: 'aiagent', + tools: [], + input_transforms: { + provider: { + type: 'static', + value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' } + }, + user_message: { type: 'javascript', expr: 'results.first.question' }, + enabled_tools: { type: 'javascript', expr: 'results.first.tools' } + } + } + } + ]) + }) + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_step', + { path: 'f/flows/agent-expr', stepId: 'ask', args: {} }, + { ...toolCallbacks, ...callbacks } + ) + ) + + const body = vi.mocked(JobService.runFlowPreview).mock.calls.at(-1)?.[0].requestBody as any + expect(body.value.modules[0].value.input_transforms.user_message).toEqual({ + type: 'javascript', + expr: 'flow_input.user_message' + }) + expect(body.args.enabled_tools).toEqual([]) + } + ) + + // The memory picker resets a value whose kind it does not offer to its first option, so a + // step saved with a legacy kind would run with its history switched off. And a tool name typed + // by hand that matches none silently narrows the run, so the roster is offered as in the editor. + it("test_run_step offers an aiagent step the editor's choices", async () => { + let opened: RunFormDisplay | undefined + await callGlobalTool('write_flow', { + path: 'f/flows/agent-legacy-memory', + summary: 'Agent with legacy memory', + modules: JSON.stringify([ + { + id: 'ask', + value: { + type: 'aiagent', + tools: [ + { + id: 'b', + summary: 'get_code', + value: { + tool_type: 'flowmodule', + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return 1 }', + input_transforms: {} + } + } + ], + input_transforms: { + provider: { + type: 'static', + value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' } + }, + user_message: { type: 'static', value: 'hi' }, + memory: { type: 'static', value: { kind: 'auto', context_length: 5 } }, + enabled_tools: { type: 'static', value: ['get_code'] } + } + } + } + ]) + }) + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_step', + { path: 'f/flows/agent-legacy-memory', stepId: 'ask', args: {} }, + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => { + opened = form + return form.args + } + } + ) + ) + + const kinds = opened?.schema.properties.memory.oneOf.map( + (variant: any) => variant.properties.kind.enum[0] + ) + expect(kinds).toContain('auto') + expect(opened?.schema.properties.enabled_tools.items.enum).toEqual(['get_code']) + }) + // A step is fed by its input transforms, so its arguments are its own and the flow's // schema describes a different set entirely. Opening the form on the flow's would offer // fields this job ignores and drop the ones it takes. @@ -7590,36 +7847,14 @@ describe('buildOpenPageUrl runs filters', () => { }) }) -describe('buildOpenPageUrl compare selection', () => { - const itemsOf = (url: string) => new URL(url, 'http://x').searchParams.get('items') - - it('explicit items win over the chat mask', () => { - const url = buildOpenPageUrl( - 'compare', - { page: 'compare', items: ['script:f/a/b'] }, - { workspaceId: 'ws', chatItems: ['flow:f/c/d'] } - ) - expect(itemsOf(url)).toBe('script:f/a/b') - }) - - it('omitted items fall back to the chat-modified mask', () => { - const url = buildOpenPageUrl( - 'compare', - { page: 'compare' }, - { workspaceId: 'ws', chatItems: ['flow:f/c/d', 'script:f/a/b'] } - ) - expect(itemsOf(url)).toBe('flow:f/c/d,script:f/a/b') - }) - - it('an empty or absent mask yields no items param (page select-all default)', () => { - expect( - itemsOf( - buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws', chatItems: [] }) - ) - ).toBeNull() - expect( - itemsOf(buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' })) - ).toBeNull() +describe('buildOpenPageUrl compare', () => { + // Without an explicit mode the page auto-picks, which is the draft view outside a fork. + it('always opens the fork comparison', () => { + const params = new URL( + buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' }), + 'http://x' + ).searchParams + expect(params.get('mode')).toBe('fork') }) }) @@ -7629,9 +7864,9 @@ describe('open_page workspace gating', () => { // A workspace the user belongs to but whose `whoami` never answers. const FLAKY = 'flaky_ws' const openPage = () => getGlobalTool('open_page') - const pageSchema = () => (openPage().def.function.parameters as any)?.properties?.page ?? {} + let def = openPage().def + const pageSchema = () => (def.function.parameters as any)?.properties?.page ?? {} const advertisedPages = () => (pageSchema().enum ?? []) as string[] - const pristineDef = openPage().def beforeEach(() => { // Admin of the workspace being browsed, plain member of the one a session operates on. @@ -7654,16 +7889,26 @@ describe('open_page workspace gating', () => { superadmin.set(undefined) whoamiByWorkspace.clear() clearWorkspaceRoleCache() - openPage().def = pristineDef + }) + + // Every chat shares this tool object, so a schema written back to it would reach the + // other chats' requests. + it("never writes a chat's schema back to the shared tool", async () => { + const shared = openPage().def + const snapshot = structuredClone(shared) + def = await openPage().schemaFor!({ operatingWorkspace: SESSION }) + expect(def).not.toBe(shared) + expect(openPage().def).toBe(shared) + expect(shared).toEqual(snapshot) }) // Reading the ambient `userStore` instead offers a session the pages of the workspace // the user happens to be browsing. it('gates on the operating workspace, not the one userStore describes', async () => { - await openPage().setSchema?.({ operatingWorkspace: NAV }) + def = await openPage().schemaFor!({ operatingWorkspace: NAV }) expect(advertisedPages()).toContain('workspace_settings') - await openPage().setSchema?.({ operatingWorkspace: SESSION }) + def = await openPage().schemaFor!({ operatingWorkspace: SESSION }) expect(advertisedPages()).toContain('runs') expect(advertisedPages()).not.toContain('workspace_settings') await expect( @@ -7678,7 +7923,7 @@ describe('open_page workspace gating', () => { // Neither layer may read as a denial: the role was never established, and the model // sees the schema before it can ever reach the handler's message. it('advertises nothing and blames no denial when the role lookup fails', async () => { - await openPage().setSchema?.({ operatingWorkspace: FLAKY }) + def = await openPage().schemaFor!({ operatingWorkspace: FLAKY }) expect(advertisedPages()).toEqual([]) expect(pageSchema().description).toContain("couldn't be checked") const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, { @@ -7691,7 +7936,7 @@ describe('open_page workspace gating', () => { // A workspace absent from `userWorkspaces` is settled, not unknown: inviting a retry // would be false, and asking `whoami` at all only earns a 401 on every iteration. it('reports a plain denial for a workspace the user is not a member of', async () => { - await openPage().setSchema?.({ operatingWorkspace: 'unreachable_ws' }) + def = await openPage().schemaFor!({ operatingWorkspace: 'unreachable_ws' }) expect(advertisedPages()).toEqual([]) expect(pageSchema().description).not.toContain("couldn't be checked") const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, { @@ -7708,7 +7953,7 @@ describe('open_page workspace gating', () => { it('asks whoami while the workspace list is still unresolved', async () => { usersWorkspaceStore.set(undefined) - await openPage().setSchema?.({ operatingWorkspace: SESSION }) + def = await openPage().schemaFor!({ operatingWorkspace: SESSION }) expect(UserService.whoami).toHaveBeenCalledWith(expect.objectContaining({ workspace: SESSION })) expect(advertisedPages()).not.toEqual([]) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 5b0490c724..5664f2349d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -43,6 +43,13 @@ import type { Script, ScriptLang } from '$lib/gen/types.gen' +import { loadSchemaFromModule, memoryPropertyFor } from '$lib/components/flows/flowInfers' +import { flowLocalAgentSchema } from '$lib/components/flows/agentResourceUtils' +import { AGENT_FIELDS, initialVisibleAgentFields } from '$lib/components/flows/agentFormFields' +import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' +import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' +import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils' +import { evalValue } from '$lib/components/flows/utils.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { FRAMEWORK_TEMPLATES, @@ -135,10 +142,11 @@ import { type CreatedResourceTriggerKind, type PreviewCardKind, type RunFormDisplay, - type Tool, type ToolCallbacks, + type ToolCodeDiff, type ToolDisplayAction } from '../shared' +import { scriptLangToEditorLang } from '$lib/scripts' import { searchDocsTool, readDocsPageTool } from '../docs/core' import { createDbSchemaTool } from '../script/core' import type { ContextElement } from '../context' @@ -195,6 +203,15 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import type { UserDraftItemKind } from '$lib/gen' import { bundleRawAppDraft } from './rawAppBundlerBridge' +import { + DEPLOY, + NONE, + RUN_PREVIEW, + WRITE_DRAFT, + type SessionAccess, + type SessionTool, + type SessionToolPolicy +} from '../sessionCapabilities' import { buildRunsUrl, buildSchedulesUrl, @@ -768,7 +785,7 @@ const deployWorkspaceItemSchema = z.object({ .boolean() .optional() .describe( - 'Deploy even if the draft was started from an older deployed version, overwriting the version deployed since. Defaults to false; prefer calling rebase_draft first to keep the newer changes.' + 'Deploy even if the draft was started from an older deployed version, overwriting the version deployed since. Defaults to false.' ) }) @@ -928,7 +945,7 @@ const runScriptSchema = z.object({ const runScriptToolDef = createToolDef( runScriptSchema, 'run_script', - 'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real"); otherwise use test_run_script.', + 'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', { strict: false } ) @@ -967,7 +984,7 @@ const runFlowSchema = z.object({ const runFlowToolDef = createToolDef( runFlowSchema, 'run_flow', - 'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real"); otherwise use test_run_flow.', + 'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', { strict: false } ) @@ -982,7 +999,7 @@ const testRunStepSchema = z.object({ const testRunStepToolDef = createToolDef( testRunStepSchema, 'test_run_step', - "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", { strict: false } ) @@ -1137,7 +1154,7 @@ const openPreviewSchema = z.object({ kind: z .enum(['script', 'flow', 'raw_app', 'pipeline']) .describe( - 'Item kind to preview. Use "raw_app" for code-based apps (created via init_app). Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' + 'Item kind to preview. Use "raw_app" for code-based apps. Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' ), path: z .string() @@ -1270,7 +1287,11 @@ type FolderPromptContext = { folders?: string[]; foldersRead?: string[]; isAdmin // non-exhaustive hint alongside permission-agnostic guidance (the complete set // needs a folder-listing tool — follow-up). // Capped so a folder-heavy workspace can't dominate the prompt. -function buildFolderGuidance(username: string, ctx?: FolderPromptContext): string { +function buildFolderGuidance( + username: string, + ctx: FolderPromptContext | undefined, + canCreateFolder: boolean +): string { if (!ctx) return '' const MAX = 40 const writable = ctx.folders ?? [] @@ -1286,7 +1307,7 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin writable.length > 0 ? ` Folders here include ${fmt(writable)} (you can also write to others not listed).` : '' - return `- As a workspace admin you can write to any existing folder.${known} If the user names a folder, use it; if they explicitly ask for a new folder, create it with \`create_folder\`; otherwise ask them which folder to use rather than guessing or creating one unprompted.` + return `- As a workspace admin you can write to any existing folder.${known} If the user names a folder, use it;${canCreateFolder ? ' if they explicitly ask for a new folder, create it with `create_folder`;' : ''} otherwise ask them which folder to use rather than guessing${canCreateFolder ? ' or creating one unprompted' : ''}.` } // Everything below states the writable set as fact, including the empty case, so an // unresolved role has to say nothing at all: "you have no shared folders" is a claim, @@ -1297,11 +1318,15 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin const lines: string[] = [] if (writable.length > 0) { lines.push( - `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path. Use \`create_folder\` only when the user explicitly asks for a new folder.` + `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}` ) } else { lines.push( - `- You have no shared folders you can write to in this workspace, so use \`u/${username}/\`. If the user explicitly asks for a shared folder, create one with \`create_folder\` (you become an owner); otherwise ask before placing shared work rather than inventing an \`f//...\` path.` + `- You have no shared folders you can write to in this workspace, so use \`u/${username}/\`. ${ + canCreateFolder + ? 'If the user explicitly asks for a shared folder, create one with `create_folder` (you become an owner); otherwise ask' + : 'If the user explicitly asks for a shared folder, say plainly that you cannot create one here; otherwise ask' + } before placing shared work rather than inventing an \`f//...\` path.` ) } if (readOnly.length > 0) { @@ -1317,9 +1342,19 @@ const buildGlobalSystemPrompt = ( previewTools: boolean, folderCtx?: FolderPromptContext, skills: AiSkillListItem[] = [], - mcpServers: McpServer[] = [] + mcpServers: McpServer[] = [], + access?: SessionAccess ) => { - const folderGuidance = buildFolderGuidance(username, folderCtx) + // 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 canRunPreview = !access || access.has('run_preview') + // 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 + // line behind and a full-access prompt is byte-for-byte the ungated text. + const when = (cond: boolean, block: string) => (cond ? block : '') + const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder) const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : '' // `previewTools` doubles as "this is a session chat" — sessions are the only // chats that get the preview tool set. The alpha heads-up only makes sense @@ -1351,48 +1386,85 @@ const buildGlobalSystemPrompt = ( The current user's workspace username is "${username}".${instanceLine} -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. +${ + 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." +}${when( + canWriteDraft, + ` Path conventions: - A workspace path starts with one of two namespaces; its trailing may itself contain "/", so a path has three or more segments: - \`u/${username}/\` — your personal scope. Default for ad-hoc, exploratory, or scratch work. - \`f//\` — a shared folder scope; the must already exist (a bare \`f/\` with no folder segment is INVALID and will fail). - If the user supplies a fully qualified \`f//...\` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a \`u/${username}/...\` path unless a tool rejects it. -- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/\`. Never invent an \`f//...\` path for a folder that does not exist; create one with \`create_folder\` only when the user explicitly asks for a new folder.${folderGuidanceBlock} +- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/\`. Never invent an \`f//...\` path for a folder that does not exist${when( + canCreateFolder, + '; create one with `create_folder` only when the user explicitly asks for a new folder' + )}.${folderGuidanceBlock}` + )}${when( + canCreateFolder && !canWriteDraft, + '\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: -- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items. +Rules:${when( + canWriteDraft, + ` +- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` + )} - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. -- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule} +- 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, + ` - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. -- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path. -- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs. +- 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, + ` +- 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.` + )} +- 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, + ` - You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value. - Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition. - When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it. - Hub scripts are prebuilt integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Use search_hub_scripts to find one before hand-writing an integration, then read_workspace_item with type "script" and the returned hub path to get its code, language, and input schema. -- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database). -- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. -${pipelineBullet} +${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. +${pipelineBullet}` + )}${when( + canRunPreview && canWriteDraft, + ` - 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. +- 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.` + )} - 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). - Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described — Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself. -- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click. -- When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" — it opens the Compare & Deploy review page.${ - previewTools - ? ' By default it preselects the items this chat modified; pass items (":" entries) to control the selection' - : ' Pass items (":" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace' - }, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed. -- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's. +- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.${when( + canWriteDraft, + ` +- Do not offer or open the Compare & Deploy page for normal draft review. Only use open_page with page "compare" when the user explicitly asks to deploy a forked workspace's changes to its parent workspace.` + )}${when( + canRunPreview, + ` +- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.` + )}${when( + !canRunPreview, + ` +- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.` + )} - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). - When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. - Keep context targeted.${ previewTools - ? ` + ? `${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="") 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 or not-yet-created folder is fine (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="") 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.` + )} - 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"). - To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open. - get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend. call), call list_app_runs to get that run's job_id from the live preview, then get_run with it. Use this when a backend call errors or returns something unexpected. @@ -1416,29 +1488,41 @@ Documentation: Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". -- Use read_flow_module_code and set_flow_module_code for inline script bodies. -- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites. +- Use read_flow_module_code${when(canWriteDraft, ' and set_flow_module_code')} for inline script bodies.${when( + canWriteDraft, + ` +- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.` + )} 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. +- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when( + canWriteDraft, + ` - 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 only after confirming framework, path, and summary with the user. -- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving. +- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.` + )} Data Tables: - Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL. -- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql. -- 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. -- 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.${ +- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists${when(canRunPreview, ' or call exec_datatable_sql')}. +- 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 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.${ + }${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.` + )}${ skills.length > 0 ? ` @@ -2335,7 +2419,7 @@ function getDatatableInstructions(language?: ScriptLang): string { const lang = language ?? 'bun' return `# Datatable SQL SDK reference -Datatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the \`list_datatables\`, \`get_datatable_table_schema\`, and \`exec_datatable_sql\` tools. 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. +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. - A runnable accesses a datatable via \`wmill.datatable()\` (the default "main") or \`wmill.datatable('')\`, referencing tables as \`schema.table\`. - Use parameterized queries (the tagged template in TypeScript, \`$1\`/\`$2\` placeholders in Python) — never interpolate untrusted values into SQL strings. @@ -2398,12 +2482,23 @@ export type SessionPromptContext = { /** Session-state guidance appended to the global system prompt so the model * knows where its work lands (staged fork vs the live workspace). */ -export function getSessionContextPromptSection(ctx: SessionPromptContext): string { +export function getSessionContextPromptSection( + ctx: SessionPromptContext, + access?: SessionAccess +): string { + // 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 targets = ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys'] + .filter(Boolean) + .join(', ') const lines = [ '', '', 'Session state:', - '- This chat is a Windmill AI session with its own operating workspace: every tool call (reads, drafts, test runs, deploys) targets that workspace.' + `- This chat is a Windmill AI session with its own operating workspace: every tool call (${targets}) targets that workspace.` ] if (ctx.pendingForkOf) { lines.push( @@ -2430,6 +2525,13 @@ export function getSessionContextPromptSection(ctx: SessionPromptContext): strin '- No operating workspace is set yet; the user picks one (or a new staged fork) before the first message is sent.' ) } + // The kind enum already withholds what the rules refuse; this says why, and where the user + // 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." + ) + } return lines.join('\n') } @@ -2476,7 +2578,8 @@ const readSkillSchema = z.object({ .describe('The exact skill resource path as listed in the Skills section of the system prompt.') }) -export const readSkillTool: Tool<{}> = { +export const readSkillTool: SessionTool<{}> = { + requires: NONE, def: createToolDef( readSkillSchema, 'read_skill', @@ -2703,17 +2806,6 @@ function allowsAllWorkspacesRuns(workspaceId: string | undefined = get(workspace return (!!get(superadmin) || !!get(devopsRole)) && workspaceId === 'admins' } -// The advertised `items` description must match this chat's surface: only chats that -// track their modified items (AI sessions) can honor "omitted = this chat's edits" — -// on an untracked chat (the global side panel) an omitted mask falls through to the -// page's select-all default, so the model is told to pass the items explicitly there. -const COMPARE_ITEMS_DESCRIPTIONS = { - tracked: - "Compare: preselect exactly these changed items, each as ':' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). Omit to preselect the items modified in this chat (everything when this chat modified nothing).", - untracked: - "Compare: preselect exactly these changed items, each as ':' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). If omitted, the page preselects EVERY pending change in the workspace, not just this chat's — when you changed specific items, pass them so the review is scoped to them." -} as const - // The Runs filters the page accepts several values for, all encoded in one param: the // value is a comma-separated list, and for the negatable ones a leading `!` excludes // instead (the page rejects a list mixing included and excluded values). @@ -2728,7 +2820,7 @@ const RUNS_TIMEFRAME_LABELS = runsTimeframes.map((tf) => tf.label) as [string, . // input_schema requires; a top-level oneOf would be rejected. Each per-page URL builder // drops any key that isn't one of its page's real query params, so a field that doesn't // apply to the chosen page is harmless. This full schema is used to PARSE tool args; the -// advertised schema (what the model sees) is narrowed per-user in `setSchema`. +// advertised schema (what the model sees) is narrowed per-user in `schemaFor`. const openPageFullSchema = z.object({ page: z.enum(OPEN_PAGE_NAMES).describe('Which page to open'), path: z @@ -2879,13 +2971,13 @@ const openPageFullSchema = z.object({ .enum([...WORKSPACE_SETTINGS_TABS] as [string, ...string[]]) .optional() .describe('Workspace settings: which settings tab to open'), - mode: z - .enum(['draft', 'fork']) + items: z + .array(z.string()) + .min(1) .optional() .describe( - "Compare: which comparison to show — 'draft' (deployed items vs their pending drafts) or 'fork' (this forked workspace vs its parent). Omit to auto-pick: the view containing the preselected items (draft whenever any of them is a pending draft); with nothing preselected, fork on a forked workspace and draft otherwise." + "Compare: preselect exactly these fork changes, each as ':' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). Omit to use the page's default selection; pass items only when the user asked to deploy specific ones." ), - items: z.array(z.string()).min(1).optional().describe(COMPARE_ITEMS_DESCRIPTIONS.tracked), new_tab: z .boolean() .optional() @@ -2929,7 +3021,6 @@ const OPEN_PAGE_FIELD_PAGES: Record = { operation: ['audit_logs'], resource: ['audit_logs'], tab: ['workspace_settings'], - mode: ['compare'], items: ['compare'] } @@ -2940,7 +3031,6 @@ const OPEN_PAGE_FIELD_PAGES: Record = { function buildOpenPageDefSchema( pages: readonly OpenPageName[], triggerKinds: readonly PageTriggerKind[], - chatEditsTracked: boolean, allWorkspacesRuns: boolean, roleUnverified = false ): z.ZodTypeAny { @@ -2970,25 +3060,18 @@ function buildOpenPageDefSchema( .enum([...triggerKinds] as [string, ...string[]]) .optional() .describe('Triggers: which trigger kind page to open') - : field === 'items' - ? z - .array(z.string()) - .min(1) - .optional() - .describe(COMPARE_ITEMS_DESCRIPTIONS[chatEditsTracked ? 'tracked' : 'untracked']) - : full[field] + : full[field] } shape.new_tab = full.new_tab return z.object(shape) } const OPEN_PAGE_DESCRIPTION = - 'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy review page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its editor opens directly). Use page "compare" when the user wants to review and deploy pending changes (the items field controls which changes are preselected). This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.' + 'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its editor opens directly). Never offer page "compare" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.' -// Non-arg inputs the URL builder needs: the chat's operating workspace (the compare -// page cannot fall back to its own store default inside a session preview) and the -// live modified-items mask backing the compare page's default preselection. -type OpenPageUrlCtx = { workspaceId: string; chatItems?: readonly string[] } +// Non-arg input the URL builder needs: the chat's operating workspace (the compare +// page cannot fall back to its own store default inside a session preview). +type OpenPageUrlCtx = { workspaceId: string } // The Runs page reads its two absolute bounds as `new Date(param)` and drops whatever // doesn't parse, so normalize to ISO here rather than passing a stamp the page will @@ -3141,14 +3224,11 @@ export function buildOpenPageUrl(page: OpenPageName, a: OpenPageArgs, ctx: OpenP case 'workspace_settings': return buildWorkspaceSettingsUrl({ tab: a.tab }) case 'compare': - // Explicit `items` wins; otherwise preselect this chat's modified items. An - // empty mask (chat modified nothing) passes no items so the page keeps its - // select-all default instead of preselecting nothing. - return buildCompareUrl({ - workspace_id: ctx.workspaceId, - mode: a.mode, - items: a.items ?? (ctx.chatItems?.length ? ctx.chatItems : undefined) - }) + // Always fork: an omitted mode lets the page auto-pick the draft view. No + // chat-modified fallback for `items` either: a session's edits are usually + // undeployed drafts, which the fork comparison leaves out, so masking by them + // would open "deploy to parent" with nothing selected. + return buildCompareUrl({ workspace_id: ctx.workspaceId, mode: 'fork', items: a.items }) } } @@ -3175,14 +3255,14 @@ function summarizeOpenPage(url: string, page: OpenPageName): string { return parts.length ? parts.join(', ') : `all ${OPEN_PAGE_LABELS[page].toLowerCase()}` } -export const openPageTool: Tool<{}> = { - // The initial def assumes an untracked chat and no resolved role; setSchema below - // rebuilds it with the caller's real surface before each iteration. +export const openPageTool: SessionTool<{}> = { + requires: NONE, + // The initial def assumes no resolved role; schemaFor below rebuilds it with the + // caller's real surface before each iteration. def: createToolDef( buildOpenPageDefSchema( restrictedOpenPages(get(workspaceStore)), allowedTriggerKinds(), - false, allowsAllWorkspacesRuns() ), 'open_page', @@ -3195,13 +3275,12 @@ export const openPageTool: Tool<{}> = { autoCollapseDetails: false, // Re-narrow the advertised `page` enum to this user's permissions each iteration, so // the model never sees (or suggests) a page the user can't reach. - setSchema: async function (helpers) { + schemaFor: async (helpers) => { const access = await allowedOpenPages(operatingWorkspaceFromHelpers(helpers)) - this.def = createToolDef( + return createToolDef( buildOpenPageDefSchema( access.pages, allowedTriggerKinds(), - (helpers as GlobalToolHelpers | undefined)?.getModifiedItems?.() !== undefined, allowsAllWorkspacesRuns(operatingWorkspaceFromHelpers(helpers)), access.roleUnverified ), @@ -3247,10 +3326,7 @@ export const openPageTool: Tool<{}> = { if (!urlWorkspace) { return 'Error: no workspace is selected, so no page can be opened.' } - const url = buildOpenPageUrl(page, parsed, { - workspaceId: urlWorkspace, - chatItems: (ctx.helpers as GlobalToolHelpers | undefined)?.getModifiedItems?.() - }) + const url = buildOpenPageUrl(page, parsed, { workspaceId: urlWorkspace }) const pageLabel = OPEN_PAGE_LABELS[page] const summary = summarizeOpenPage(url, page) @@ -3280,10 +3356,11 @@ export const openPageTool: Tool<{}> = { } } -export const globalTools: Tool<{}>[] = [ +export const globalTools: SessionTool<{}>[] = [ readSkillTool, openPageTool, { + requires: NONE, def: createToolDef( getInstructionsSchema, 'get_instructions', @@ -3293,6 +3370,15 @@ export const globalTools: Tool<{}>[] = [ fn: async (ctx) => { const { args, toolId, toolCallbacks } = ctx const parsed = getInstructionsSchema.parse(args) + // 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.' + toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' }) + return message + } const label = parsed.subject === 'script' && parsed.language ? `${parsed.subject} (${parsed.language})` @@ -3306,6 +3392,7 @@ export const globalTools: Tool<{}>[] = [ searchDocsTool, readDocsPageTool, { + requires: NONE, def: createToolDef( askUserQuestionSchema, 'askUserQuestion', @@ -3374,6 +3461,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( updateUserInstructionsSchema, 'update_user_instructions', @@ -3447,6 +3535,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listWorkspaceItemsSchema, 'list_workspace_items', @@ -3511,6 +3600,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', @@ -3560,6 +3650,8 @@ export const globalTools: Tool<{}>[] = [ } }, { + // folders.rs `create_folder` runs `check_deploy_rules` and has no operator check. + requires: DEPLOY, def: createToolDef( createFolderSchema, 'create_folder', @@ -3602,6 +3694,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef(writeScriptSchema, 'write_script', 'Create or overwrite a draft script.'), showDetails: true, streamArguments: true, @@ -3612,6 +3705,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a draft flow.'), showDetails: true, streamArguments: true, @@ -3653,6 +3747,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeScheduleToolSchema, 'write_schedule', @@ -3678,6 +3773,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeTriggerSchema, 'write_trigger', @@ -3705,10 +3801,11 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getTriggerSchemaSchema, 'get_trigger_schema', - 'Get the configuration schema for one trigger kind. Call before write_trigger.' + 'Get the configuration schema for one trigger kind — its config fields differ per kind.' ), planModeSafe: true, fn: async (ctx) => { @@ -3717,15 +3814,17 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( z.object({}), 'get_schedule_schema', - "Get the shape of write_schedule's `advanced` object: retry, pausing, tags, and error-handler tuning." + "Get the shape of a schedule's `advanced` object: retry, pausing, tags, and error-handler tuning." ), planModeSafe: true, fn: async () => JSON.stringify(advancedScheduleShape(), null, 2) }, { + requires: WRITE_DRAFT, def: createToolDef( editScriptSchema, 'edit_script', @@ -3740,6 +3839,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', @@ -3754,6 +3854,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: RUN_PREVIEW, def: testRunScriptToolDef, fn: async (ctx) => { const parsed = testRunScriptSchema.parse(ctx.args) @@ -3771,6 +3872,9 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + // Ungated, unlike the test runs: this executes the DEPLOYED item under the user's own + // permissions, the one run an operator's token allows. `run_flow` likewise. + requires: NONE, def: runScriptToolDef, fn: async (ctx) => { const parsed = runScriptSchema.parse(ctx.args) @@ -3785,6 +3889,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: RUN_PREVIEW, def: testRunFlowToolDef, fn: async (ctx) => { const parsed = testRunFlowSchema.parse(ctx.args) @@ -3799,6 +3904,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: NONE, def: runFlowToolDef, fn: async (ctx) => { const parsed = runFlowSchema.parse(ctx.args) @@ -3812,6 +3918,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: RUN_PREVIEW, def: testRunStepToolDef, fn: async (ctx) => { const parsed = testRunStepSchema.parse(ctx.args) @@ -3826,6 +3933,7 @@ export const globalTools: Tool<{}>[] = [ autoCollapseDetails: false }, { + requires: NONE, def: createToolDef( listRunsSchema, 'list_runs', @@ -3855,6 +3963,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( z.object({}), 'list_workers', @@ -3898,6 +4007,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getRunSchema, 'get_run', @@ -3927,6 +4037,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( cancelJobSchema, 'cancel_job', @@ -3954,6 +4065,21 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Ungated as a whole: the draft may predate a role change, and what the server accepts + // 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. + script: ['deploy', 'manage_code'], + flow: ['deploy', 'manage_code'], + app: ['deploy', 'manage_code'], + resource: DEPLOY, + variable: DEPLOY, + // The schedule and trigger handlers check neither. + schedule: NONE, + trigger: NONE + } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deployWorkspaceItemSchema, 'deploy_workspace_item', @@ -3970,6 +4096,8 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies. + requires: WRITE_DRAFT, def: createToolDef( rebaseDraftSchema, 'rebase_draft', @@ -3984,6 +4112,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( diffSchema, 'diff', @@ -4007,6 +4136,20 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Ungated as a whole, like deploying. Each kind's handler, reached through + // `deleteWorkspaceItem`'s switch: + requires: NONE, + 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'], + resource: DEPLOY, + variable: DEPLOY, + schedule: NONE, + trigger: NONE + } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deleteWorkspaceItemSchema, 'delete_workspace_item', @@ -4023,6 +4166,9 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Ungated: discarding your OWN draft skips drafts.rs `require_can_write_path`, so a + // user who has LOST write access can still clean up. + requires: NONE, def: createToolDef( discardLocalDraftSchema, 'discard_local_draft', @@ -4038,6 +4184,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeResourceSchema, 'write_resource', @@ -4053,6 +4200,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeVariableSchema, 'write_variable', @@ -4068,6 +4216,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( searchResourceTypesSchema, 'search_resource_types', @@ -4103,6 +4252,7 @@ export const globalTools: Tool<{}>[] = [ updateEditorCache: false }), { + requires: NONE, def: createToolDef( readFlowModuleCodeSchema, 'read_flow_module_code', @@ -4115,6 +4265,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( setFlowModuleCodeSchema, 'set_flow_module_code', @@ -4129,6 +4280,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( initAppSchema, 'init_app', @@ -4143,6 +4295,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readAppFileSchema, 'read_app_file', @@ -4155,6 +4308,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( searchAppSchema, 'search_app', @@ -4167,6 +4321,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeAppFileSchema, 'write_app_file', @@ -4181,6 +4336,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( deleteAppFileSchema, 'delete_app_file', @@ -4192,6 +4348,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( patchAppFileSchema, 'patch_app_file', @@ -4206,6 +4363,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( writeAppRunnableSchema, 'write_app_runnable', @@ -4221,6 +4379,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: WRITE_DRAFT, def: createToolDef( deleteAppRunnableSchema, 'delete_app_runnable', @@ -4232,6 +4391,9 @@ export const globalTools: Tool<{}>[] = [ } }, { + // Reaches apps.rs `execute_component` rather than jobs.rs, but that handler refuses + // operators too once `force_viewer_static_fields` marks the call a preview. + requires: RUN_PREVIEW, def: testRunAppRunnableToolDef, fn: async (ctx) => { const parsed = testRunAppRunnableSchema.parse(ctx.args) @@ -4246,6 +4408,7 @@ export const globalTools: Tool<{}>[] = [ }, ...artifactTools, { + requires: NONE, def: createToolDef( openPreviewSchema, 'open_preview', @@ -4257,6 +4420,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getPreviewStatusSchema, 'get_preview_status', @@ -4266,6 +4430,7 @@ export const globalTools: Tool<{}>[] = [ fn: async (ctx) => getSessionPreviewStatus(sessionIdFromCtx(ctx)) }, { + requires: NONE, def: createToolDef( closePageSchema, 'close_page', @@ -4277,6 +4442,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( getRuntimeLogsSchema, 'get_app_runtime_logs', @@ -4297,6 +4463,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( listAppRunsSchema, 'list_app_runs', @@ -4316,6 +4483,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( searchDomSchema, 'search_dom', @@ -4344,6 +4512,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( readDomSchema, 'read_dom', @@ -4372,6 +4541,7 @@ export const globalTools: Tool<{}>[] = [ } }, { + requires: NONE, def: createToolDef( takeScreenshotSchema, 'take_screenshot', @@ -4452,7 +4622,7 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([ * chat, or `globalTools` minus the preview tools for the regular global * side-panel chat. */ -export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] { +export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): SessionTool<{}>[] { const tools = sessionPreview ? globalTools : globalTools.filter((t) => !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name)) @@ -4491,6 +4661,9 @@ type WriteDraftCtx = { export type SessionToolHelpers = { sessionId?: string } export type GlobalToolHelpers = SessionToolHelpers & { + /** The session's capability profile. Undefined outside a session, or before the first + * send resolves it. */ + access?: SessionAccess /** Runs the flow editor mounted on `storagePath`, if one is. `memoryId` names the * chat-mode conversation the turn belongs to. */ testActiveFlow?: ( @@ -4511,10 +4684,6 @@ export type GlobalToolHelpers = SessionToolHelpers & { // Wired only for session chats (see AIChatManager): the artifact tools are session-gated. artifacts?: SessionArtifactsStore getChatId?: () => string | undefined - // Live snapshot of the items this chat modified (`kind:path` mask keys, see - // modifiedItemsMask.ts); undefined when the chat doesn't track them (the global - // side-panel chat). Backs open_page's compare-page default preselection. - getModifiedItems?: () => string[] | undefined openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void } @@ -5029,10 +5198,12 @@ function finishAppDraftWrite( function finishDraftWrite( result: DraftPersistResult, existed: boolean, - ctx: WriteDraftCtx + ctx: WriteDraftCtx, + codeDiff?: ToolCodeDiff ): string { const failure = draftWriteFailure(result, ctx) if (failure) return failure + if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff }) ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath) maybeAttachPreviewCard(ctx, result.itemKind, result.item.path) const stored = result.item @@ -5078,7 +5249,11 @@ async function writeDraft( path: string, args: A, ctx: WriteDraftCtx, - opts: { triggerKind?: TriggerKind; override?: boolean } = {} + opts: { + triggerKind?: TriggerKind + override?: boolean + codeDiff?: ToolCodeDiff | ((base: T | undefined, draft: T) => ToolCodeDiff | undefined) + } = {} ): Promise { const { workspace } = ctx startDraftWrite(ctx, type, path) @@ -5097,7 +5272,8 @@ async function writeDraft( triggerKind: opts.triggerKind, force: opts.override }) - return finishDraftWrite(result, existed, ctx) + const codeDiff = typeof opts.codeDiff === 'function' ? opts.codeDiff(base, draft) : opts.codeDiff + return finishDraftWrite(result, existed, ctx, codeDiff) } type ScriptDraftArgs = { @@ -5150,8 +5326,21 @@ const SCRIPT_SPEC: WriteSpec = { } } -function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise { - return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override }) +function writeScriptDraft( + args: ScriptDraftArgs, + ctx: WriteDraftCtx, + codeDiff?: ToolCodeDiff +): Promise { + return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { + override: args.override, + codeDiff: + codeDiff ?? + ((base, draft) => ({ + before: base?.content ?? '', + after: draft.content, + lang: scriptLangToEditorLang(draft.language) + })) + }) } type FlowDraftArgs = { @@ -5368,7 +5557,8 @@ async function editScript( language: base.language, content: updated }, - ctx + ctx, + { before: base.content, after: updated, lang: scriptLangToEditorLang(base.language) } ) } @@ -5608,6 +5798,68 @@ async function loadSubflowForFlowStep( } } +/** + * The run form for an agent step: the rows the flow editor's own step test shows, opened on the + * values it opens them with, built from that form's helpers so the two cannot drift. + */ +async function agentStepRunForm( + module: FlowModule, + workspace: string, + proposed: Record | null | undefined +): Promise<{ schema: Record; args: Record }> { + const transforms = (module.value as { input_transforms?: Record }).input_transforms + // A linked step's brain belongs to the resource rather than to this flow, so only the inputs + // this flow supplies are its to edit. + const full = (await loadSchemaFromModule(module, workspace)).schema + const schema = (module.value as { agent?: string }).agent ? flowLocalAgentSchema(full) : full + + const visible = initialVisibleAgentFields(transforms, schema.properties) + // What the model asked to set, shown even where the step configures nothing: this form has no + // control for opening a row, so a field left out of it is one the chat cannot reach at all. + for (const key of Object.keys(proposed ?? {})) visible.add(key) + const position = new Map(AGENT_FIELDS.map((field, index) => [field.key, index])) + const keys = Object.keys(schema.properties ?? {}) + // A key the field registry does not know is kept, so a new one is never silently dropped. + .filter((key) => !position.has(key) || visible.has(key)) + .sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity)) + + const evaluated: Record = {} + for (const key of keys) { + const value = evalValue(key, module, undefined, false) + if (value !== undefined) evaluated[key] = value + } + const args = { ...evaluated, ...(proposed ?? {}) } + // A mounted form fills a blank list with `[]`, and the bypass posture submits the form unmounted. + // For `enabled_tools` the two mean opposite things: `[]` runs no tools, an absent key runs all. + if (keys.includes('enabled_tools')) args.enabled_tools ??= [] + + const properties = Object.fromEntries(keys.map((key) => [key, schema.properties[key]])) + if (properties.memory) properties.memory = memoryPropertyFor(properties.memory, args.memory) + // Picked from the agent's tools, as in the editor: a name typed by hand that matches none + // silently narrows the run to fewer tools. + const list = properties.enabled_tools + if (list) { + const { agent, tools } = module.value as { agent?: string; tools?: AgentTool[] } + const roster = agent ? await resolveLinkedAgentTools(agent, workspace, true) : (tools ?? []) + properties.enabled_tools = { + ...list, + items: { ...(list.items ?? { type: 'string' }), enum: enabledToolNames(roster) } + } + } + + return { + schema: { + ...schema, + properties, + order: keys, + required: ((schema.required as string[] | undefined) ?? []).filter((key) => + keys.includes(key) + ) + }, + args + } +} + // Leaf of a workspace path (last segment), for human-readable confirmation // prompts. Falls back to the full path, then a generic noun. function pathLeaf(path: unknown, fallback: string): string { @@ -6065,7 +6317,9 @@ async function testRunFlowStepByPath( toolCallbacks, toolId, loadScript: loadScriptForFlowStep, - loadSubflow: loadSubflowForFlowStep + loadSubflow: loadSubflowForFlowStep, + flowPath: args.path, + withAgentDrafts: (value) => withAgentDrafts(value, workspace) }) // The module resolution landed on, not the id that was asked for: the job's entrypoint @@ -6074,15 +6328,20 @@ async function testRunFlowStepByPath( const isPreprocessor = resolved.module.id === SPECIAL_MODULE_IDS.PREPROCESSOR // The step's own inputs, never the flow's: a step is fed by its input transforms, so the // flow's schema names arguments this job would ignore and omits the ones it takes. + const agentForm = + resolved.module.value.type === 'aiagent' + ? await agentStepRunForm(resolved.module, workspace, args.args) + : undefined const schema = - resolved.code != undefined && resolved.lang + agentForm?.schema ?? + (resolved.code != undefined && resolved.lang ? await schemaForTestRun({ content: resolved.code, language: resolved.lang, schema: resolved.schema, entrypoint: isPreprocessor ? 'preprocessor' : undefined }) - : (resolved.schema ?? {}) + : (resolved.schema ?? {})) const stepSummary = resolved.module.summary return runThroughForm( @@ -6100,7 +6359,7 @@ async function testRunFlowStepByPath( // itself be a draft. schemaNoun: 'step', toolName: 'test_run_step', - proposed: args.args, + proposed: agentForm?.args ?? args.args, startMessage: resolved.startMessage, contextName: resolved.runnableKind, noun: 'step', @@ -6109,7 +6368,9 @@ async function testRunFlowStepByPath( autoAcceptable: true, background: args.background, detachAfterMs: waitSecondsToDetachMs(args.wait_seconds), - startJob: resolved.startJob + startJob: agentForm + ? (submitted) => resolved.startJob(submitted, agentForm.schema.order) + : resolved.startJob }, ctx ) @@ -8419,6 +8680,9 @@ export function prepareGlobalSystemMessage( user?: GlobalPromptIdentity skills?: AiSkillListItem[] mcpServers?: McpServer[] + /** Capabilities of the chat's operating workspace; undefined keeps every block. Must + * match the profile the toolset was filtered with, or the prompt names withheld tools. */ + access?: SessionAccess } ): ChatCompletionSystemMessageParam { const user = opts?.user ?? get(userStore) @@ -8435,7 +8699,8 @@ export function prepareGlobalSystemMessage( opts?.previewTools ?? false, folderCtx, opts?.skills ?? [], - opts?.mcpServers ?? [] + opts?.mcpServers ?? [], + opts?.access ) if (instructions?.workspace?.trim()) { content = `${content}\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n${instructions.workspace.trim()}` diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts new file mode 100644 index 0000000000..feffda425e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('monaco-editor', () => ({ + editor: {}, + languages: {}, + KeyCode: {}, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 } +})) +vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({ + getTypeScriptWorker: async () => async () => ({}), + typescriptVersion: 'test' +})) +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 { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly' + +const sessionContext: SessionPromptContext = { workspaceId: 'ws', parentWorkspaceId: 'parent' } +const pipelineContext: PipelineContext = { + folder: 'myfolder', + mode: 'edit', + nodes: [], + assets: [] +} + +describe('assembleGlobalTools', () => { + it('includes the pipeline tools only when a pipeline editor is active', () => { + const names = (opts: Parameters[0]) => + assembleGlobalTools(opts).map((t) => t.def.function.name) + expect(names({ pipelineContext })).toContain('get_pipeline_graph') + expect(names({})).not.toContain('get_pipeline_graph') + }) +}) + +describe('assembleGlobalSystemMessage', () => { + // The hazard this module exists to remove: every caller gets every section, so a + // mid-session rebuild (update_user_instructions) cannot silently drop one. + it('carries both optional sections when both contexts are given', () => { + const content = assembleGlobalSystemMessage(undefined, { + sessionContext, + pipelineContext + }).content as string + expect(content).toContain(getSessionContextPromptSection(sessionContext)) + expect(content).toContain(getPipelinePromptSection(pipelineContext)) + // Order is part of the assembled output, so it is pinned too. + expect(content.indexOf(getSessionContextPromptSection(sessionContext))).toBeLessThan( + content.indexOf(getPipelinePromptSection(pipelineContext)) + ) + }) + + it('omits them when the contexts are absent', () => { + const content = assembleGlobalSystemMessage(undefined, {}).content as string + // Each section's own heading, read from the code rather than retyped: a reword + // cannot quietly make these vacuous, and a heading is the one part every variant + // 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))) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts new file mode 100644 index 0000000000..a291e93ec7 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts @@ -0,0 +1,49 @@ +/** + * The whole assembled surface of a GLOBAL-mode chat — prompt sections and tools — + * in one place, so a new section cannot be added to one rebuild path and forgotten + * on another. Plan mode's decoration is not here: it is applied per request, later. + */ +import type { ChatCompletionSystemMessageParam } from 'openai/resources/index.mjs' +import type { SessionTool } from '../sessionCapabilities' +import { + getSessionContextPromptSection, + globalToolsFor, + prepareGlobalSystemMessage, + type SessionPromptContext +} from './core' +import { createMcpTools } from './mcpTools' +import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '../pipeline/core' +import { getFolderInstructionsPromptSection, type FolderInstruction } from '../folderInstructions' + +// Derived, not retyped: an option added to prepareGlobalSystemMessage is reachable +// here at once. Hand-listing the four would compile fine while leaving the new one +// unreachable from this path, and only the eval harness — which calls the builder +// directly — would get it. +export type GlobalAssemblyOpts = NonNullable[1]> & { + sessionContext?: SessionPromptContext + pipelineContext?: PipelineContext + folderInstructions?: readonly FolderInstruction[] +} + +export function assembleGlobalSystemMessage( + instructions: Parameters[0], + opts: GlobalAssemblyOpts +): ChatCompletionSystemMessageParam { + const systemMessage = prepareGlobalSystemMessage(instructions, opts) + systemMessage.content += getFolderInstructionsPromptSection(opts.folderInstructions ?? []) + if (opts.sessionContext) { + systemMessage.content += getSessionContextPromptSection(opts.sessionContext, opts.access) + } + if (opts.pipelineContext) { + systemMessage.content += getPipelinePromptSection(opts.pipelineContext, opts.access) + } + return systemMessage +} + +export function assembleGlobalTools(opts: GlobalAssemblyOpts): SessionTool[] { + return [ + ...globalToolsFor({ sessionPreview: opts.previewTools ?? false }), + ...(opts.pipelineContext ? pipelineTools : []), + ...createMcpTools(opts.mcpServers ?? []) + ] +} diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts index 90afa0b04b..620e5a86ea 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { ResourceService, type GetMcpToolsResponse } from '$lib/gen' -import { createToolDef, type Tool } from '../shared' +import { createToolDef } from '../shared' +import { NONE, type SessionTool } from '../sessionCapabilities' import { enabledMcpPaths } from '$lib/components/mcp/enabledServers' import { isOwnOrSharedMcpPath, MCP_LIST_PER_PAGE, mcpViewer } from '$lib/components/mcp/ownServers' @@ -349,9 +350,10 @@ const callMcpToolSchema = z.object({ * mode and behind the user's confirmation — building both from one body keeps * them from drifting. */ -function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> { +function createCallTool(servers: McpServer[], mode: 'read' | 'write'): SessionTool<{}> { const isRead = mode === 'read' return { + requires: NONE, def: createToolDef( callMcpToolSchema, isRead ? 'call_mcp_read_tool' : 'call_mcp_write_tool', @@ -419,12 +421,13 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> * are not registered at all, so a workspace without an MCP connection pays no * per-iteration schema cost for them. */ -export function createMcpTools(servers: McpServer[]): Tool<{}>[] { +export function createMcpTools(servers: McpServer[]): SessionTool<{}>[] { if (servers.length === 0) return [] const serverList = servers.map((s) => s.path).join(', ') return [ { + requires: NONE, def: createToolDef( searchMcpToolsSchema, 'search_mcp_tools', diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts new file mode 100644 index 0000000000..82ad8162d3 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { whoami, deployRules } = vi.hoisted(() => ({ + whoami: vi.fn(), + deployRules: vi.fn() +})) + +vi.mock('$lib/gen', () => ({ UserService: { whoami } })) +vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules })) + +import { resolveSessionAccess } from './sessionAccess' +import { clearWorkspaceRoleCache } from '$lib/user' + +type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean } + +function user(overrides: WhoamiOverrides) { + return { + email: 'u@windmill.dev', + username: 'u', + is_admin: false, + is_super_admin: false, + operator: false, + created_at: '', + disabled: false, + groups: [], + folders: [], + folders_read: [], + folders_owners: [], + ...overrides + } +} + +async function capabilitiesFor(overrides: WhoamiOverrides, workspace = 'ws') { + whoami.mockResolvedValueOnce(user(overrides)) + return await resolveSessionAccess(workspace) +} + +describe('resolveSessionAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + deployRules.mockResolvedValue({ ok: true }) + // The role memo is app-wide, so without this each case answers from the previous one. + clearWorkspaceRoleCache() + }) + + it('gives a developer every capability but admin', async () => { + const caps = await capabilitiesFor({}) + expect([...caps].sort()).toEqual(['deploy', 'manage_code', 'run_preview', 'write_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']) + }) + + 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([]) + }) + + // Both spellings of `authed.is_admin`, which the draft path honours and the handlers + // refusing `authed.is_operator` do not — the session path never clears that flag. + it.each([{ is_admin: true }, { is_super_admin: true }])( + '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']) + } + ) + + it('keeps drafting when a rule refuses deploy', async () => { + deployRules.mockResolvedValue({ + ok: false, + reason: 'restricted to deployers', + refusedBy: 'RestrictDeployToDeployers' + }) + const caps = await capabilitiesFor({}) + expect(caps.has('deploy')).toBe(false) + expect(caps.has('write_draft')).toBe(true) + }) + + it('grants everything when the role cannot be resolved', async () => { + whoami.mockRejectedValueOnce(new Error('network')) + const access = await resolveSessionAccess('ws') + expect(access.has('write_draft')).toBe(true) + expect(access.has('deploy')).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts new file mode 100644 index 0000000000..1ac13136a4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -0,0 +1,68 @@ +import { getWorkspaceRole } from '$lib/user' +import { checkDeployRules } from '$lib/utils_workspace_deploy' +import type { SessionAccess, SessionCapability } from '../sessionCapabilities' + +const ALL_CAPABILITIES: SessionCapability[] = [ + 'write_draft', + 'run_preview', + 'deploy', + 'manage_code', + 'admin' +] + +/** Fail open, here and at every resolution failure below: blanking a toolset on a + * transient error tells a developer mid-session that they cannot author anything, + * which is worse and far less legible than the 403 they get by trying. Matches + * `checkDeployPermission`, which fails open for the same reason. */ +export function fullSessionAccess(): SessionAccess { + return new Set(ALL_CAPABILITIES) +} + +/** Pure, so the profiles this can produce are enumerable rather than listed by hand. + * `isAdmin` is one field because the server's `authed.is_admin` is + * `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. */ +export function capabilitiesForRole(role: { + isAdmin: boolean + operator: boolean + deployRulesPass: boolean +}): SessionAccess { + const capabilities = new Set() + // 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) { + capabilities.add('write_draft') + } + if (!role.operator) { + capabilities.add('run_preview') + capabilities.add('manage_code') + } + // 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) { + capabilities.add('deploy') + } + if (role.isAdmin) { + capabilities.add('admin') + } + return capabilities +} + +export async function resolveSessionAccess(workspace: string): Promise { + // Shares the 5-minute memo with the identity the same pre-flight resolves beside this + // one, so a send costs one `whoami` rather than two, at the cost of a role changed + // elsewhere landing within that window rather than on the very next message. + // `lookup_failed` covers a rejected request AND a body too malformed to map. + const lookup = await getWorkspaceRole(workspace) + if (lookup.kind !== 'resolved') { + return fullSessionAccess() + } + const me = lookup.user + + return capabilitiesForRole({ + isAdmin: !!me.is_admin || !!me.is_super_admin, + operator: !!me.operator, + deployRulesPass: (await checkDeployRules(workspace, me)).ok + }) +} diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts new file mode 100644 index 0000000000..42136112c4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' + +// The toolset pulls in the script/flow editor tools, hence monaco. Same stand-ins as +// global/core.test.ts; nothing here executes a tool, so bare shapes are enough. +vi.mock('monaco-editor', () => ({ + editor: {}, + languages: {}, + KeyCode: {}, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 } +})) +vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({ + getTypeScriptWorker: async () => async () => ({}), + typescriptVersion: 'test' +})) +vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) +vi.mock('$lib/components/vscode', () => ({})) + +// `globalToolsFor` withholds take_screenshot off Blink, and node reports a `Node.js/` +// user agent — so without this the description sweep below silently skips a tool that +// ships in every real Chromium session. Claim the maximal toolset instead. +vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Chrome/120.0.0.0' }) + +import { globalTools, prepareGlobalSystemMessage, type SessionPromptContext } from './core' +import { appendPlanModeInstructions } from '../planMode' +import { PlanModeController } from '../planModeController.svelte' +import { pipelineTools } from '../pipeline/core' +import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly' +import { filterSessionTools, sessionToolAllowed } from './sessionToolset' +import { capabilitiesForRole, fullSessionAccess } from './sessionAccess' +import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCapabilities' + +const ASSEMBLY_OPTS = { + previewTools: true, + pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }, + mcpServers: [{ path: 'f/test/server' } as any] +} + +/** Every tool that can reach a session toolset: what assembly builds, so a source added + * there needs no edit here, unioned with the static sources so the set cannot shrink if + * assembly stops reaching one, plus the plan tools the manager appends. */ +function sessionReachableTools(): SessionTool[] { + const plan = new PlanModeController({ available: true } as any).availableTools + const byName = new Map>() + for (const t of [ + ...assembleGlobalTools(ASSEMBLY_OPTS), + ...globalTools, + ...pipelineTools, + ...plan + ]) { + byName.set(t.def.function.name, t) + } + return [...byName.values()] +} + +function withheldNames(access: SessionAccess): string[] { + return sessionReachableTools() + .filter((t) => !sessionToolAllowed(t, access)) + .map((t) => t.def.function.name) +} + +/** The tools a session actually ships, through the same assembly production uses. */ +function shippedSessionTools(access: SessionAccess) { + return filterSessionTools(assembleGlobalTools(ASSEMBLY_OPTS), access) +} + +function accessWith(capabilities: SessionCapability[]): SessionAccess { + return new Set(capabilities) +} + +/** The profiles a real session can hold, keyed for the test name, over every combination + * of the role facts `capabilitiesForRole` reads. */ +const REACHABLE_PROFILES = [ + ...new Map( + [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 + }) + ) + ) + ).entries() +] + +/** One per branch of getSessionContextPromptSection — each words the deploy target + * differently, so a gate fixed in one branch can still leak in another. */ +const SESSION_CONTEXTS: SessionPromptContext[] = [ + { pendingForkOf: 'parent' }, + { workspaceId: 'dev', parentWorkspaceId: 'parent', isDevWorkspace: true }, + { workspaceId: 'fork', parentWorkspaceId: 'parent' }, + { workspaceId: 'fork', forkParentUnknown: true }, + { workspaceId: 'live' }, + {} +] + +describe('session tool policies', () => { + // Full access must be a no-op, or every existing session (and the ai_evals + // baseline measured against it) changes behaviour. + it('withholds nothing from a session with every capability', () => { + const tools = sessionReachableTools() + const filtered = filterSessionTools(tools, fullSessionAccess()) + expect(filtered).toHaveLength(tools.length) + filtered.forEach((tool, i) => expect(tool).toBe(tools[i])) + }) + + it('withholds a tool that arrives without a policy', () => { + expect(sessionToolAllowed({ def: globalTools[0].def }, fullSessionAccess())).toBe(false) + }) + + it('passes the toolset through untouched when access is unresolved', () => { + const tools = globalTools.map((t) => ({ def: t.def })) + expect(filterSessionTools(tools, undefined)).toBe(tools) + }) + + // The kind is an argument, so its enum carries the permission, per the handler each kind + // reaches. Asserted on the shipped schema, since that is all the model sees. + it('offers the kind-taking tools only the kinds their handlers accept', () => { + const shipped = (name: string, access: SessionAccess) => + shippedSessionTools(access).find((t) => t.def.function.name === name)! + const kindsOf = (name: string, access: SessionAccess) => + (shipped(name, access).def.function.parameters as any).properties.type.enum + + // A developer a protection rule refuses keeps the two kinds no rule reaches. + const refused = accessWith(['write_draft', 'run_preview', 'manage_code']) + 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']) + expect(kindsOf('deploy_workspace_item', operator)).toEqual([ + 'schedule', + 'trigger', + 'resource', + 'variable' + ]) + + // Deleting a script is admin-only, where deploying one is not. + const developer = accessWith(['write_draft', 'run_preview', 'manage_code', 'deploy']) + expect(kindsOf('deploy_workspace_item', developer)).toContain('script') + expect(kindsOf('delete_workspace_item', developer)).not.toContain('script') + + // Full access narrows nothing and ships the shared object itself, so the tool defs — + // part of every iteration's cached prefix — are byte for byte what they were. + const original = globalTools.find((t) => t.def.function.name === 'deploy_workspace_item')! + expect(shipped('deploy_workspace_item', fullSessionAccess())).toBe(original) + // And narrowing never writes through to it: the def is shared by every session, so a + // mutation here would strip one user's kinds from everyone else's schema. + expect((original.def.function.parameters as any).properties.type.enum).toContain('script') + }) + + // Naming a tool the model was not given produces invented calls. Asserted over the + // ASSEMBLED message: the session-state, pipeline and plan-mode sections are each + // appended by a different caller, and each can name a withheld tool. + it.each(REACHABLE_PROFILES)( + 'never names a withheld tool in the assembled prompt (%s)', + (_label, access) => { + const withheld = withheldNames(access) + // The tool DEFINITIONS ship alongside the prompt, so a withheld name in a + // description is the same broken promise as one in the prompt. + const defs = JSON.stringify(shippedSessionTools(access).map((t) => t.def)) + expect(withheld.filter((n) => defs.includes(n))).toEqual([]) + for (const previewTools of [false, true]) { + for (const ctx of SESSION_CONTEXTS) { + const msg = assembleGlobalSystemMessage(undefined, { + previewTools, + user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] }, + access, + sessionContext: ctx, + pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] } + }) + // Both decoration variants: the escalation one adds its own tool mentions. + for (const blocks of [0, 9]) { + const full = appendPlanModeInstructions(msg, blocks).content as string + expect(withheld.filter((n) => full.includes(n))).toEqual([]) + } + } + } + } + ) + + // A tool result is the one place the sweep above cannot see, and `get_instructions` + // 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) => + tool.fn({ + args: { subject: 'script', language: 'bun' }, + workspace: 'ws', + helpers: { access }, + toolId: 't1', + toolCallbacks: { setToolStatus: () => {} } + } as any) as Promise + + const readOnly = accessWith([]) + const withheld = withheldNames(readOnly) + const restricted = await call(readOnly) + 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 full-access profile must gate nothing at all: the text has to match the ungated + // build byte for byte, or every session's cached prefix and the ai_evals baseline + // move underneath us. + it('builds an unchanged prompt when every capability is present', () => { + const user = { username: 'alex', folders: ['shared'], folders_read: ['shared'] } + for (const previewTools of [false, true]) { + const ungated = prepareGlobalSystemMessage(undefined, { previewTools, user }).content + const full = prepareGlobalSystemMessage(undefined, { + previewTools, + user, + access: fullSessionAccess() + }).content + expect(full).toBe(ungated) + } + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts new file mode 100644 index 0000000000..4789967a6d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts @@ -0,0 +1,46 @@ +import type { ChatCompletionFunctionTool } from 'openai/resources/index.mjs' +import type { SessionAccess, SessionToolPolicy } from '../sessionCapabilities' + +type FilterableTool = { + def: ChatCompletionFunctionTool + requires?: SessionToolPolicy + kindRequires?: Readonly>> +} + +const satisfies = (policy: SessionToolPolicy, access: SessionAccess) => + policy.every((c) => access.has(c)) + +/** Fails closed: every array that feeds a session is typed to declare `requires`, so a tool + * without one arrived some other way, and leaking it is the worse of the two answers. */ +export function sessionToolAllowed(tool: FilterableTool, access: SessionAccess): boolean { + return tool.requires ? satisfies(tool.requires, access) : false +} + +/** Filter an assembled toolset, and cut each kind-taking tool's `type` enum to the kinds + * `access` can land. `access` undefined means "not resolved yet, or not a session" — the + * toolset passes through untouched. */ +export function filterSessionTools( + tools: T[], + access: SessionAccess | undefined +): T[] { + if (!access) return tools + return tools.filter((t) => sessionToolAllowed(t, access)).map((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(tool: T, access: SessionAccess): T { + const kindRequires = tool.kindRequires + if (!kindRequires) return tool + const parameters = tool.def.function.parameters as { + properties: Record + } + const kinds = parameters.properties.type.enum ?? [] + // 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 + 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/mention.test.ts b/frontend/src/lib/components/copilot/chat/mention.test.ts index 8d379ce3b4..ae021bf1de 100644 --- a/frontend/src/lib/components/copilot/chat/mention.test.ts +++ b/frontend/src/lib/components/copilot/chat/mention.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { MENTION_RE, mentionTitle, formatMention } from './mention' +import { + MENTION_RE, + mentionTitle, + formatMention, + hasMention, + mentionTitlesInText, + removeMentionFromText +} from './mention' describe('formatMention', () => { it('leaves a simple name bare', () => { @@ -52,3 +59,83 @@ describe('MENTION_RE', () => { expect(mentionTitle(m[0])).toBe(name) }) }) + +describe('removeMentionFromText', () => { + it('removes a bare mention and keeps neighboring words separated', () => { + expect(removeMentionFromText('before @app.ts after', 'app.ts')).toBe('before after') + }) + + it('removes a bracketed mention', () => { + expect(removeMentionFromText('before @[my folder/a b.ts] after', 'my folder/a b.ts')).toBe( + 'before after' + ) + }) + + it('removes an escaped bracketed mention', () => { + expect(removeMentionFromText('before @[notes \\] draft.md] after', 'notes ] draft.md')).toBe( + 'before after' + ) + }) + + it('removes edge mentions without leaving extra whitespace', () => { + expect(removeMentionFromText('@app.ts after', 'app.ts')).toBe('after') + expect(removeMentionFromText('before @app.ts', 'app.ts')).toBe('before') + }) + + it('keeps embedded mention-like text intact', () => { + expect(removeMentionFromText('Contact owner@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact owner@app.ts about' + ) + }) + + it('keeps Unicode-prefixed embedded mention-like text intact', () => { + expect(removeMentionFromText('Contact 用户@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact 用户@app.ts about' + ) + expect(removeMentionFromText('Contact हिंदी@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact हिंदी@app.ts about' + ) + expect(removeMentionFromText('Contact 𐐀@app.ts about @app.ts', 'app.ts')).toBe( + 'Contact 𐐀@app.ts about' + ) + }) +}) + +describe('hasMention', () => { + it('does not treat embedded mention-like text as a selected context mention', () => { + expect(hasMention('Contact owner@app.ts', 'app.ts')).toBe(false) + expect(hasMention('Contact owner@app.ts about @app.ts', 'app.ts')).toBe(true) + }) + + it('does not treat Unicode-prefixed embedded text as a mention', () => { + expect(hasMention('Contact 用户@app.ts', 'app.ts')).toBe(false) + expect(hasMention('Contact हिंदी@app.ts', 'app.ts')).toBe(false) + expect(hasMention('Contact 𐐀@app.ts', 'app.ts')).toBe(false) + }) + + it('treats punctuation next to a mention as a boundary', () => { + expect(hasMention('use @app.ts, then compare', 'app.ts')).toBe(true) + expect(hasMention('open (@app.ts)', 'app.ts')).toBe(true) + }) +}) + +describe('mentionTitlesInText', () => { + it('extracts only standalone mentions for context synchronization', () => { + expect([...mentionTitlesInText('Contact owner@app.ts about @app.ts')]).toEqual(['app.ts']) + expect([...mentionTitlesInText('Contact owner@app.ts')]).toEqual([]) + }) + + it('ignores Unicode-prefixed embedded text for context synchronization', () => { + expect([...mentionTitlesInText('Contact 用户@app.ts')]).toEqual([]) + }) + + it('keeps punctuation-adjacent mentions synchronized', () => { + expect([...mentionTitlesInText('use @app.ts, then compare')]).toEqual(['app.ts']) + expect([...mentionTitlesInText('open (@app.ts)')]).toEqual(['app.ts']) + }) + + it('ignores combining-mark and astral-letter embedded text', () => { + expect([...mentionTitlesInText('Contact हिंदी@app.ts')]).toEqual([]) + expect([...mentionTitlesInText('Contact 𐐀@app.ts')]).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/mention.ts b/frontend/src/lib/components/copilot/chat/mention.ts index 65a2b3b7c6..d1d42696b0 100644 --- a/frontend/src/lib/components/copilot/chat/mention.ts +++ b/frontend/src/lib/components/copilot/chat/mention.ts @@ -29,3 +29,59 @@ const BARE_SAFE = /^[\w/.\-]+$/ export function formatMention(name: string): string { return BARE_SAFE.test(name) ? `@${name}` : `@[${name.replace(/[\\\]]/g, '\\$&')}]` } + +function isWordChar(char: string | undefined): boolean { + return char !== undefined && /[\p{L}\p{N}\p{M}_]/u.test(char) +} + +function codePointBefore(text: string, index: number): string | undefined { + if (index <= 0) return undefined + const prev = text.charCodeAt(index - 1) + if (prev >= 0xdc00 && prev <= 0xdfff && index > 1) return text.slice(index - 2, index) + return text[index - 1] +} + +// A mention is owned by this parser when the `@` starts a token. The trailing side is +// intentionally open: non-spacing scripts often continue immediately after a mention, +// while embedded forms like `owner@app.ts` are rejected by the leading boundary. +export function hasMentionLeadingBoundary(text: string, index: number): boolean { + return !isWordChar(codePointBefore(text, index)) +} + +export function isStandaloneMention(text: string, match: RegExpMatchArray): boolean { + if (match.index === undefined) return false + return hasMentionLeadingBoundary(text, match.index) +} + +export function hasMention(text: string, title: string): boolean { + return [...text.matchAll(MENTION_RE)].some( + (m) => mentionTitle(m[0]) === title && isStandaloneMention(text, m) + ) +} + +export function mentionTitlesInText(text: string): Set { + const out = new Set() + for (const m of text.matchAll(MENTION_RE)) { + if (isStandaloneMention(text, m)) out.add(mentionTitle(m[0])) + } + return out +} + +export function removeMentionFromText(text: string, title: string): string { + let out = '' + let last = 0 + for (const m of text.matchAll(MENTION_RE)) { + if (m.index === undefined || mentionTitle(m[0]) !== title) continue + let start = m.index + let end = m.index + m[0].length + if (!isStandaloneMention(text, m)) continue + const hasLead = start > 0 && /\s/.test(text[start - 1]) + const hasTrail = end < text.length && /\s/.test(text[end]) + if (hasLead && !hasTrail) start -= 1 + if (!hasLead && hasTrail) end += 1 + if (hasLead && hasTrail) end += 1 + out += text.slice(last, start) + last = end + } + return out + text.slice(last) +} diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.test.ts b/frontend/src/lib/components/copilot/chat/openai-responses.test.ts index 276084a171..951f8f0c77 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.test.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.test.ts @@ -3,8 +3,10 @@ import { buildPromptCacheKey, getOpenAIResponsesCompletion, openAIWebSearchDetails, + parseOpenAIResponsesCompletion, toResponsesContent } from './openai-responses' +import { OutputTokenLimitError } from './outputTokenLimit' const mocks = vi.hoisted(() => ({ getProviderAndCompletionConfig: vi.fn(), @@ -153,3 +155,34 @@ describe('openAIWebSearchDetails', () => { ).toEqual({ query: undefined, sources: [{ url: 'https://a.dev' }] }) }) }) + +describe('parseOpenAIResponsesCompletion output token limit', () => { + it('fails a response the stream reports incomplete at max_output_tokens', async () => { + const handlers: Record void> = {} + // The SDK's final snapshot of an incomplete response still reads in_progress; + // only the response.incomplete event carries the reason. + const runner = { + on: (name: string, fn: (event: any) => void) => { + handlers[name] = fn + }, + done: async () => { + handlers['response.incomplete']?.({ + type: 'response.incomplete', + response: { incomplete_details: { reason: 'max_output_tokens' } } + }) + }, + finalResponse: async () => ({ status: 'in_progress', output: [] }) + } + + const parsed = parseOpenAIResponsesCompletion( + runner as any, + { onNewToken: vi.fn(), onMessageEnd: vi.fn(), setToolStatus: vi.fn() } as any, + [], + [], + [], + {} + ) + + await expect(parsed).rejects.toBeInstanceOf(OutputTokenLimitError) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 4118eb492d..31d3b379ac 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -21,6 +21,7 @@ import { type ToolCallbacks, type WebSearchSource } from './shared' +import { OutputTokenLimitError } from './outputTokenLimit' import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs' import type { AIProviderModel } from '$lib/gen' import { openAIResponsesUsageToChatTokenUsage, type ChatTokenUsage } from './tokenUsage' @@ -271,7 +272,8 @@ export async function getOpenAIResponsesCompletion( messages, stream: true, tools, - forceModelProvider: options?.forceModelProvider + forceModelProvider: options?.forceModelProvider, + reasoningEffort: options?.reasoningEffort }) const { instructions, input } = convertMessagesToResponsesInput(messages) const responsesConfig = applyReasoningToConfig( @@ -330,7 +332,8 @@ export async function* getOpenAIResponsesCompletionStream( messages, stream: true, tools, - forceModelProvider: options?.forceModelProvider + forceModelProvider: options?.forceModelProvider, + reasoningEffort: options?.reasoningEffort }) const { instructions, input } = convertMessagesToResponsesInput(messages) // No prompt cache key here: a rejected key has to be retried without it, and this is @@ -542,6 +545,13 @@ export async function parseOpenAIResponsesCompletion( }) }) + // The stream's final snapshot keeps the in-progress status, so an + // incomplete response is only visible through its own event. + let hitOutputTokenLimit = false + runner.on('response.incomplete', (event) => { + hitOutputTokenLimit = event.response.incomplete_details?.reason === 'max_output_tokens' + }) + // Handle errors runner.on('error', (err: OpenAIError | ResponseErrorEvent) => { currentStreamingTool = undefined @@ -591,7 +601,8 @@ export async function parseOpenAIResponsesCompletion( toolCall, helpers, toolCallbacks: callbacks, - workspace: options?.workspace + workspace: options?.workspace, + messages }) messages.push(messageToAdd) addedMessages.push(messageToAdd) @@ -600,6 +611,9 @@ export async function parseOpenAIResponsesCompletion( return { shouldContinue: true, tokenUsage } } + if (hitOutputTokenLimit) { + throw new OutputTokenLimitError() + } return { shouldContinue: false, tokenUsage } } diff --git a/frontend/src/lib/components/copilot/chat/outputTokenLimit.ts b/frontend/src/lib/components/copilot/chat/outputTokenLimit.ts new file mode 100644 index 0000000000..19b74f0c68 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/outputTokenLimit.ts @@ -0,0 +1,17 @@ +/** The provider ended the response at the request's output token cap. A turn + * cut off there must fail rather than end quietly: thinking counts toward the + * cap, so the stop often lands mid-thought and would read as the model giving + * up. Failing also keeps the turn's output so far for a follow-up. */ +export class OutputTokenLimitError extends Error { + constructor() { + super( + "The response was cut off at the model's output token limit. Ask it to continue, or raise the limit in the workspace AI settings under Model output limits." + ) + this.name = 'OutputTokenLimitError' + } +} + +/** Shown instead on Windmill's free tier, whose server clamps the output below any + * workspace setting: pointing at that setting would send the user to a no-op. */ +export const FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE = + "The response was cut off at the free tier's output token limit. Ask it to continue, or add your own API key in the workspace AI settings for a higher limit." diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.ts index f696b1c869..a1a8f1ad6b 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.ts @@ -1,7 +1,14 @@ import { z } from 'zod' import { $ScriptLang } from '$lib/gen/schemas.gen' import type { ScriptLang } from '$lib/gen' -import { createToolDef, executeTestRun, findAndReplace, type Tool } from '../shared' +import { createToolDef, executeTestRun, findAndReplace } from '../shared' +import { + NONE, + RUN_PREVIEW, + WRITE_DRAFT, + type SessionAccess, + type SessionTool +} from '../sessionCapabilities' import type { PipelineOutputKind } from '$lib/components/assets/AssetGraph/pipelineTemplates' // ============================================================================ @@ -113,7 +120,7 @@ const readPipelineNodeSchema = z.object({ const readPipelineNodeToolDef = createToolDef( readPipelineNodeSchema, 'read_pipeline_node', - 'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before edit_pipeline_node so edits target the exact current text.' + 'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before editing a node so edits target the exact current text.' ) // ---------------------------------------------------------------------------- @@ -205,8 +212,9 @@ function inferredLineageNote(reads: string[], writes: string[]): string { return ` Inferred lineage: ${parts.join('; ')}.` } -export const pipelineTools: Tool[] = [ +export const pipelineTools: SessionTool[] = [ { + requires: NONE, def: getPipelineGraphToolDef, planModeSafe: true, fn: async ({ helpers, toolId, toolCallbacks }) => { @@ -221,6 +229,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: NONE, def: readPipelineNodeToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { @@ -236,6 +245,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: WRITE_DRAFT, def: buildPipelineNodeToolDef, streamArguments: true, showDetails: true, @@ -258,6 +268,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: WRITE_DRAFT, def: editPipelineNodeToolDef, streamArguments: true, showDetails: true, @@ -286,6 +297,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: WRITE_DRAFT, def: removePipelineNodeToolDef, fn: async ({ args, helpers, toolId, toolCallbacks }) => { const pipeline = requirePipeline(helpers) @@ -300,6 +312,7 @@ export const pipelineTools: Tool[] = [ } }, { + requires: RUN_PREVIEW, def: testPipelineNodeToolDef, requiresConfirmation: true, confirmationMessage: 'Run pipeline node', @@ -331,8 +344,14 @@ export const pipelineTools: Tool[] = [ * /pipeline editor is open. Describes the annotation model and the direct-draft * workflow so the model uses the pipeline tools rather than the generic * write_script draft tools. + * + * `access` is the session's resolved capabilities (undefined outside a session, or + * before they resolve). The node-authoring guidance is dropped without `write_draft`, + * 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): string { +export function getPipelinePromptSection(ctx: PipelineContext, access?: SessionAccess): string { + const canWriteDraft = !access || access.has('write_draft') return ` Data Pipeline editor (ACTIVE): @@ -342,8 +361,12 @@ Data Pipeline editor (ACTIVE): - \`materialize\` (the managed output): a managed \`// materialize ducklake:///\` 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:///
    \`) — 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:////\`, with \`\` 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:////\` 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 append\` INSERT-appends rows (incremental); \`// materialize key=\` merges/upserts on \`\`. \`// materialize manual \` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned \` 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() = {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 = {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 = [where ]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension = \` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents 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 \` FILTER (WHERE )\`, 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}/\` — \`${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 \`\`). +- Every node of this pipeline lives at \`f/${ctx.folder}/\` — \`${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 \`\`).${ + 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. - 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). - Only deploy when the user explicitly asks; the user deploys drafts from the canvas.` + : '' + }` } diff --git a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts index ab80dc3e88..01e5040040 100644 --- a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts @@ -1,6 +1,7 @@ import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter' -import { createToolDef, type Tool, type ToolCallbacks } from './shared' +import { createToolDef, type ToolCallbacks } from './shared' +import { NONE, type SessionTool } from './sessionCapabilities' import { appendPlanModeInstructions, derivePlanTitle, @@ -100,14 +101,24 @@ export class PlanModeController { /** Only the transition the current posture allows; auto-accepting exposes neither, since * entering is the user's choice. */ - get tools(): Tool[] { + get tools(): SessionTool[] { if (!this.#host.available) return [] if (this.#host.autoAccepting) return [] return this.#host.active ? [this.exitTool] : [this.enterTool] } + /** Both transitions, whichever posture is selected: what plan mode contributes to this + * chat's capabilities, rather than to the turn it is about to send. */ + get availableTools(): SessionTool[] { + return this.#host.available ? [this.enterTool, this.exitTool] : [] + } + // This safety tag is what keeps plan mode escapable through its handoff tool. - exitTool: Tool = { + exitTool: SessionTool = { + // Ungated even for a user who can change nothing: the posture is the USER's choice, + // and its instructions order this call to hand the plan over — withholding it + // strands the model in a round nothing else can end. + requires: NONE, def: createToolDef(exitPlanModeArgs, EXIT_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL_DESCRIPTION), planModeSafe: true, requiresConfirmation: true, @@ -156,7 +167,8 @@ export class PlanModeController { } } - enterTool: Tool = { + enterTool: SessionTool = { + requires: NONE, def: createToolDef(enterPlanModeArgs, ENTER_PLAN_MODE_TOOL, ENTER_PLAN_MODE_TOOL_DESCRIPTION), planModeSafe: true, requiresConfirmation: true, diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts new file mode 100644 index 0000000000..f235ae3796 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('monaco-editor', () => ({ + editor: {}, + languages: {}, + KeyCode: {}, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 } +})) +vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({ + getTypeScriptWorker: async () => async () => ({}), + typescriptVersion: 'test' +})) +vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) +vi.mock('$lib/components/vscode', () => ({})) + +import { editCodeToolWithDiff } from './core' + +describe('editCodeToolWithDiff', () => { + it('records the complete script diff after applying replacements', async () => { + const statuses: unknown[] = [] + const applyCode = vi.fn(async () => {}) + + await editCodeToolWithDiff.fn({ + args: { diffs: [{ old_string: 'return 1', new_string: 'return 2' }] }, + helpers: { + getScriptOptions: () => ({ + code: 'export function main() {\n\treturn 1\n}', + lang: 'bun', + path: 'f/example', + args: {} + }), + applyCode + }, + toolCallbacks: { + setToolStatus: (_toolId, status) => statuses.push(status) + }, + toolId: 'edit-1' + }) + + expect(applyCode).toHaveBeenCalledTimes(2) + expect(statuses.at(-1)).toMatchObject({ + codeDiff: { + before: 'export function main() {\n\treturn 1\n}', + after: 'export function main() {\n\treturn 2\n}', + lang: 'typescript' + } + }) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 622f182eae..92cb889b24 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -9,6 +9,7 @@ import type { } from 'openai/resources/index.mjs' import { type DBSchema, dbSchemas } from '$lib/stores' import type { ContextElement } from '../context' +import { NONE, RUN_PREVIEW, type SessionTool } from '../sessionCapabilities' import { createSearchHubScriptsTool, type Tool, @@ -27,6 +28,7 @@ import type { ReviewChangesOpts } from '../monaco-adapter' import { copilotInfo, getCurrentModel } from '$lib/aiStore' import { get } from 'svelte/store' import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata' +import { scriptLangToEditorLang } from '$lib/scripts' import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts' // Score threshold for npm packages search filtering @@ -461,9 +463,12 @@ export const resourceTypeTool: Tool = { // Generic DB schema tool factory shared by the script, flow and global modes export function createDbSchemaTool( opts: { description?: string; updateEditorCache?: boolean } = {} -): Tool { +): SessionTool { const { description, updateEditorCache = true } = opts return { + // `getDbSchemas` below introspects by running a query job through /jobs/run/preview, + // which jobs.rs refuses operators — so this reads like a lookup but gates like a run. + requires: RUN_PREVIEW, def: description ? { ...DB_SCHEMA_FUNCTION_DEF, @@ -609,7 +614,8 @@ const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionFunctionTool = { } // Helpers-agnostic so both script mode and global mode can offer it. -export const searchNpmPackagesTool: Tool<{}> = { +export const searchNpmPackagesTool: SessionTool<{}> = { + requires: NONE, def: SEARCH_NPM_PACKAGES_TOOL, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { @@ -826,7 +832,12 @@ export const editCodeToolWithDiff: Tool = { toolCallbacks.setToolStatus(toolId, { content: `Code changes applied`, - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: updatedCode, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return `Applied changes to the script editor.` } catch (error) { @@ -878,7 +889,12 @@ export const editCodeTool: Tool = { toolCallbacks.setToolStatus(toolId, { content: 'Code changes applied', - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: args.code, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return 'Code has been applied to the script editor.' } catch (error) { diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts new file mode 100644 index 0000000000..fc77b3e1c4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -0,0 +1,52 @@ +import type { Tool } from './shared' + +// A leaf on purpose: every module defining a session-reachable tool imports these +// constants, so importing anything with side effects here drags it into all of them. + +/** + * What a user may do in ONE workspace, as an AI session's toolset needs to know it. + * Best-effort, not a boundary — the token is the enforcement point, so this narrows + * what the model is offered and guarantees nothing. + */ +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 the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ + | 'run_preview' + /** Passes `check_deploy_rules`. */ + | 'deploy' + /** Passes the operator refusal the script, flow and app 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 `require_admin`. */ + | 'admin' + +export type SessionAccess = ReadonlySet + +/** + * The capabilities without which a tool's call cannot succeed — usually because the + * backend refuses it, occasionally because only that capability produces the tool's + * input. Never a relevance judgement: a tool the server would accept ships, even when + * it is of little use to the session, because withholding it is a stricter answer than + * the one the user would get by trying. + */ +export type SessionToolPolicy = readonly SessionCapability[] + +export const NONE: SessionToolPolicy = [] +export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] +export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] +export const DEPLOY: SessionToolPolicy = ['deploy'] + +/** + * 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. + */ +export type SessionTool = Tool & { + requires: SessionToolPolicy + /** For a tool taking the item kind as its `type` argument, where one verdict for the whole + * tool is either too strict or too loose: what each kind's handler needs. A session is + * offered only the kinds it can land, and the rest never appear in the schema it sees. */ + kindRequires?: Readonly>> +} diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 768b6d1b65..4574df7355 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { DisplayMessage, ToolDisplayMessage } from './shared' -import { openItemPreviewAction } from './shared' +import { openItemPreviewAction, webSearchResultOf } from './shared' vi.mock('monaco-editor', () => ({ editor: {} @@ -54,7 +54,10 @@ vi.mock('$lib/gen', () => ({ AzureTriggerService: { createAzureTrigger: vi.fn() }, AmqpTriggerService: { createAmqpTrigger: vi.fn() }, EmailTriggerService: { createEmailTrigger: vi.fn() }, - SettingService: { getGlobal: vi.fn() } + SettingService: { getGlobal: vi.fn() }, + ResourceService: { + getResourceValue: vi.fn(async ({ path }: { path: string }) => ({ content: `body of ${path}` })) + } })) vi.mock('$lib/utils', () => ({ @@ -205,6 +208,106 @@ describe('buildContextString', () => { }) describe('processToolCall', () => { + it('delivers folder instructions once, holding back a change until they are read', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const read = vi.fn(async () => 'read ok') + const write = vi.fn(async () => 'write ok') + const tools = [ + { def: createToolDef(z.object({}), 'read_item', 'Read'), planModeSafe: true, fn: read }, + { def: createToolDef(z.object({}), 'write_item', 'Write'), fn: write } + ] + const folderInstructions = { + list: () => [ + { path: 'f/billing/AGENTS', scope: 'f/billing/' }, + { path: 'f/billing/eu/AGENTS', scope: 'f/billing/eu/' }, + { path: 'f/other/AGENTS', scope: 'f/other/' }, + { path: 'g/ops/AGENTS', scope: 'g/ops/' } + ], + deliveredBy: new Map() + } + const messages: any[] = [] + const call = async ( + id: string, + name: string, + path: string | object, + workspace = 'test-workspace' + ) => { + const message = await processToolCall({ + tools, + toolCall: { + id, + type: 'function', + function: { + name, + arguments: JSON.stringify(typeof path === 'string' ? { path } : path) + } + }, + helpers: {}, + workspace, + messages, + toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), folderInstructions } + }) + messages.push(message) + return message.content as string + } + const turn = () => messages.push({ role: 'assistant', content: '' }) + + turn() + const readResult = await call('c1', 'read_item', 'f/billing/eu/invoice') + expect(read).toHaveBeenCalledTimes(1) + expect(readResult.startsWith('read ok')).toBe(true) + // Outermost first, so the nested folder's instructions read as the specific ones. + expect(readResult.indexOf('body of f/billing/AGENTS')).toBeLessThan( + readResult.indexOf('body of f/billing/eu/AGENTS') + ) + // Same batch: the model has not read c1's result yet, so the write still waits, + // pointed at that result rather than handed the bodies twice. + const sameBatch = await call('c2', 'write_item', 'f/billing/eu/invoice') + expect(write).not.toHaveBeenCalled() + expect(sameBatch).not.toContain('body of f/billing/AGENTS') + expect(sameBatch).toContain('earlier result of this same batch') + + turn() + expect(await call('c3', 'write_item', 'f/billing/eu/invoice')).toBe('write ok') + + const held = await call('c4', 'write_item', 'f/other/x') + expect(write).toHaveBeenCalledTimes(1) + expect(held).toContain('body of f/other/AGENTS') + expect(held).not.toContain('body of f/billing/AGENTS') + + turn() + expect(await call('c5', 'write_item', 'f/other/x')).toBe('write ok') + + expect(await call('g1', 'read_item', 'g/ops/runbook')).toContain('body of g/ops/AGENTS') + + // The same path in another workspace is another resource, delivered afresh. + expect(await call('w1', 'read_item', 'f/other/x', 'other-workspace')).toContain( + 'body of f/other/AGENTS' + ) + + // A delivery lost from the conversation (compaction) is made again. + messages.splice(0) + turn() + expect(await call('c6', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS') + + // A trigger names its target inside its config. + messages.splice(0) + turn() + const nested = await call('t1', 'write_item', { kind: 'http', config: { path: 'f/other/t' } }) + expect(nested).toContain('body of f/other/AGENTS') + expect(write).toHaveBeenCalledTimes(2) + + // A call stopped mid-run is answered with a placeholder under its id: no delivery. + messages.splice(0) + folderInstructions.deliveredBy.set('stopped', { + workspace: 'test-workspace', + paths: ['f/billing/AGENTS'] + }) + messages.push({ role: 'tool', tool_call_id: 'stopped', content: 'Interrupted' }) + turn() + expect(await call('c7', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS') + }) + it('returns pre-confirmation validation errors without asking for confirmation', async () => { const { createToolDef, processToolCall } = await import('./shared') const error = 'the script needs to be deployed before doing this action' @@ -1510,6 +1613,50 @@ describe('pollJobCompletion detach', () => { }) }) +describe('executeTestRun result note', () => { + // A failed run's card lands on the error, with the logs a tab away: the model must stay + // free to quote them, so only a success says the user already sees the run. + it('tells the model not to repeat a successful result, and not a failed one', async () => { + vi.useFakeTimers() + try { + const { executeTestRun } = await import('./shared') + const { JobService } = await import('$lib/gen') + const getJobUpdates = vi.mocked(JobService.getJobUpdates) + getJobUpdates.mockReset() + getJobUpdates.mockResolvedValue({ completed: true, running: false } as any) + const run = async (success: boolean) => { + vi.mocked(JobService.getJob).mockReset() + vi.mocked(JobService.getJob).mockResolvedValue({ + type: 'CompletedJob', + success, + result: [1, 2], + logs: 'ran' + } as any) + const promise = executeTestRun({ + jobStarter: async () => 'job1', + workspace: 'w', + toolId: 'tool1', + startMessage: 'Starting...', + contextName: 'script', + // The job hooks are what mark the global/sessions chat. + toolCallbacks: { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + onJobStatus: vi.fn(), + onJobStarted: vi.fn() + } as any + }) + await vi.advanceTimersByTimeAsync(1000) + return promise + } + expect(await run(true)).toContain('Do not repeat') + expect(await run(false)).not.toContain('Do not repeat') + } finally { + vi.useRealTimers() + } + }) +}) + describe('deriveChatJobStatus', () => { // CompletedJob is discriminated by the presence of a `success` key; the branch // order deliberately mirrors JobStatusIcon so the badge and scalar never drift. @@ -1825,3 +1972,38 @@ describe('processToolCall confirmation hooks', () => { expect(tool.fn).toHaveBeenCalled() }) }) + +// Any tool gets the web search card by returning this shape. The card renders urls and +// titles only, so a result carrying anything more must keep its JSON pane. +describe('webSearchResultOf', () => { + it('reads the shape, from the value or its JSON text', () => { + const result = { sources: [{ url: 'https://a.dev', title: 'A' }], query: 'a' } + expect(webSearchResultOf(result)).toEqual(result) + expect(webSearchResultOf(JSON.stringify(result))).toEqual(result) + }) + + // A Python tool serializes an absent optional as null, and the card has nothing to tell + // its author why an almost-right result fell back to JSON. + it('reads null on an optional field as absent', () => { + expect( + webSearchResultOf({ sources: [{ url: 'https://a.dev', title: null }], query: null }) + ).toEqual({ + sources: [{ url: 'https://a.dev', title: undefined }], + query: undefined + }) + }) + + it.each([ + ['an extra key', { sources: [{ url: 'https://a.dev' }], summary: 'x' }], + ['an extra source field', { sources: [{ url: 'https://a.dev', snippet: 'x' }] }], + ['a source without url', { sources: [{ title: 'A' }] }], + // The card renders no relative or javascript: link, so a result whose urls it would + // drop keeps its own JSON rather than showing an empty source list. + ['a relative url', { sources: [{ url: '/docs/pg17', title: 'PG 17' }] }], + ['a javascript: url', { sources: [{ url: 'javascript:alert(1)' }] }], + ['no sources', { sources: [] }], + ['a bare list of links', [{ url: 'https://a.dev' }]] + ])('rejects %s', (_, result) => { + expect(webSearchResultOf(result)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index bd347224f9..1e486c7d41 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -7,6 +7,7 @@ import type { UserDraftItemKind } from '$lib/gen' // The gate's two refusals, from a module that holds prose and one size limit: under the // shallow-import rule below, the rest of plan mode is not reachable from here. import { PLAN_MODE_MESSAGES } from './planModeMessages' +import { NONE } from './sessionCapabilities' // Import-free leaf, so it satisfies the shallow-import rule below. import { openItemPreviewAction, @@ -38,6 +39,7 @@ import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } fro import { workspaceStore } from '$lib/stores' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree' +import { agentTestInputTransforms } from '$lib/components/flows/agentFormFields' import type { FunctionParameters } from 'openai/resources/shared.mjs' import { z } from 'zod' import { @@ -59,6 +61,7 @@ import { forLater } from '$lib/forLater' import { scriptLangToEditorLang } from '$lib/scripts' import { getCurrentModel } from '$lib/aiStore' import { type editor as meditor } from 'monaco-editor' +import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions' // Prettify function for code arguments - extracts and formats code from JSON function prettifyCodeArguments(content: string): string { @@ -610,6 +613,84 @@ export type WebSearchSource = { title?: string } +export type ToolCodeDiff = { + before: string + after: string + /** Monaco language id. */ + lang: string +} + +/** The result shape any tool returns to have it rendered as a web search card. */ +export type WebSearchResult = { + sources: WebSearchSource[] + query?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A link the card can render: the source list drops everything else, so a result whose + * urls are relative or `javascript:` would show as an empty list in place of its own output. */ +export function isRenderableSourceUrl(url: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(url).protocol) + } catch { + return false + } +} + +/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a + * TypeScript one, so both read as absent. */ +function isOptionalString(value: unknown): boolean { + return value === null || typeof value === 'string' +} + +function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } { + return ( + isRecord(value) && + typeof value.url === 'string' && + isRenderableSourceUrl(value.url) && + Object.entries(value).every( + ([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v)) + ) + ) +} + +/** + * A tool result as a web search, or undefined when it is anything else. The keys must be + * exactly `sources` and an optional `query`: the card renders only urls and titles, so a + * result carrying more would lose it. Accepts the result as its JSON text too. + */ +export function webSearchResultOf(result: unknown): WebSearchResult | undefined { + if (typeof result === 'string') { + try { + result = JSON.parse(result) + } catch { + return undefined + } + } + if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) { + return undefined + } + const sources = result.sources + if ( + !sources.every(isWebSearchSource) || + !Object.entries(result).every( + ([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v)) + ) + ) { + return undefined + } + return { + sources: sources.map((source) => ({ + url: source.url, + title: typeof source.title === 'string' ? source.title : undefined + })), + query: typeof result.query === 'string' ? result.query : undefined + } +} + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string @@ -654,6 +735,9 @@ export type ToolDisplayMessage = { * always-visible card that opens (or focuses) the item's preview in the * session side panel. Set only for session chats — the side panel is their surface. */ previewCard?: { kind: PreviewCardKind; path: string } + /** Whole text on both sides of an edit this call saved. Kept in full rather than as a + * reference: drafts are overwritten in place, so the sides cannot be fetched back later. */ + codeDiff?: ToolCodeDiff planArtifactId?: string /** The version this card's proposal wrote, so a card scrolled far up still opens the plan * it proposed rather than what the document became. */ @@ -661,6 +745,8 @@ export type ToolDisplayMessage = { /** Refused by the plan-mode gate. Renders as its own lean row rather than a tool * error, so the transcript says the mode stopped it and not that the call failed. */ blockedByPlanMode?: boolean + /** Held back until the model has read the folder instructions it was handed. */ + heldForFolderInstructions?: boolean /** The user declined: the reject button, a Stop, or a posture switch. Set only there, so * a decision is distinguishable from every other way a call errors. */ declinedByUser?: boolean @@ -893,20 +979,29 @@ function stringifyErrorBody(body: unknown): string { * (tab closed while a tool polls) logs nothing, so the statuses sum to the calls that * finished, not to the calls made. */ -type ToolCallStatus = 'ok' | 'error' | 'declined' | 'rejected' | 'blocked_plan_mode' +type ToolCallStatus = + | 'ok' + | 'error' + | 'declined' + | 'rejected' + | 'blocked_plan_mode' + | 'held_for_instructions' export async function processToolCall({ tools, toolCall, helpers, toolCallbacks, - workspace + workspace, + messages = [] }: { tools: Tool[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T toolCallbacks: ToolCallbacks workspace?: string + /** The conversation so far, for which folder instructions it already carries. */ + messages?: readonly ChatCompletionMessageParam[] }): Promise { const tool = tools.find((t) => t.def.function.name === toolCall.function.name) const workspaceId = workspace ?? get(workspaceStore) ?? '' @@ -990,6 +1085,43 @@ export async function processToolCall({ } } + // After the gates that refuse the call, so a refused call does not use up the + // delivery. A call that changes something is held back until the model has read + // the instructions: arriving with its result, they would be too late to shape it. + const folderInstructions = tool + ? await pendingFolderInstructions( + toolCallbacks.folderInstructions, + messages, + args, + workspaceId + ) + : undefined + if (folderInstructions?.paths.length) { + toolCallbacks.folderInstructions?.deliveredBy.set(toolCall.id, { + workspace: workspaceId, + paths: folderInstructions.paths + }) + } + if (folderInstructions && tool?.planModeSafe !== true) { + logToolOutcome('held_for_instructions') + toolCallbacks.setToolStatus(toolCall.id, { + content: 'Read folder instructions', + heldForFolderInstructions: true, + parameters: args, + isLoading: false, + isQueued: false, + isStreamingArguments: false, + needsConfirmation: false, + showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails + }) + return { + role: 'tool' as const, + tool_call_id: toolCall.id, + content: `Not run: this call touches a folder whose instructions you had not read yet. Follow them, then make the call again (adjusted if they require it).\n\n${folderInstructions.text}` + } + } + const requiresConfirmation = tool?.requiresConfirmation === true // By name: skipping the wait is itself an answer on the user's behalf, and one tool // must not be answered for. @@ -1094,7 +1226,7 @@ export async function processToolCall({ const toAdd = { role: 'tool' as const, tool_call_id: toolCall.id, - content: result + content: folderInstructions ? `${result}\n\n${folderInstructions.text}` : result } return toAdd } catch (err) { @@ -1168,7 +1300,10 @@ export interface Tool { workspace: string helpers: T }) => MaybePromise - setSchema?: (helpers: any) => Promise + /** This chat's definition of the tool, for a schema that depends on the chat (its workspace, + * its open flow). Returns a new def: tools are module singletons shared by every chat, so + * one written back leaks into the others. */ + schemaFor?: (helpers: any) => Promise /** Safe to run while plan mode is active. Absence fails closed. */ planModeSafe?: boolean /** The arguments a plan-mode-safe tool still refuses while the posture holds — for a tool @@ -1369,6 +1504,9 @@ export interface ToolCallbacks { attachToolImage?: (toolId: string, image: AttachedImage) => void /** Drain every image buffered this batch (insertion order), clearing the buffer. */ takePendingToolImages?: () => AttachedImage[] + /** Wired only by the global/sessions chat: delivers the folder instructions + * covering the paths a tool call names, once per conversation. */ + folderInstructions?: FolderInstructionsContext } export function createToolDef( @@ -1468,6 +1606,7 @@ export function isHubPath(path: string): boolean { } export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ + requires: NONE, def: searchHubScriptsToolDef, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { @@ -1871,6 +2010,15 @@ export async function buildTestRunArgs( return parsedArgs } +// Said with the result rather than in the system prompt: there, models still copied a +// result's table or JSON into the reply, under a card that already renders it. Only for +// a successful run in the generic card completedJobToolStatus fills: a failed run's card +// lands on the error with the logs a tab away, and a formatCompletion card shows its own. +// Never on a background completion: its card can be far up the chat by then, so the +// reply may be the only place the user reads the result. +const RESULT_SHOWN_NOTE = + "The user already sees this run's result in its card. Do not repeat the result or logs in your reply: say what it shows, quoting only the values your conclusion rests on." + // The string handed back to the model when a job is backgrounded. It carries the // job id so the model can pull status/args/result/logs on demand (get_run / list_runs), // and tells it the completion will be reported later (notify-only wake). @@ -2015,7 +2163,12 @@ export async function executeTestRun(config: TestRunConfig): Promise { ...(job.success ? {} : { error: getErrorMessage(job.result) }) }) - const summary = formatResultSummary(job.result, job.logs, job.success) + // detachEnabled marks the global/sessions chat; the in-editor chats read the summary + // alone. The card opening on the result is up to each tool: a run card does, a + // generic one only with `autoCollapseDetails: false` at its registration. + const summary = + (detachEnabled && job.success ? `${RESULT_SHOWN_NOTE}\n` : '') + + formatResultSummary(job.result, job.logs, job.success) // get_run only exists in the global/sessions chat (the same hosts that wire // the job hooks) — don't advertise it to in-editor chats. if (detachEnabled && config.contextName === 'flow' && !job.success) { @@ -2060,6 +2213,13 @@ type FlowStepRunConfig = { toolId: string loadScript?: FlowStepScriptLoader loadSubflow?: FlowStepSubflowLoader + /** The path of the flow the step belongs to. An agent step's preview carries it, as the flow + * editor's own step test does: the run is filed under it, and a managed memory keyed on a + * memory id someone named reads the flow's conversation rather than a stray one. */ + flowPath?: string + /** The flow editor's substitution of a linked agent's unsaved draft, passed in because it + * reaches the draft store, which this module's import list must not. */ + withAgentDrafts?: (value: FlowValue) => Promise } export type FlowStepTestRunConfig = FlowStepRunConfig & { @@ -2084,8 +2244,9 @@ export type ResolvedFlowStepRun = { startMessage: string /** Takes the arguments as submitted. The preprocessor's entrypoint override is added * here rather than by the caller: it is declared by no schema, so anything that - * conforms arguments to one would drop it. */ - startJob: (args: Record) => Promise + * conforms arguments to one would drop it. An agent step also takes the run form's rows: + * each runs from its submitted value, including one the submission leaves out. */ + startJob: (args: Record, formKeys?: readonly string[]) => Promise } function normalizeFlowStepArgs(args: Record | null | undefined): Record { @@ -2129,7 +2290,9 @@ export async function resolveFlowStepRun({ toolCallbacks, toolId, loadScript = loadDeployedScriptForFlowStep, - loadSubflow + loadSubflow, + flowPath, + withAgentDrafts }: FlowStepRunConfig): Promise { const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined @@ -2216,12 +2379,51 @@ export async function resolveFlowStepRun({ } } + if (moduleValue.type === 'aiagent') { + return { + module: targetModule, + // The job started is a flow preview: an agent step has none of its own, and the tool + // calls it makes are that flow's steps. + runnableKind: 'flow', + startMessage: `Starting test run of agent step "${stepId}"...`, + startJob: async (args, formKeys) => { + // The step alone, stripped of the settings around it: a mock would answer for it + // and a suspend would hold the run for an approval nobody is watching for. + const value: FlowValue = { + modules: [ + { + id: targetModule.id, + value: { + ...moduleValue, + input_transforms: agentTestInputTransforms( + moduleValue.input_transforms, + args, + formKeys + ) + } + } + ] + } + return JobService.runFlowPreview({ + workspace, + requestBody: { + // After the inputs are wired, as the flow editor's step test does: a linked agent's + // draft brings its own brain and tools, and the step's run inputs stay on top. + value: withAgentDrafts ? await withAgentDrafts(value) : value, + args, + path: flowPath + } + }) + } + } + } + toolCallbacks.setToolStatus(toolId, { content: `Step type "${moduleValue.type}" not supported for testing`, error: `Cannot test step of type "${moduleValue.type}"` }) throw new Error( - `Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow` + `Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow, aiagent` ) } diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts new file mode 100644 index 0000000000..5077585a4f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest' +import hljs from 'highlight.js/lib/core' +import { + argumentsCarryWholeFile, + diffLineCounts, + hasToolCodeDiff, + toolCodeDiff, + toolDiffLines, + visibleToolDiffRows +} from './toolCodeDiff' +import { highlightedSourceLines } from './toolCodeDiffHighlight' +import { TOOL_CODE_DIFF_LANGUAGES, toolCodeDiffLanguage } from './toolCodeDiffLanguage' +import type { ToolDisplayMessage } from './shared' + +function message(overrides: Partial): ToolDisplayMessage { + return { + role: 'tool', + tool_call_id: 'call-1', + content: 'Updated script "f/example" as a draft', + ...overrides + } +} + +describe('toolCodeDiff', () => { + it('uses the saved full script sides after an edit succeeds', () => { + const codeDiff = { + before: 'const before = true\n', + after: 'const after = true\n', + lang: 'typescript' + } + + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' }, + codeDiff + }) + ) + ).toBe(codeDiff) + }) + + it('uses edit arguments until the full script sides are available', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' } + }) + ) + ).toEqual({ before: 'before', after: 'after', lang: 'plaintext' }) + }) + + it('renders streamed edit arguments before their JSON object is complete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: '{"old_string":"before\\nline","new_string":"after' + }) + ) + ).toEqual({ before: 'before\nline', after: 'after', lang: 'plaintext' }) + }) + + it('uses each replacement from an in-editor edit while its arguments stream', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: { + diffs: [ + { old_string: 'first old', new_string: 'first new' }, + { old_string: 'second old', new_string: 'second new' } + ] + } + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('uses each replacement while in-editor edit arguments are incomplete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: + '{"diffs":[{"old_string":"first old","new_string":"first new"},{"old_string":"second old","new_string":"second new' + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('shows a streamed full-code editor update without replacements', () => { + expect( + toolCodeDiff(message({ toolName: 'edit_code', parameters: '{"code":"console.log(1)' })) + ).toEqual({ before: '', after: 'console.log(1)', lang: 'plaintext' }) + }) + + it('shows streamed full-script updates before the saved diff is available', () => { + expect( + toolCodeDiff( + message({ toolName: 'write_script', parameters: '{"content":"print(\\\"hello\\\")' }) + ) + ).toEqual({ before: '', after: 'print("hello")', lang: 'plaintext' }) + }) + + it('only marks tools with a defined argument diff as diff-capable', () => { + expect(hasToolCodeDiff('edit_script')).toBe(true) + expect(hasToolCodeDiff('edit_code')).toBe(true) + expect(hasToolCodeDiff('write_script')).toBe(true) + expect(hasToolCodeDiff('__proto__')).toBe(false) + expect(hasToolCodeDiff('toString')).toBe(false) + expect(hasToolCodeDiff(undefined)).toBe(false) + }) + + it('ignores tool names inherited from Object.prototype', () => { + expect(toolCodeDiff(message({ toolName: 'constructor', parameters: {} }))).toBeUndefined() + }) + + it('counts added and removed lines independently', () => { + expect( + diffLineCounts({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual({ + added: 2, + removed: 1 + }) + }) + + it('keeps a final newline as a diffable line', () => { + expect(toolDiffLines({ before: 'one', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'added', content: '', newLine: 2 } + ]) + }) + + it('does not add a final-newline row when creating or deleting a file', () => { + expect(toolDiffLines({ before: '', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'added', content: 'one', newLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + expect(toolDiffLines({ before: 'one\n', after: '', lang: 'plaintext' })).toEqual([ + { kind: 'removed', content: 'one', oldLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + }) + + it('keeps both gutters correct across removed and added lines', () => { + expect( + toolDiffLines({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { + kind: 'removed', + content: 'two', + oldLine: 2, + newLine: undefined, + changedRanges: [{ start: 0, length: 3 }] + }, + { + kind: 'added', + content: 'new', + oldLine: undefined, + newLine: 2, + changedRanges: [{ start: 0, length: 3 }] + }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 }, + { + kind: 'added', + content: 'four', + oldLine: undefined, + newLine: 4, + changedRanges: [{ start: 0, length: 4 }] + } + ]) + }) + + it('keeps unchanged trailing lines out of the change counts', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\nnew\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + expect(toolDiffLines(diff).filter((line) => line.content === 'three')).toEqual([ + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('does not report changes for identical files', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\ntwo\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 0, removed: 0 }) + expect(toolDiffLines(diff)).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'context', content: 'two', oldLine: 2, newLine: 2 }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('renders a newly created file', () => { + expect(toolDiffLines({ before: '', after: 'print("hello")', lang: 'python' })).toEqual([ + { + kind: 'added', + content: 'print("hello")', + oldLine: undefined, + newLine: 1, + changedRanges: [{ start: 0, length: 14 }] + } + ]) + }) + + it('renders a deleted file', () => { + expect(toolDiffLines({ before: 'print("goodbye")', after: '', lang: 'python' })).toEqual([ + { + kind: 'removed', + content: 'print("goodbye")', + oldLine: 1, + newLine: undefined, + changedRanges: [{ start: 0, length: 16 }] + } + ]) + }) + + it('uses Monaco line alignment for reordered lines', () => { + expect( + toolDiffLines({ + before: 'const a = 1\nconst b = 2', + after: 'const b = 2\nconst a = 1', + lang: 'typescript' + }) + ).toEqual([ + { + kind: 'added', + content: 'const b = 2', + newLine: 1, + changedRanges: [{ start: 0, length: 11, extendsToEnd: true }] + }, + { kind: 'context', content: 'const a = 1', oldLine: 1, newLine: 2 }, + { + kind: 'removed', + content: 'const b = 2', + oldLine: 2, + changedRanges: [{ start: 0, length: 11 }] + } + ]) + }) + + it('avoids expensive alignment while arguments stream', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(toolDiffLines({ before, after, lang: 'plaintext' }, true)).toHaveLength(400) + expect(diffLineCounts({ before, after, lang: 'plaintext' }, true)).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('diffs a small edit in a large file', () => { + const beforeLines = Array.from({ length: 600 }, (_, index) => `line ${index}`) + const afterLines = beforeLines.map((line, index) => (index === 400 ? 'changed' : line)) + const diff = { before: beforeLines.join('\n'), after: afterLines.join('\n'), lang: 'plaintext' } + const lines = toolDiffLines(diff) + + expect(lines.filter((line) => line.kind !== 'context')).toMatchObject([ + { kind: 'removed', content: 'line 400', oldLine: 401 }, + { kind: 'added', content: 'changed', newLine: 401 } + ]) + expect(lines[399]).toEqual({ kind: 'context', content: 'line 399', oldLine: 400, newLine: 400 }) + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + }) + + it('reports a large rewrite as a whole-file change', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(diffLineCounts({ before, after, lang: 'plaintext' })).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('keeps an empty streamed replacement paired with its own edit', () => { + const parameters = + '{"diffs":[{"old_string":"a","new_string":""},{"old_string":"b","new_string":"c"}]}' + + expect(toolCodeDiff(message({ toolName: 'edit_code', parameters }))).toEqual({ + before: 'a\nb', + after: '\nc', + lang: 'plaintext' + }) + }) + + it('recognizes calls whose arguments are a whole file', () => { + const call = ( + toolName: string, + parameters: unknown, + codeDiff?: ToolDisplayMessage['codeDiff'] + ) => argumentsCarryWholeFile(message({ toolName, parameters, codeDiff })) + + expect(call('write_script', { path: 'f/a', content: '' })).toBe(true) + expect(call('edit_code', '{"code":"x')).toBe(true) + expect( + call('edit_code', { code: 'x' }, { before: 'old', after: 'x', lang: 'typescript' }) + ).toBe(true) + expect(call('edit_code', { diffs: [{ old_string: 'a', new_string: 'b' }] })).toBe(false) + expect(call('edit_script', { old_string: 'a', new_string: 'b' })).toBe(false) + }) + + it('shows each row of a small edit once', () => { + const lines = toolDiffLines({ before: 'a\nb\nc\n', after: 'a\nB\nc\n', lang: 'plaintext' }) + + expect(visibleToolDiffRows(lines, new Map())).toEqual(lines) + expect(lines.map((line) => line.kind)).toEqual(['context', 'removed', 'added', 'context']) + }) + + it('bounds the rows of a large rewrite', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + const lines = toolDiffLines({ before, after, lang: 'plaintext' }) + const rows = visibleToolDiffRows(lines, new Map()) + + expect(rows).toHaveLength(802) + expect(rows[400]).toEqual({ kind: 'omitted', key: 'removed:0', count: 600 }) + expect(rows[401]).toMatchObject({ kind: 'added', newLine: 1 }) + expect(rows[801]).toEqual({ kind: 'omitted', key: 'added:1000', count: 600 }) + + // One click on a run reveals 400 more rows; the overall cap still holds until `end` is clicked. + const expanded = visibleToolDiffRows(lines, new Map([['removed:0', 1]])) + expect(expanded).toHaveLength(1_001) + expect(expanded[800]).toEqual({ kind: 'omitted', key: 'removed:0', count: 200 }) + expect(expanded[1_000]).toEqual({ kind: 'omitted', key: 'end', count: 801 }) + expect( + visibleToolDiffRows( + lines, + new Map([ + ['removed:0', 1], + ['end', 1] + ]) + ) + ).toHaveLength(1_202) + }) + + it('highlights each line within the scope of the whole source', () => { + const typescript = toolCodeDiffLanguage('typescript') + hljs.registerLanguage(typescript.name, typescript.register) + const code = 'const s = `a\nb`\nconst n = 1\n' + + const lines = highlightedSourceLines(code, typescript.name) + expect(lines).toHaveLength(3) + expect(lines[0]).toBe( + 'const s = `a' + ) + expect(lines[1]).toMatch(/^b`<\/span>/) + expect(highlightedSourceLines(code, typescript.name, 2)).toEqual(lines.slice(0, 2)) + expect(highlightedSourceLines('a\nb', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('a\nb\n', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('', typescript.name)).toEqual([]) + + const large = Array.from({ length: 2_001 }, () => 'const a = 1 < 2').join('\n') + '\n' + const plain = highlightedSourceLines(large, typescript.name) + expect(plain).toHaveLength(2_001) + expect(plain[0]).toBe('const a = 1 < 2') + + const minified = highlightedSourceLines('x'.repeat(100_001), typescript.name) + expect(minified).toEqual(['x'.repeat(10_000) + '…']) + }) + + it('has a highlighter for every supported editor language', () => { + expect(TOOL_CODE_DIFF_LANGUAGES).toEqual( + expect.arrayContaining([ + 'typescript', + 'javascript', + 'python', + 'json', + 'yaml', + 'sql', + 'shell', + 'powershell', + 'php', + 'rust', + 'graphql', + 'csharp', + 'nu', + 'java', + 'r', + 'go', + 'ruby', + 'plaintext' + ]) + ) + for (const language of TOOL_CODE_DIFF_LANGUAGES) { + const highlighter = toolCodeDiffLanguage(language) + hljs.registerLanguage(highlighter.name, highlighter.register) + expect(() => + hljs.highlight('const changed = true', { language: highlighter.name }) + ).not.toThrow() + } + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts new file mode 100644 index 0000000000..2e5d8bf13c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts @@ -0,0 +1,381 @@ +// This is Monaco's advanced diff engine; it supplies the same multi-line inner ranges. +import { DefaultLinesDiffComputer } from '@codingame/monaco-vscode-api/vscode/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer' +import type { ToolCodeDiff, ToolDisplayMessage } from './shared' + +// Builds a diff from a call's own arguments, for the moments the saved `codeDiff` does not +// exist: while the arguments stream, when the call failed, and for transcripts saved before +// calls recorded one. Argument diffs have no language or surrounding lines. +const ARGS_DIFF_BY_TOOL: Record ToolCodeDiff | undefined> = { + edit_script: (params) => argumentDiff(streamingEditArguments(params)), + write_script: (params) => fullContentArgumentDiff(params, 'content'), + edit_code: (params) => { + if ( + params && + typeof params === 'object' && + Array.isArray((params as { diffs?: unknown }).diffs) + ) { + const diffs = (params as { diffs: unknown[] }).diffs + return argumentDiffs(diffs.map(streamingEditArguments)) + } + if (typeof params === 'string') { + return ( + argumentDiffs(streamingEditArgumentsArray(params)) ?? + fullContentArgumentDiff(params, 'code') + ) + } + return argumentDiff(streamingEditArguments(params)) ?? fullContentArgumentDiff(params, 'code') + } +} + +function fullContentArgumentDiff(params: unknown, key: string): ToolCodeDiff | undefined { + const content = + params && typeof params === 'object' + ? (params as Record)[key] + : typeof params === 'string' + ? partialJsonString(params, key) + : undefined + return typeof content === 'string' ? { before: '', after: content, lang: 'plaintext' } : undefined +} + +function argumentDiff( + edit: { old_string?: string; new_string?: string } | undefined +): ToolCodeDiff | undefined { + return typeof edit?.old_string === 'string' || typeof edit?.new_string === 'string' + ? { + before: typeof edit.old_string === 'string' ? edit.old_string : '', + after: typeof edit.new_string === 'string' ? edit.new_string : '', + lang: 'plaintext' + } + : undefined +} + +function argumentDiffs( + edits: ({ old_string?: string; new_string?: string } | undefined)[] +): ToolCodeDiff | undefined { + const diffs = edits.map(argumentDiff).filter((diff): diff is ToolCodeDiff => diff !== undefined) + if (diffs.length === 0) return undefined + return { + before: diffs.map((diff) => diff.before).join('\n'), + after: diffs.map((diff) => diff.after).join('\n'), + lang: 'plaintext' + } +} + +function streamingEditArguments( + params: unknown +): { old_string?: string; new_string?: string } | undefined { + if (params && typeof params === 'object') + return params as { old_string?: string; new_string?: string } + if (typeof params !== 'string') return undefined + + return { + old_string: partialJsonString(params, 'old_string'), + new_string: partialJsonString(params, 'new_string') + } +} + +function streamingEditArgumentsArray( + partialJson: string +): { old_string?: string; new_string?: string }[] { + const oldStrings = partialJsonStrings(partialJson, 'old_string') + const newStrings = partialJsonStrings(partialJson, 'new_string') + return Array.from({ length: Math.max(oldStrings.length, newStrings.length) }, (_, index) => ({ + old_string: oldStrings[index], + new_string: newStrings[index] + })) +} + +function partialJsonString(partialJson: string, key: string): string | undefined { + return partialJsonStrings(partialJson, key)[0] +} + +function partialJsonStrings(partialJson: string, key: string): string[] { + const matches = partialJson.matchAll(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'g')) + const result: string[] = [] + // An empty string is a real value (a deletion): skipping it would pair the next edit's + // `new_string` with this edit's `old_string`. + for (const match of matches) { + try { + result.push(JSON.parse(`"${match[1]}"`)) + } catch { + continue + } + } + return result +} + +export function hasToolCodeDiff(toolName: string | undefined): boolean { + return toolName !== undefined && Object.hasOwn(ARGS_DIFF_BY_TOOL, toolName) +} + +export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefined { + if (message.codeDiff) return message.codeDiff + if (!message.toolName) return undefined + return Object.hasOwn(ARGS_DIFF_BY_TOOL, message.toolName) + ? ARGS_DIFF_BY_TOOL[message.toolName](message.parameters) + : undefined +} + +// Decided by tool and argument shape, never by the saved `codeDiff`: an overwrite's saved diff +// has a before side, and clearing a script leaves both argument sides empty, yet both calls sent +// a whole file. +export function argumentsCarryWholeFile(message: ToolDisplayMessage): boolean { + if (message.toolName === 'write_script') return true + if (message.toolName !== 'edit_code') return false + const params = message.parameters + if (typeof params === 'string') return /"code"\s*:/.test(params) + return Boolean(params && typeof params === 'object' && 'code' in params) +} + +export function diffLineCounts( + diff: ToolCodeDiff, + streaming = false +): { added: number; removed: number } { + if (streaming) { + return { added: lines(diff.after).length, removed: lines(diff.before).length } + } + + return toolDiffLineCounts(toolDiffLines(diff)) +} + +export function toolDiffLineCounts(lines: ToolDiffLine[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of lines) { + if (line.kind === 'added') added++ + else if (line.kind === 'removed') removed++ + } + return { added, removed } +} + +export type ToolDiffLine = { + kind: 'context' | 'added' | 'removed' + content: string + oldLine?: number + newLine?: number + changedRanges?: CharacterRange[] +} + +export type CharacterRange = { start: number; length: number; extendsToEnd?: boolean } + +const STREAMING_PREVIEW_LINES = 200 + +function lines(value: string): string[] { + const result = value.split('\n') + if (result.at(-1) === '') result.pop() + return result +} + +function hasFinalNewline(value: string): boolean { + return value.endsWith('\n') +} + +export function toolDiffLines(diff: ToolCodeDiff, streaming = false): ToolDiffLine[] { + const before = lines(diff.before) + const after = lines(diff.after) + if (streaming) return streamingDiffLines(before, after) + + const result: ToolDiffLine[] = [] + const monacoBefore = monacoLines(diff.before) + const monacoAfter = monacoLines(diff.after) + // Runs on the UI thread. Cost follows the amount changed, not file size: only a large + // rewrite reaches the cap, and Monaco then reports the whole file as one change. + const changes = new DefaultLinesDiffComputer().computeDiff(monacoBefore, monacoAfter, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 200, + computeMoves: false, + extendToSubwords: false + }).changes + const removedRanges = new Map() + const addedRanges = new Map() + for (const change of changes) { + for (const innerChange of change.innerChanges ?? []) { + addCharacterRanges(removedRanges, monacoBefore, innerChange.originalRange) + addCharacterRanges(addedRanges, monacoAfter, innerChange.modifiedRange) + } + } + + let oldIndex = 0 + let newIndex = 0 + for (const change of changes) { + const oldStart = change.original.startLineNumber - 1 + const newStart = change.modified.startLineNumber - 1 + appendContextLines( + result, + before, + after, + oldIndex, + newIndex, + Math.min(oldStart - oldIndex, newStart - newIndex) + ) + appendChangedLines( + result, + 'removed', + before, + oldStart, + change.original.endLineNumberExclusive - 1, + removedRanges + ) + appendChangedLines( + result, + 'added', + after, + newStart, + change.modified.endLineNumberExclusive - 1, + addedRanges + ) + oldIndex = Math.min(change.original.endLineNumberExclusive - 1, before.length) + newIndex = Math.min(change.modified.endLineNumberExclusive - 1, after.length) + } + const trailingContext = Math.max(0, Math.min(before.length - oldIndex, after.length - newIndex)) + appendContextLines(result, before, after, oldIndex, newIndex, trailingContext) + oldIndex += trailingContext + newIndex += trailingContext + appendChangedLines(result, 'removed', before, oldIndex, before.length, removedRanges) + appendChangedLines(result, 'added', after, newIndex, after.length, addedRanges) + if ( + diff.before !== '' && + diff.after !== '' && + hasFinalNewline(diff.before) !== hasFinalNewline(diff.after) + ) { + result.push( + hasFinalNewline(diff.before) + ? { kind: 'removed', content: '', oldLine: before.length + 1 } + : { kind: 'added', content: '', newLine: after.length + 1 } + ) + } + + return result +} + +export type VisibleToolDiffRow = + | ToolDiffLine + | { kind: 'collapsed'; key: string; count: number } + | { kind: 'omitted'; key: string; count: number } + +const CONTEXT_LINES = 3 +// Every visible row is a DOM row with no virtualization, and only context collapses: a whole-file +// rewrite would otherwise render every line of both sides. Two capped runs fit under the overall +// cap, so a rewrite still shows its added side. Each expansion of an omitted row reveals one more +// cap's worth, so no single click renders an unbounded number of rows. +const MAX_CHANGED_RUN_ROWS = 400 +const MAX_VISIBLE_ROWS = 1_000 + +// `expansions` counts the clicks on each collapsed or omitted row, by its key. +export function visibleToolDiffRows( + lines: ToolDiffLine[], + expansions: ReadonlyMap +): VisibleToolDiffRow[] { + const result: VisibleToolDiffRow[] = [] + for (let index = 0; index < lines.length; ) { + const start = index + const kind = lines[start].kind + while (index < lines.length && lines[index].kind === kind) index++ + const count = index - start + + if (kind !== 'context') { + const key = `${kind}:${start}` + const shown = MAX_CHANGED_RUN_ROWS * (1 + (expansions.get(key) ?? 0)) + result.push(...lines.slice(start, Math.min(index, start + shown))) + if (count > shown) result.push({ kind: 'omitted', key, count: count - shown }) + continue + } + + const key = `${lines[start].oldLine}:${lines[start].newLine}:${count}` + if (count <= CONTEXT_LINES * 2 + 1 || expansions.has(key)) { + result.push(...lines.slice(start, index)) + continue + } + result.push(...lines.slice(start, start + CONTEXT_LINES)) + result.push({ kind: 'collapsed', key, count: count - CONTEXT_LINES * 2 }) + result.push(...lines.slice(index - CONTEXT_LINES, index)) + } + + const maxRows = MAX_VISIBLE_ROWS * (1 + (expansions.get('end') ?? 0)) + if (result.length <= maxRows) return result + const hidden = result + .slice(maxRows) + .reduce((total, row) => total + ('count' in row ? row.count : 1), 0) + return [...result.slice(0, maxRows), { kind: 'omitted', key: 'end', count: hidden }] +} + +function streamingDiffLines(before: string[], after: string[]): ToolDiffLine[] { + return [ + ...before + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'removed' as const, content, oldLine: index + 1 })), + ...after + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'added' as const, content, newLine: index + 1 })) + ] +} + +function monacoLines(value: string): string[] { + const result = lines(value) + return result.length ? result : [''] +} + +function appendContextLines( + result: ToolDiffLine[], + before: string[], + after: string[], + oldStart: number, + newStart: number, + count: number +): void { + for (let index = 0; index < count; index++) { + result.push({ + kind: 'context', + content: before[oldStart + index], + oldLine: oldStart + index + 1, + newLine: newStart + index + 1 + }) + } +} + +function appendChangedLines( + result: ToolDiffLine[], + kind: 'added' | 'removed', + source: string[], + start: number, + end: number, + ranges: Map +): void { + for (let index = start; index < Math.min(end, source.length); index++) { + const changedRanges = ranges.get(index + 1) + result.push({ + kind, + content: source[index], + ...(kind === 'added' ? { newLine: index + 1 } : { oldLine: index + 1 }), + ...(changedRanges?.length ? { changedRanges } : {}) + }) + } +} + +function addCharacterRanges( + ranges: Map, + lines: string[], + range: { + startLineNumber: number + startColumn: number + endLineNumber: number + endColumn: number + } +): void { + for ( + let lineNumber = range.startLineNumber; + lineNumber <= Math.min(range.endLineNumber, lines.length); + lineNumber++ + ) { + const content = lines[lineNumber - 1] + const start = lineNumber === range.startLineNumber ? range.startColumn - 1 : 0 + const extendsToEnd = lineNumber < range.endLineNumber + const end = extendsToEnd ? content.length : range.endColumn - 1 + if (end > start) { + ranges.set(lineNumber, [ + ...(ranges.get(lineNumber) ?? []), + { start, length: end - start, ...(extendsToEnd ? { extendsToEnd } : {}) } + ]) + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts new file mode 100644 index 0000000000..b63d002e9e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts @@ -0,0 +1,57 @@ +import hljs from 'highlight.js/lib/core' +import { escapeHtml } from '$lib/utils' + +// highlight.js runs on the UI thread at roughly 15 ms per 1,000 lines (50 KB), and must see the +// whole source for multi-line strings and comments to scope correctly. Larger sides, including +// minified one-line sources, render as plain text with each line cut at MAX_PLAIN_LINE_CHARS. +const MAX_HIGHLIGHTED_LINES = 2_000 +const MAX_HIGHLIGHTED_CHARS = 100_000 +const MAX_PLAIN_LINE_CHARS = 10_000 + +// Highlights the whole source before splitting it, so a line inside a multi-line string or +// comment keeps that scope. Only the first `lineLimit` lines are highlighted: a streamed source +// grows on every delta while the preview shows a bounded prefix. +export function highlightedSourceLines( + code: string, + languageName: string, + lineLimit = Infinity +): string[] { + if (code === '' || lineLimit <= 0) return [] + const sourceLines = code.split('\n') + const truncated = sourceLines.length > lineLimit + const shownLines = truncated ? sourceLines.slice(0, lineLimit) : sourceLines + const source = truncated ? shownLines.join('\n') : code + if (shownLines.length > MAX_HIGHLIGHTED_LINES || source.length > MAX_HIGHLIGHTED_CHARS) { + const plain = shownLines.map((line) => + escapeHtml( + line.length > MAX_PLAIN_LINE_CHARS ? line.slice(0, MAX_PLAIN_LINE_CHARS) + '…' : line + ) + ) + if (!truncated && code.endsWith('\n')) plain.pop() + return plain + } + const highlighted = hljs.highlight(source, { language: languageName }).value + return splitHighlightedLines(highlighted, !truncated && code.endsWith('\n')) +} + +// Returns one entry per source line, closing the spans still open at each line end and +// reopening them on the next line so every entry is balanced HTML. +export function splitHighlightedLines(highlighted: string, hasFinalNewline: boolean): string[] { + const result: string[] = [] + const openTags: string[] = [] + let line = '' + + for (const token of highlighted.split(/(<[^>]+>|\n)/)) { + if (token === '\n') { + result.push(line + ''.repeat(openTags.length)) + line = openTags.join('') + } else { + line += token + if (token.startsWith('') openTags.pop() + } + } + + if (!hasFinalNewline) result.push(line) + return result +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts new file mode 100644 index 0000000000..ddf2464136 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts @@ -0,0 +1,43 @@ +import { csharp, go, java, json, plaintext, type LanguageType } from 'svelte-highlight/languages' +import graphql from 'svelte-highlight/languages/graphql' +import javascript from 'svelte-highlight/languages/javascript' +import php from 'svelte-highlight/languages/php' +import python from 'svelte-highlight/languages/python' +import powershell from 'svelte-highlight/languages/powershell' +import r from 'svelte-highlight/languages/r' +import ruby from 'svelte-highlight/languages/ruby' +import rust from 'svelte-highlight/languages/rust' +import shell from 'svelte-highlight/languages/shell' +import sql from 'svelte-highlight/languages/sql' +import typescript from 'svelte-highlight/languages/typescript' +import yaml from 'svelte-highlight/languages/yaml' + +// Keyed by the editor language `scriptLangToEditorLang` returns, not the script language. +// for related places search: ADD_NEW_LANG +const LANGUAGE_BY_MONACO: Record> = { + typescript, + javascript, + python, + json, + yaml, + sql, + shell, + powershell, + php, + rust, + graphql, + csharp, + nu: python, + java, + r, + go, + ruby, + text: plaintext, + plaintext +} + +export const TOOL_CODE_DIFF_LANGUAGES = Object.keys(LANGUAGE_BY_MONACO) + +export function toolCodeDiffLanguage(language: string): LanguageType { + return LANGUAGE_BY_MONACO[language] ?? plaintext +} diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index 5f1c657b39..8e06e0e2b9 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -263,6 +263,7 @@ describe('model context windows', () => { expect(getKnownModelContextWindow('gemini-3-flash')).toBe(1000000) expect(getKnownModelContextWindow('gemini-2.5-flash')).toBe(1000000) expect(getKnownModelContextWindow('deepseek-v4-pro')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-flash')).toBe(1000000) expect(getKnownModelContextWindow('deepseek-chat')).toBe(1000000) expect(getKnownModelContextWindow('deepseek-reasoner')).toBe(1000000) }) diff --git a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts index c665cc964e..6966e018d1 100644 --- a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts +++ b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts @@ -285,3 +285,65 @@ describe('parseOpenAICompletion tool call arguments', () => { expect(toolResult.content).toBe('tool ok') }) }) + +describe('output token limit', () => { + it('follows the model through every host that serves it', async () => { + const { getModelMaxTokens } = await import('./lib') + expect(getModelMaxTokens('deepseek', 'deepseek-flash')).toBe(131072) + expect(getModelMaxTokens('azure_foundry', 'DeepSeek-V4-Pro')).toBe(131072) + expect(getModelMaxTokens('openrouter', 'deepseek/deepseek-v4-pro')).toBe(131072) + // gpt-oss reasons whether or not the chat sends an effort. + expect(getModelMaxTokens('groq', 'openai/gpt-oss-120b')).toBe(16000) + expect(getModelMaxTokens('openrouter', 'openai/o3')).toBe(100000) + // A legacy `/thinking` selection is its base model, not the `thinking` row. + expect(getModelMaxTokens('anthropic', 'claude-sonnet-4-5/thinking')).toBe(64000) + }) + + it('gives a model outside the table room to think only when the request asks it to', async () => { + const { getModelMaxTokens } = await import('./lib') + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3', 'high')).toBe(32768) + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3', 'none')).toBe(8192) + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3')).toBe(8192) + // The registry does not know Custom AI models, so an effort says nothing about them. + expect(getModelMaxTokens('customai', 'local-model', 'high')).toBe(8192) + // A self-hosted open-weight model runs at its operator's context length, not the + // vendor's, while a Custom AI proxy in front of a hosted model keeps its row. + expect(getModelMaxTokens('customai', 'deepseek-v4-pro', 'high')).toBe(8192) + expect(getModelMaxTokens('customai', 'gpt-5')).toBe(128000) + }) + + it('sizes the budget from the effort the same request carries', async () => { + const { getCompletion } = await import('./lib') + const create = vi.fn() + + await getCompletion([], new AbortController(), undefined, { + forceModelProvider: { provider: 'mistral', model: 'mistral-medium-latest' }, + openaiClient: { chat: { completions: { create } } } as any, + reasoningEffort: 'high' + }) + + expect(create.mock.calls[0][0]).toMatchObject({ max_tokens: 32768, reasoning_effort: 'high' }) + }) + + it('fails a response cut off at the output token limit instead of ending the turn', async () => { + const { parseOpenAICompletion } = await import('./lib') + const { OutputTokenLimitError } = await import('./chat/outputTokenLimit') + + // Thinking counts toward max_tokens, so the cap can land before any answer. + const parsed = parseOpenAICompletion( + streamOf([ + { choices: [{ delta: { reasoning_content: 'I need to plan the flow so each' } }] }, + { choices: [{ delta: {}, finish_reason: 'length' }] } + ]), + createCallbacks(), + [], + [], + [], + {}, + undefined, + { workspace: 'test' } + ) + + await expect(parsed).rejects.toBeInstanceOf(OutputTokenLimitError) + }) +}) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 19f350a665..655a872f96 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -15,11 +15,17 @@ import { get, type Writable } from 'svelte/store' import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { + getKnownModelMaxOutputTokens, requiresMaxCompletionTokens, usesAnthropicMessagesApi, usesOpenRouterPromptCaching } from './modelConfig' -import { applyReasoningToConfig } from './reasoningRegistry' +import { + applyReasoningToConfig, + requestsReasoning, + stripLegacyThinkingSuffix, + type ReasoningEffort +} from './reasoningRegistry' import { formatResourceTypes } from './utils' import { appendPendingToolImages, @@ -28,6 +34,7 @@ import { type Tool, type ToolCallbacks } from './chat/shared' +import { OutputTokenLimitError } from './chat/outputTokenLimit' import { hasValidToolCallArguments } from './chat/toolCallArguments' import { getNonStreamingOpenAIResponsesCompletion, @@ -116,7 +123,7 @@ export const AI_PROVIDERS: Record = { }, deepseek: { label: 'DeepSeek', - defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'] + defaultModels: ['deepseek-v4-pro', 'deepseek-flash'] }, groq: { label: 'Groq', @@ -309,40 +316,31 @@ export async function fetchAvailableModels( return data?.data.map((m) => m.id) ?? [] } -export function getModelMaxTokens(provider: AIProvider, model: string) { - if (model.includes('gpt-5')) { - return 128000 - } else if ( - (provider === 'azure_openai' || provider === 'openai' || provider === 'azure_foundry') && - model.startsWith('o') - ) { - return 100000 - } else if ( - // Raising this further would also raise the worst case of the - // non-streaming completion path, which the Anthropic SDK refuses once - // the request could run past ~10 minutes. - model.includes('claude-sonnet') || - model.includes('claude-haiku') || - model.includes('claude-fable') || - model.includes('claude-mythos') || - // Opus only from 4.5 on. Opus 4.1 and older cap at 32K and fall through - // to the row below. Dots are normalized because OpenRouter writes - // `anthropic/claude-opus-4.5` where Anthropic writes `claude-opus-4-5`. - /claude-opus-(4-(5|6|7|8)|5)(?!\d)/.test(model.replace(/\./g, '-')) || - model.includes('gemini-2.5') || - model.includes('gemini-3') - ) { - return 64000 - } else if (model.includes('gpt-4.1')) { - return 32768 - } else if (model.includes('claude-opus')) { - return 32000 - } else if (model.includes('gpt-4o') || model.includes('codestral')) { - return 16384 - } else if (model.includes('gpt-4-turbo') || model.includes('gpt-3.5')) { - return 4096 - } - return 8192 +// Thinking counts toward max_tokens, so a model outside the table that is asked to +// reason gets more room than the plain fallback. A host rejects a budget above the +// model's cap, so the reasoning fallback must stay within the cap of every model it +// reaches (true of every reasoning model OpenRouter serves outside the table). Custom +// AI never gets it: the registry does not know its models, and the per-model override +// is its way to a larger budget. +const FALLBACK_MAX_TOKENS = 8192 +const REASONING_FALLBACK_MAX_TOKENS = 32768 + +/** + * The output budget a request sends when the workspace sets no override. `reasoningEffort` + * is the effort the request carries, as `resolveRequestReasoning` resolves it. + */ +export function getModelMaxTokens( + provider: AIProvider, + model: string, + reasoningEffort?: ReasoningEffort +): number { + const bareModel = stripLegacyThinkingSuffix(model) + return ( + getKnownModelMaxOutputTokens(provider, bareModel) ?? + (requestsReasoning(provider, bareModel, reasoningEffort) + ? REASONING_FALLBACK_MAX_TOKENS + : FALLBACK_MAX_TOKENS) + ) } // Resolves the completion token cap for a model: the workspace's per-model @@ -350,8 +348,16 @@ export function getModelMaxTokens(provider: AIProvider, model: string) { // Anthropic request paths so both honor the same limit. `cap` bounds the result // (used by short metadata completions, see METADATA_MAX_TOKENS) — a hard ceiling // that wins over both the workspace override and the default. -function resolveMaxTokens(modelProvider: AIProviderModel, cap?: number): number { - const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) +function resolveMaxTokens( + modelProvider: AIProviderModel, + cap?: number, + reasoningEffort?: ReasoningEffort +): number { + const defaultMaxTokens = getModelMaxTokens( + modelProvider.provider, + modelProvider.model, + reasoningEffort + ) const modelKey = `${modelProvider.provider}:${modelProvider.model}` let customMaxTokensStore: Record | undefined try { @@ -373,9 +379,10 @@ export const METADATA_MAX_TOKENS = 4096 function getModelSpecificConfig( modelProvider: AIProviderModel, tools?: OpenAI.Chat.Completions.ChatCompletionTool[], - maxTokensCap?: number + maxTokensCap?: number, + reasoningEffort?: ReasoningEffort ) { - const maxTokens = resolveMaxTokens(modelProvider, maxTokensCap) + const maxTokens = resolveMaxTokens(modelProvider, maxTokensCap, reasoningEffort) if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' || @@ -897,7 +904,8 @@ export function getProviderAndCompletionConfig({ tools, forceModelProvider, maxTokensCap, - promptCaching + promptCaching, + reasoningEffort }: { messages: ChatCompletionMessageParam[] stream: K @@ -907,6 +915,9 @@ export function getProviderAndCompletionConfig({ // Opt-in: a cache write costs more than an uncached read, so it only pays off where // the same prefix is sent again. True for the chat loop, false for one-shot calls. promptCaching?: boolean + // The effort the caller will add with `applyReasoningToConfig`. Only sizes the + // output budget here: a request that reasons needs room for the thinking. + reasoningEffort?: ReasoningEffort }): { provider: AIProvider config: K extends true @@ -923,7 +934,7 @@ export function getProviderAndCompletionConfig({ provider: modelProvider.provider, config: { ...providerConfig, - ...getModelSpecificConfig(modelProvider, tools, maxTokensCap), + ...getModelSpecificConfig(modelProvider, tools, maxTokensCap, reasoningEffort), messages: processedMessages, stream } as any @@ -1129,7 +1140,8 @@ export async function getCompletion( stream: true, tools, forceModelProvider: options?.forceModelProvider, - promptCaching: options?.promptCaching + promptCaching: options?.promptCaching, + reasoningEffort: options?.reasoningEffort }) // Use Responses API for OpenAI and Azure OpenAI @@ -1218,6 +1230,7 @@ export async function parseOpenAICompletion( // to the next call, the previous one is demoted to queued. let streamingToolCallId: string | undefined = undefined let malformedFunctionCallError = false + let hitOutputTokenLimit = false let tokenUsage = emptyChatTokenUsage() let answer = '' @@ -1244,6 +1257,9 @@ export async function parseOpenAICompletion( ) { malformedFunctionCallError = true } + if (finishReason === 'length') { + hitOutputTokenLimit = true + } // Mistral nests reasoning inside structured content parts; split them out // so a content delta never leaks "[object Object]" into the answer. @@ -1417,7 +1433,8 @@ export async function parseOpenAICompletion( toolCall, helpers, toolCallbacks: callbacks, - workspace: options?.workspace + workspace: options?.workspace, + messages }) messages.push(messageToAdd) addedMessages.push(messageToAdd) @@ -1460,6 +1477,8 @@ export async function parseOpenAICompletion( } messages.push(toolResponse) addedMessages.push(toolResponse) + } else if (hitOutputTokenLimit) { + throw new OutputTokenLimitError() } else { return { shouldContinue: false, tokenUsage } } diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index e5a66248b3..77d0cf942a 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -96,10 +96,12 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['gemini-3.1', 1_000_000], ['gemini-3', 1_000_000], ['gemini-2.5', 1_000_000], - // DeepSeek — the V4 family (pro / flash) is 1M. The deepseek-chat / - // deepseek-reasoner aliases were retired 2026-07-24 but can still sit in a - // saved selection, so they keep resolving to the window they had. + // DeepSeek — the V4 family (pro / flash) is 1M; V4.1 Flash is served as + // `deepseek-flash`. The deepseek-chat / deepseek-reasoner aliases were retired + // 2026-07-24 but can still sit in a saved selection, so they keep resolving to + // the window they had. ['deepseek-v4', 1_000_000], + ['deepseek-flash', 1_000_000], ['deepseek-chat', 1_000_000], ['deepseek-reasoner', 1_000_000], ['deepseek', 128_000], @@ -115,6 +117,58 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['codestral', 32_000] ] +// Output token budgets, matched like the context windows above so one row covers +// every host of a model: native API, Azure AI Foundry, OpenRouter, Together, Bedrock. +// A host rejects any request above its own limit, so each value is one every host of +// the model accepts, not the vendor's maximum. Models not listed fall back in +// `getModelMaxTokens`. +const MODEL_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ + // Anthropic — the Anthropic SDK refuses a non-streaming request whose max_tokens + // implies more than ~10 minutes, so these stay below the models' own maximum. + // Opus caps at 32K before 4.5. + ['claude-opus-4.5', 64_000], + ['claude-opus-4.6', 64_000], + ['claude-opus-4.7', 64_000], + ['claude-opus-4.8', 64_000], + ['claude-opus-5', 64_000], + ['claude-opus', 32_000], + ['claude-sonnet', 64_000], + ['claude-haiku', 64_000], + ['claude-fable', 64_000], + ['claude-mythos', 64_000], + // OpenAI + ['gpt-5', 128_000], + ['gpt-4.1', 32_768], + ['gpt-4o', 16_384], + ['gpt-4-turbo', 4_096], + ['gpt-3.5', 4_096], + ['o1', 100_000], + ['o3', 100_000], + ['o4-mini', 100_000], + // Google + ['gemini-3', 64_000], + ['gemini-2.5', 64_000] +] + +// Open-weight models, which a self-hosted server (Custom AI) caps at whatever context +// length its operator set, often below these: vLLM rejects a request whose prompt plus +// max_tokens exceeds it. So these rows only apply to the providers that host them. +const OPEN_WEIGHT_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ + // gpt-oss reasons on every request. Bedrock documents 16K, where Groq takes 65536 + ['gpt-oss', 16_000], + // DeepSeek — thinks by default and counts the thinking toward max_tokens. 131072 + // is DeepSeek's own default at the highest effort. R1 is capped by its OpenRouter + // host. + ['deepseek-v4', 131_072], + ['deepseek-flash', 131_072], + ['deepseek-r1', 16_000], + // Mistral + ['codestral', 16_384], + // Models named for thinking reason on every request, whether or not the chat sends + // an effort. Every one OpenRouter lists takes at least this much. + ['thinking', 32_768] +] + // Version separators differ by route to the same model: Anthropic writes // `claude-opus-4-8`, OpenRouter writes `anthropic/claude-opus-4.8`. Collapsing // dots to dashes on both sides keeps one table entry covering every route — @@ -195,6 +249,19 @@ export function getKnownModelContextWindow(model: string): number | undefined { return matchModel(MODEL_CONTEXT_WINDOW_MATCHERS, model) } +const MODEL_MAX_OUTPUT_TOKEN_MATCHERS = buildModelMatchers(MODEL_MAX_OUTPUT_TOKENS) +const OPEN_WEIGHT_MAX_OUTPUT_TOKEN_MATCHERS = buildModelMatchers(OPEN_WEIGHT_MAX_OUTPUT_TOKENS) + +export function getKnownModelMaxOutputTokens( + provider: AIProvider, + model: string +): number | undefined { + return ( + matchModel(MODEL_MAX_OUTPUT_TOKEN_MATCHERS, model) ?? + (provider === 'customai' ? undefined : matchModel(OPEN_WEIGHT_MAX_OUTPUT_TOKEN_MATCHERS, model)) + ) +} + /** Trim/compaction logic needs a number; assume a conservative window when unknown. */ export const ASSUMED_CONTEXT_WINDOW = 128000 diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 242f6cb329..9915e9c42f 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -374,7 +374,8 @@ const ANTHROPIC_ALWAYS_THINKING = /fable|mythos/ /** * Disable token to forward when the user explicitly turns reasoning off on a * model that reasons *by default* — omitting the field would silently keep - * the default-on behavior. Undefined means omission is the correct off. + * the default-on behavior. Undefined means omission is the correct off. Every + * token is `'none'`: `requestsReasoning` reads that value as off. */ export function explicitOffToken(provider: AIProvider, model: string): ReasoningEffort | undefined { switch (reasoningProviderFamily(provider, model)) { @@ -434,6 +435,20 @@ export function resolveRequestReasoning( return undefined } +/** + * Whether a request carrying `effort`, as `resolveRequestReasoning` resolves it, + * has the model reason. An effort sent to a model the registry does not mark as + * reasoning-capable answers no: an explicit choice is always sent, but that does + * not make the model think. + */ +export function requestsReasoning( + provider: AIProvider, + model: string, + effort: ReasoningEffort | undefined +): boolean { + return !!effort && effort !== 'none' && supportsReasoning(provider, model) +} + export type ReasoningApiKind = 'anthropic' | 'responses' | 'completions' | 'deepseek' | 'mistral' /** diff --git a/frontend/src/lib/components/dbOps.ts b/frontend/src/lib/components/dbOps.ts index e239a9e957..dbe507d13f 100644 --- a/frontend/src/lib/components/dbOps.ts +++ b/frontend/src/lib/components/dbOps.ts @@ -24,6 +24,7 @@ import { transformSnowflakeForeignKeys, type RawForeignKey } from './apps/components/display/dbtable/queries/relationalKeys' +import { groupForeignKeyRows, type DbRelation, type RawAllForeignKeyRow } from './dbRelations' export type IDbTableOps = { dbType: DbType @@ -259,6 +260,10 @@ export type IDbSchemaOps = { table: string schema?: string }) => Promise + /** Every foreign key of the database in one query, for the schema diagram. + * PostgreSQL only — the other databases either don't expose foreign keys or + * can only be asked one table at a time. */ + onFetchAllForeignKeys: () => Promise } /** Thrown by a schema op when the user declines the out-of-order run warning. @@ -454,6 +459,16 @@ export function dbSchemaOpsWithPreviewScripts({ return [] } + async function fetchAllForeignKeys(): Promise { + if (dbType !== 'postgresql') return [] + const content = makeMarker('ALL_FOREIGN_KEYS', {}) + const rows = (await runScriptAndPollResult({ + workspace, + requestBody: { args: dbArg(), content, language, tag } + })) as RawAllForeignKeyRow[] + return Array.isArray(rows) ? groupForeignKeyRows(rows) : [] + } + return { onDelete: async ({ tableKey, schema }) => { const content = makeMarker('DROP_TABLE', { table: tableKey, schema }) @@ -518,6 +533,7 @@ export function dbSchemaOpsWithPreviewScripts({ await applyDdl(migrationName('rename_schema', schema), content, downContent) }, onFetchForeignKeys: fetchForeignKeys, + onFetchAllForeignKeys: fetchAllForeignKeys, onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => { const foreignKeys = await fetchForeignKeys({ table, schema }) let pk_constraint_name: string | undefined diff --git a/frontend/src/lib/components/dbRelations.test.ts b/frontend/src/lib/components/dbRelations.test.ts new file mode 100644 index 0000000000..ad5d63a43e --- /dev/null +++ b/frontend/src/lib/components/dbRelations.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { buildRelationIndex, groupForeignKeyRows, type RawAllForeignKeyRow } from './dbRelations' + +function row(over: Partial): RawAllForeignKeyRow { + return { + fk_constraint_name: 'fk', + source_schema: 'shop', + source_table: 'orders', + source_column: 'customer_id', + target_schema: 'shop', + target_table: 'customers', + target_column: 'id', + ordinal: 1, + ...over + } +} + +describe('groupForeignKeyRows', () => { + it('pairs a composite key by ordinal, whatever order the rows arrive in', () => { + const relations = groupForeignKeyRows([ + row({ + fk_constraint_name: 'returns_order_item_fkey', + source_table: 'returns', + source_column: 'sku', + target_table: 'order_items', + target_column: 'sku', + ordinal: 2 + }), + row({ + fk_constraint_name: 'returns_order_item_fkey', + source_table: 'returns', + source_column: 'order_id', + target_table: 'order_items', + target_column: 'order_id', + ordinal: 1 + }) + ]) + + expect(relations).toHaveLength(1) + expect(relations[0].from.columns).toEqual(['order_id', 'sku']) + expect(relations[0].to.columns).toEqual(['order_id', 'sku']) + }) + + it('keeps same-named constraints on different tables apart', () => { + const relations = groupForeignKeyRows([ + row({ fk_constraint_name: 'owner_fkey', source_table: 'orders' }), + row({ fk_constraint_name: 'owner_fkey', source_table: 'addresses' }) + ]) + expect(relations.map((r) => r.from.table).sort()).toEqual(['addresses', 'orders']) + }) +}) + +describe('buildRelationIndex', () => { + const index = buildRelationIndex( + groupForeignKeyRows([ + row({ source_table: 'orders', source_column: 'customer_id', target_column: 'id' }), + row({ + fk_constraint_name: 'items_order_fkey', + source_table: 'order_items', + source_column: 'order_id', + target_table: 'orders', + target_column: 'id' + }) + ]) + ) + + it('links a column only to the column it is actually paired with', () => { + // `order_items.order_id` points at `orders.id`, not at the same-named + // column of every other table taking part in a relation. + expect([...(index.linkedColumns.get('shop.order_items.order_id') ?? [])]).toEqual([ + 'shop.orders.id' + ]) + }) + + it('relates tables in both directions', () => { + expect([...(index.relatedTables.get('shop.orders') ?? [])].sort()).toEqual([ + 'shop.customers', + 'shop.order_items' + ]) + }) +}) diff --git a/frontend/src/lib/components/dbRelations.ts b/frontend/src/lib/components/dbRelations.ts new file mode 100644 index 0000000000..eb6451057e --- /dev/null +++ b/frontend/src/lib/components/dbRelations.ts @@ -0,0 +1,137 @@ +/** One row of the `ALL_FOREIGN_KEYS` marker: a single referencing column. */ +export type RawAllForeignKeyRow = { + fk_constraint_name: string + source_schema: string + source_table: string + source_column: string + target_schema: string + target_table: string + target_column: string + ordinal: number | string +} + +export type DbTableRef = { schema: string; table: string } + +/** A foreign key, with its columns paired to the ones they point at by index. */ +export type DbRelation = { + constraint: string + from: DbTableRef & { columns: string[] } + to: DbTableRef & { columns: string[] } +} + +export function tableKeyOf(ref: DbTableRef): string { + return `${ref.schema}.${ref.table}` +} + +export function columnKeyOf(ref: DbTableRef, column: string): string { + return columnKeyIn(tableKeyOf(ref), column) +} + +export function columnKeyIn(tableKey: string, column: string): string { + return `${tableKey}.${column}` +} + +/** Groups the flat rows into one relation per constraint, keeping the column + * pairs in the constraint's declaration order (the query's `ordinal`). */ +export function groupForeignKeyRows(rows: RawAllForeignKeyRow[]): DbRelation[] { + const byConstraint = new Map() + + for (const raw of rows) { + const row = lowercaseKeys(raw) + if (!row.source_table || !row.target_table) continue + const key = `${row.source_schema}.${row.source_table}.${row.fk_constraint_name}` + let entry = byConstraint.get(key) + if (!entry) { + entry = { + relation: { + constraint: row.fk_constraint_name, + from: { schema: row.source_schema, table: row.source_table, columns: [] }, + to: { schema: row.target_schema, table: row.target_table, columns: [] } + }, + pairs: [] + } + byConstraint.set(key, entry) + } + entry.pairs.push(row) + } + + return Array.from(byConstraint.values()).map(({ relation, pairs }) => { + pairs.sort((a, b) => Number(a.ordinal) - Number(b.ordinal)) + relation.from.columns = pairs.map((p) => p.source_column) + relation.to.columns = pairs.map((p) => p.target_column) + return relation + }) +} + +function lowercaseKeys(row: RawAllForeignKeyRow): RawAllForeignKeyRow { + const out: any = {} + for (const key of Object.keys(row)) out[key.toLowerCase()] = (row as any)[key] + return out as RawAllForeignKeyRow +} + +/** Everything the diagram needs to answer "what lights up when this is hovered". + * Built once per fetched relation set, then read on every hover. */ +export type RelationIndex = { + /** Tables reachable from a table by one foreign key, either direction. */ + relatedTables: Map> + /** Columns a column is paired with by a foreign key, either direction. */ + linkedColumns: Map> + /** Every column taking part in a relation of a table, keyed by table. Both + * the table's own referencing/referenced columns and the far ends. */ + relatedColumns: Map> + /** Columns that reference another table — rendered with a link marker. */ + referencingColumns: Set + /** Columns another table points at. */ + referencedColumns: Set + /** The table a column key belongs to. Both parts are dot-joined names, so + * splitting a column key back apart is not safe. */ + columnTable: Map +} + +export function buildRelationIndex(relations: DbRelation[]): RelationIndex { + const index: RelationIndex = { + relatedTables: new Map(), + linkedColumns: new Map(), + relatedColumns: new Map(), + referencingColumns: new Set(), + referencedColumns: new Set(), + columnTable: new Map() + } + + for (const relation of relations) { + const fromTable = tableKeyOf(relation.from) + const toTable = tableKeyOf(relation.to) + + add(index.relatedTables, fromTable, toTable) + add(index.relatedTables, toTable, fromTable) + + const pairCount = Math.min(relation.from.columns.length, relation.to.columns.length) + for (let i = 0; i < pairCount; i++) { + const fromColumn = columnKeyOf(relation.from, relation.from.columns[i]) + const toColumn = columnKeyOf(relation.to, relation.to.columns[i]) + + index.referencingColumns.add(fromColumn) + index.referencedColumns.add(toColumn) + index.columnTable.set(fromColumn, fromTable) + index.columnTable.set(toColumn, toTable) + + add(index.linkedColumns, fromColumn, toColumn) + add(index.linkedColumns, toColumn, fromColumn) + + // Both ends of the pair under both tables: hovering either table has to + // light its own column and the one it is paired with. + for (const table of [fromTable, toTable]) { + add(index.relatedColumns, table, fromColumn) + add(index.relatedColumns, table, toColumn) + } + } + } + + return index +} + +function add(map: Map>, key: string, value: string) { + let set = map.get(key) + if (!set) map.set(key, (set = new Set())) + set.add(value) +} diff --git a/frontend/src/lib/components/dbdiagram/DbSchemaDiagram.svelte b/frontend/src/lib/components/dbdiagram/DbSchemaDiagram.svelte new file mode 100644 index 0000000000..d9b2752374 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DbSchemaDiagram.svelte @@ -0,0 +1,171 @@ + + +
    + {#if error} +
    + {error} +
    + {/if} + {#if tables.length === 0} +
    + Check tables in the sidebar to draw them here +
    + {:else} + + highlight.clear()} + > +
    + + + + +
    +
    + {/if} + {#if loading} +
    + + Loading relationships +
    + {/if} +
    + + diff --git a/frontend/src/lib/components/dbdiagram/DbTableNode.svelte b/frontend/src/lib/components/dbdiagram/DbTableNode.svelte new file mode 100644 index 0000000000..d17faaa006 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DbTableNode.svelte @@ -0,0 +1,118 @@ + + + +
    highlight.hover({ table: table.key })} + onmouseleave={() => highlight.hover(undefined)} + onclick={() => highlight.togglePin({ table: table.key })} + role="button" + tabindex="-1" +> + + +
    + + {table.table} + {table.schema} +
    + + {#each columns as column (column.name)} + {@const columnKey = columnKeyIn(table.key, column.name)} + {@const isLit = highlight.columns?.has(columnKey)} + +
    highlight.hover({ table: table.key, column: column.name })} + onmouseleave={() => highlight.hover({ table: table.key })} + onclick={(e) => { + e.stopPropagation() + highlight.togglePin({ table: table.key, column: column.name }) + }} + > + {#if column.isPrimaryKey} + + {:else if data.index.referencingColumns.has(columnKey)} + + {:else} + + {/if} + {column.name} + {column.datatype} +
    + {/each} + + {#if hasExpandToggle(table)} +
    + +
    + {/if} +
    diff --git a/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte b/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte new file mode 100644 index 0000000000..b271886abd --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte @@ -0,0 +1,20 @@ + diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts b/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts new file mode 100644 index 0000000000..54f52b85ca --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts @@ -0,0 +1,80 @@ +import { getContext, setContext } from 'svelte' +import { columnKeyIn, type RelationIndex } from '../dbRelations' + +/** What the pointer is on: a whole table, or one of its columns. */ +export type HighlightTarget = { table: string; column?: string } + +const EMPTY: ReadonlySet = new Set() + +/** + * The diagram draws no edges, so the relationships only exist while something is + * highlighted. This owns that: what is hovered (or pinned by a click), and which + * tables and columns it lights up. + */ +export class DbDiagramHighlight { + #getIndex: () => RelationIndex + + hovered = $state(undefined) + /** Set by clicking, so the relations can be read without holding the pointer + * still. A hover takes precedence while it lasts. */ + pinned = $state(undefined) + + target = $derived(this.hovered ?? this.pinned) + + /** Tables that stay lit. Undefined means nothing is highlighted and every + * table renders at full strength. */ + tables = $derived.by | undefined>(() => { + const target = this.target + if (!target) return undefined + if (target.column) { + const columnTable = this.#getIndex().columnTable + const tables = new Set([target.table]) + for (const column of this.columns ?? EMPTY) { + const table = columnTable.get(column) + if (table) tables.add(table) + } + return tables + } + return new Set([target.table, ...(this.#getIndex().relatedTables.get(target.table) ?? EMPTY)]) + }) + + /** Columns that stay lit inside the highlighted tables. */ + columns = $derived.by | undefined>(() => { + const target = this.target + if (!target) return undefined + const index = this.#getIndex() + if (target.column) { + const key = columnKeyIn(target.table, target.column) + return new Set([key, ...(index.linkedColumns.get(key) ?? EMPTY)]) + } + return index.relatedColumns.get(target.table) ?? EMPTY + }) + + constructor(getIndex: () => RelationIndex) { + this.#getIndex = getIndex + } + + hover(target: HighlightTarget | undefined) { + this.hovered = target + } + + /** Clicking the already-pinned target unpins it. */ + togglePin(target: HighlightTarget) { + const current = this.pinned + this.pinned = + current && current.table === target.table && current.column === target.column + ? undefined + : target + } + + clear() { + this.hovered = undefined + this.pinned = undefined + } +} + +const contextKey = 'DbDiagramHighlight' + +export const setDbDiagramHighlight = (highlight: DbDiagramHighlight) => + setContext(contextKey, highlight) +export const getDbDiagramHighlight = () => getContext(contextKey) diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts b/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts new file mode 100644 index 0000000000..8b6b584860 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { CARD_WIDTH, cardHeight, layoutTables, type DiagramTable } from './dbDiagramModel' + +function table(name: string, columnCount: number): DiagramTable { + return { + key: `shop.${name}`, + schema: 'shop', + table: name, + columns: Array.from({ length: columnCount }, (_, i) => ({ + name: `c${i}`, + datatype: 'text', + isPrimaryKey: i === 0 + })) + } +} + +describe('layoutTables', () => { + // Runs again on every checkbox click, so cards must not land on top of each + // other for any mix of card heights. + it('never overlaps two cards', () => { + const tables = Array.from({ length: 40 }, (_, i) => table(`t${i}`, 1 + (i % 17))) + const expanded = new Set(['shop.t3', 'shop.t20']) + const positions = layoutTables(tables, expanded) + + const boxes = tables.map((t) => ({ + x: positions[t.key].x, + y: positions[t.key].y, + h: cardHeight(t, expanded.has(t.key)) + })) + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i] + const b = boxes[j] + const apart = + a.x + CARD_WIDTH <= b.x || b.x + CARD_WIDTH <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y + expect(apart, `${tables[i].key} overlaps ${tables[j].key}`).toBe(true) + } + } + }) + + it('places the same tables in the same spots every time', () => { + const tables = Array.from({ length: 12 }, (_, i) => table(`t${i}`, 3 + i)) + expect(layoutTables(tables, new Set())).toEqual(layoutTables(tables, new Set())) + }) +}) diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts b/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts new file mode 100644 index 0000000000..ccd32cdb22 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts @@ -0,0 +1,131 @@ +import type { DBSchema } from '$lib/stores' +import type { ColumnDef } from '../apps/components/display/dbtable/utils' +import type { SelectedTable } from '../DBManager.svelte' +import { tableKeyOf } from '../dbRelations' + +export type DiagramColumn = { + name: string + datatype: string + isPrimaryKey: boolean +} + +export type DiagramTable = { + /** `schema.table`, the key relations and highlights are addressed by. */ + key: string + schema: string + table: string + columns: DiagramColumn[] +} + +/** Card geometry. The node component must render exactly the rows the layout + * budgeted for, or cards overlap. */ +export const CARD_WIDTH = 248 +export const HEADER_HEIGHT = 34 +export const ROW_HEIGHT = 22 +const CARD_PADDING = 6 +const GAP_X = 32 +const GAP_Y = 20 +/** Rows shown before a card collapses into a "+N more" row. */ +export const COLLAPSED_ROW_LIMIT = 12 +/** Cards are packed towards this width/height ratio, so the canvas opens on + * something screen-shaped rather than one very long column. */ +const TARGET_ASPECT = 1.7 + +export function visibleColumns(table: DiagramTable, expanded: boolean): DiagramColumn[] { + return expanded ? table.columns : table.columns.slice(0, COLLAPSED_ROW_LIMIT) +} + +export function hiddenColumnCount(table: DiagramTable, expanded: boolean): number { + return table.columns.length - visibleColumns(table, expanded).length +} + +/** Whether the card carries a row to expand or collapse its column list. */ +export function hasExpandToggle(table: DiagramTable): boolean { + return table.columns.length > COLLAPSED_ROW_LIMIT +} + +export function cardHeight(table: DiagramTable, expanded: boolean): number { + const rows = visibleColumns(table, expanded).length + (hasExpandToggle(table) ? 1 : 0) + return HEADER_HEIGHT + rows * ROW_HEIGHT + CARD_PADDING +} + +/** The tables to draw, in the order they are laid out. */ +export function buildDiagramTables( + dbSchema: DBSchema, + colDefs: Record | undefined, + selected: SelectedTable[] +): DiagramTable[] { + if (dbSchema.lang === 'graphql') return [] + const tables: DiagramTable[] = [] + + for (const { schema, table } of selected) { + const key = tableKeyOf({ schema, table }) + const fromColDefs = colDefs?.[key] + let columns: DiagramColumn[] + if (fromColDefs?.length) { + columns = fromColDefs.map((col) => ({ + name: col.field ?? '', + datatype: col.datatype ?? '', + isPrimaryKey: !!col.isprimarykey + })) + } else { + // The cached schema has no primary-key flag; it only stands in until the + // column metadata query lands. + const schemaColumns = dbSchema.schema[schema]?.[table] + if (!schemaColumns) continue + columns = Object.entries(schemaColumns).map(([name, col]) => ({ + name, + datatype: col.type, + isPrimaryKey: false + })) + } + tables.push({ key, schema, table, columns }) + } + + tables.sort((a, b) => a.schema.localeCompare(b.schema) || a.table.localeCompare(b.table)) + return tables +} + +/** Packs the cards into balanced columns, in reading order. Runs again on every + * change to the selection, so it must be deterministic: the same tables always + * land in the same place. */ +export function layoutTables( + tables: DiagramTable[], + expanded: Set +): Record { + const heights = tables.map((t) => cardHeight(t, expanded.has(t.key))) + const stacked = heights.reduce((a, b) => a + b, 0) + GAP_Y * Math.max(0, tables.length - 1) + + let columnCount = 1 + let bestScore = Infinity + for (let c = 1; c <= tables.length; c++) { + const width = c * CARD_WIDTH + (c - 1) * GAP_X + const height = stacked / c + // Compared in log space so twice-too-wide and twice-too-tall score alike. + const score = Math.abs(Math.log(width / height / TARGET_ASPECT)) + if (score < bestScore) { + bestScore = score + columnCount = c + } + } + + const target = stacked / columnCount + const positions: Record = {} + let column = 0 + let columnHeight = 0 + + tables.forEach((table, i) => { + const height = heights[i] + // Overshooting the target by more than stopping short of it costs less in + // the next column. + const overshoot = columnHeight + height - target + if (column < columnCount - 1 && columnHeight > 0 && overshoot > target - columnHeight) { + column++ + columnHeight = 0 + } + positions[table.key] = { x: column * (CARD_WIDTH + GAP_X), y: columnHeight } + columnHeight += height + GAP_Y + }) + + return positions +} diff --git a/frontend/src/lib/components/flows/FlowProgressBar.svelte b/frontend/src/lib/components/flows/FlowProgressBar.svelte index d31c1cae97..5edf55b5de 100644 --- a/frontend/src/lib/components/flows/FlowProgressBar.svelte +++ b/frontend/src/lib/components/flows/FlowProgressBar.svelte @@ -2,6 +2,7 @@ import { type Job } from '$lib/gen' import ProgressBar from '../progressBar/ProgressBar.svelte' import { forLater } from '$lib/forLater' + import type { Snippet } from 'svelte' interface Props { job?: Job | undefined @@ -11,6 +12,7 @@ textPosition?: 'top' | 'bottom' // Prefer step ID over step index when both are available showStepId?: boolean + errorAction?: Snippet } let { @@ -19,7 +21,8 @@ class: className, slim = false, textPosition = 'top', - showStepId = false + showStepId = false, + errorAction }: Props = $props() let error: number | undefined = $state(undefined) @@ -183,4 +186,5 @@ {isCanceled} {isScheduled} {isSkipped} + {errorAction} /> diff --git a/frontend/src/lib/components/flows/agentFormFields.test.ts b/frontend/src/lib/components/flows/agentFormFields.test.ts index 311b48ade1..48b4c94d92 100644 --- a/frontend/src/lib/components/flows/agentFormFields.test.ts +++ b/frontend/src/lib/components/flows/agentFormFields.test.ts @@ -6,6 +6,7 @@ import { agentMemoryMode, historyInputApplies, agentFieldIsSet, + agentTestInputTransforms, initialVisibleAgentFields } from './agentFormFields' @@ -147,3 +148,23 @@ describe('memoryPropertyFor', () => { ).toBeUndefined() }) }) + +describe('agentTestInputTransforms', () => { + it('unsets a blank history input and feeds every other form key from flow_input', () => { + const authored = { + memory_id: { type: 'static' as const, value: 'thread-1' }, + previous_messages: { type: 'static' as const, value: [{ role: 'user', content: 'hi' }] }, + system_prompt: { type: 'javascript' as const, expr: 'results.a.prompt' } + } + expect( + agentTestInputTransforms(authored, { memory_id: '', previous_messages: [{ role: 'user' }] }, [ + 'memory_id', + 'previous_messages', + 'system_prompt' + ]) + ).toEqual({ + previous_messages: { type: 'javascript', expr: 'flow_input.previous_messages' }, + system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' } + }) + }) +}) diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index 3d62497ba3..7f09afdf7e 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -30,6 +30,28 @@ export const AGENT_TOOLS_ROW = 'tools' export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number] +/** The input transforms a one-off test of an agent step runs with: each form key is fed from + * `flow_input`, over the step's own transforms. A blank history input is unset, as on the step: + * an expression evaluating to nothing reads as an empty memory id, and the step's transform is + * stale. */ +export function agentTestInputTransforms( + stepTransforms: Record | undefined, + args: Record, + formKeys: readonly string[] = Object.keys(args) +): Record { + const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length) + const kept = (key: string) => + !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key]) + return { + ...Object.fromEntries(Object.entries(stepTransforms ?? {}).filter(([key]) => kept(key))), + ...Object.fromEntries( + formKeys + .filter(kept) + .map((key) => [key, { type: 'javascript' as const, expr: `flow_input.${key}` }]) + ) + } +} + /** What turning managed memory on writes. */ export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 } diff --git a/frontend/src/lib/components/flows/agentToolUtils.ts b/frontend/src/lib/components/flows/agentToolUtils.ts index e9838f60e3..87c15bc66a 100644 --- a/frontend/src/lib/components/flows/agentToolUtils.ts +++ b/frontend/src/lib/components/flows/agentToolUtils.ts @@ -126,6 +126,11 @@ export function toolEnabledName(tool: AgentTool): string | undefined { return toolDisplayName(tool) } +/** The names `enabled_tools` can pick from: every tool in the roster that has one. */ +export function enabledToolNames(tools: AgentTool[]): string[] { + return tools.map((tool) => toolEnabledName(tool)).filter((name): name is string => !!name) +} + /** * Create an AI Agent tool (nested agent) */ diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index b6a98160be..4f69d3a1a1 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -1,7 +1,16 @@ {#if draft.refusal} @@ -359,7 +434,7 @@ - +
    - - - - -
    - + +
    + +
    + + +
    + +
    +
    + + + +
    +
    + {#if chatMounted} +
    + {#if chatGap?.memory} +
    + +

    Chat needs managed memory

    +

    + {chatGap.memoryCanTurnOn + ? 'Without it, every message would be answered without the ones before it.' + : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} +

    + {#if chatGap.memoryCanTurnOn && !readOnly} + + {/if} +
    + {:else if chatGap?.noStream} +
    + + + {chatGap.noStream === 'image' + ? 'Image answers do not stream: each one shows once its run ends.' + : 'Streaming is off: each answer shows once its run ends.'} + + {#if chatGap.noStream === 'off' && !readOnly} + + {/if} +
    + {/if} + +
    + +
    - - - - - + {/if} +
    diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index a6e113f39b..ea45761bf9 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -1,5 +1,7 @@ + +
    + {#if isPreviewLoading} +
    + + Listing the changes to push... +
    + {:else} +

    + Here is what will get pushed to the repository, according to the filters you set. +

    + {/if} + + {#if previewError} + +
    + {previewError} + +
    +
    + {/if} + + {#if previewResult && !previewError} +
    +

    Changes to push

    + + {#if previewResult.changes?.length > 0} + + {:else} +
    + The repository already matches this workspace: there is nothing to push. +
    + {/if} +
    + {/if} + + {#if applyError} + + {applyError} + + {/if} + +
    + {#each [{ id: previewJobId, status: previewJobStatus, label: 'Changes job', running: isPreviewLoading }, { id: applyJobId, status: applyJobStatus, label: 'Push job', running: isApplying }] as job (job.label)} + + {#if job.running || (job.id && job.status !== 'success')} +
    + {#if job.status === 'running'} + + {:else if job.status === 'failure'} + + {/if} + {#if job.id} + {job.label}: + + {job.id} + + {/if} +
    + {/if} + {/each} + + +
    +
    + +
    + + {#if showCliInstructions} +
    + +
    {cliInstructions}
    +
    + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 075c6b1f24..97b79b7c1b 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -1,5 +1,5 @@ import { getContext, setContext } from 'svelte' -import { enterpriseLicense, userStore } from '$lib/stores' +import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { get } from 'svelte/store' import { sendUserToast } from '$lib/toast' import { apiErrorMessage } from '$lib/utils' @@ -46,7 +46,15 @@ export type GitSyncSettings = { export type ModalState = { push: { idx: number; repo: GitSyncRepository; open: boolean } | null pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null - success: { open: boolean; savedWithoutInit?: boolean; autoPullOn?: boolean } | null + success: { + open: boolean + savedWithoutInit?: boolean + autoPullOn?: boolean + /** Why pulling fell back to polling, when it did. */ + webhookError?: string + /** Whether this workspace is the one that would turn pulling on for this repository. */ + ownsAutoPull?: boolean + } | null } export type ValidationState = { @@ -202,6 +210,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] @@ -290,8 +303,23 @@ export function createGitSyncContext(workspace: string) { closeModal('pull') } - function showSuccessModal(savedWithoutInit?: boolean, autoPullOn?: boolean) { - activeModals.success = { open: true, savedWithoutInit, autoPullOn } + /** Only a sync repository in a non-fork workspace has a pull toggle of its own: promotion + * repositories have none, and a fork's pulling is its parent's to set. */ + function ownsAutoPull(repo: GitSyncRepository): boolean { + const ws = get(workspaceStore) + const isFork = + (ws?.startsWith('wm-fork-') ?? false) || + !!get(userWorkspaces)?.find((w) => w.id === ws)?.parent_workspace_id + return !repo.use_individual_branch && !isFork + } + + function showSuccessModal( + savedWithoutInit?: boolean, + autoPullOn?: boolean, + ownsAutoPull?: boolean, + webhookError?: string + ) { + activeModals.success = { open: true, savedWithoutInit, autoPullOn, ownsAutoPull, webhookError } } function closeSuccessModal() { @@ -314,8 +342,11 @@ export function createGitSyncContext(workspace: string) { repo.detectionJobId = undefined repo.detectionJobStatus = undefined - // Track the detection timestamp to avoid race conditions from old jobs + // Track the detection timestamp to avoid race conditions from old jobs. Stamped before + // the request, or a failure to even start the job reads as superseded and leaves the + // repository loading for good. const detectionTimestamp = Date.now() + repo._detectionTimestamp = detectionTimestamp try { const jobId = await JobService.runScriptByPath({ @@ -335,7 +366,6 @@ export function createGitSyncContext(workspace: string) { repo.detectionJobId = jobId repo.detectionJobStatus = 'running' - repo._detectionTimestamp = detectionTimestamp // Use JobManager for polling - result will be the actual job response await jobManager.runWithProgress(() => Promise.resolve(jobId), { @@ -506,7 +536,9 @@ export function createGitSyncContext(workspace: string) { } } - async function saveRepository(idx: number, savedWithoutInit = false) { + /** `announce` off for a caller that reports the save itself — the setup dialog ends on a + * step saying the same thing, and a modal over it would say it twice. */ + async function saveRepository(idx: number, savedWithoutInit = false, announce = true) { const repo = repositories[idx] if (!repo || !validateRepository(repo, idx)) { throw new Error('Cannot save invalid repository') @@ -539,7 +571,8 @@ export function createGitSyncContext(workspace: string) { if (repoToSave.auto_pull) { repoToSave.auto_pull = { ...repoToSave.auto_pull, - enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined + enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined, + webhook_error: await readWebhookError(repoToSave) } } @@ -553,7 +586,31 @@ export function createGitSyncContext(workspace: string) { repoToSave.detectionState = undefined repoToSave.extractedSettings = undefined // Show success modal for new connections - showSuccessModal(savedWithoutInit, repoToSave.auto_pull?.enabled === true) + if (announce) { + showSuccessModal( + savedWithoutInit, + repoToSave.auto_pull?.enabled === true, + ownsAutoPull(repoToSave), + repoToSave.auto_pull?.webhook_error + ) + } + } + } + + /** The webhook is registered after the settings commit, best-effort, and a failure is + * reported only through the stored settings — so it stays invisible until they are read + * back. Without this, pulling reads as working when it has silently fallen back to + * polling (an app missing the webhook permission, an instance GitHub cannot reach). */ + async function readWebhookError(repo: GitSyncRepository): Promise { + if (!repo.auto_pull?.enabled) return undefined + try { + const settings = await WorkspaceService.getSettings({ workspace }) + return settings.git_sync?.repositories?.find( + (r) => r.git_repo_resource_path.replace('$res:', '') === repo.git_repo_resource_path + )?.auto_pull?.webhook_error + } catch { + // Only costs the warning; the card shows it on the next load. + return undefined } } @@ -704,6 +761,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] @@ -712,9 +774,10 @@ export function createGitSyncContext(workspace: string) { legacyImported: false, isUnsavedConnection: true, collapsed: false - // Pull-from-Git defaults are applied by the repository card once the - // selected resource resolves: only app-backed repos (instant webhook - // delivery) default to auto-pull on; polling is opt-in for token repos. + // Pull-from-Git defaults are applied once the selected resource resolves, by + // whoever is configuring the connection (see applyNewConnectionDefaults): only + // app-backed repos (instant webhook delivery) default to auto-pull on; polling is + // opt-in for token repos. }) gitSyncTestJobs.push({ jobId: '', @@ -741,6 +804,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] diff --git a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte index 610dafb562..cb8236cafc 100644 --- a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte @@ -111,5 +111,7 @@ bind:open={gitSyncContext.activeModals.success.open} savedWithoutInit={gitSyncContext.activeModals.success.savedWithoutInit} autoPullOn={gitSyncContext.activeModals.success.autoPullOn} + explainAutoPullOff={gitSyncContext.activeModals.success.ownsAutoPull ?? false} + webhookError={gitSyncContext.activeModals.success.webhookError} /> {/if} diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index 291c89c606..8c3af1c54d 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -1,7 +1,6 @@ {#if !gitSyncContext} @@ -132,7 +228,7 @@ {#snippet actions()} {#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined} +
    + {:else} +
    + {#each syncRows as { repo, idx } (idx)} + {@render repoRow(repo, idx)} + {/each} + {#if promotionRows.length > 0} + +
    Promotion repositories
    + {#each promotionRows as { repo, idx } (idx)} + {@render repoRow(repo, idx)} + {/each} + {/if} +
    - {#if $enterpriseLicense} - - {#if primarySync && !primarySync.repo?.isUnsavedConnection && !devSingleRepo} - {#if secondarySync.length > 0 || secondarySyncExpanded} -
    - - - {#if secondarySyncExpanded} -
    - {#if secondarySync.length === 0} -
    - No secondary sync repositories configured -
    - {:else} - {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondary} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondary} -
    - + {addSyncLabel} + + {#if !$enterpriseLicense}{/if}
    {/if} - {/if} - {/if} - - - {#if showPromotion && !devSingleRepo} -
    - gitSyncContext.addPromotionRepository()} - isCollapsible={false} - showEmptyState={primaryPromotion?.repo === null} - /> - - - {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} - {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} -
    - - - {#if secondaryPromotionExpanded} -
    - {#if secondaryPromotion.length === 0} -
    - No secondary promotion repositories configured -
    - {:else} - {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondaryPromotion} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondaryPromotion} -
    - -
    - {/if} - {/if} + {#if showAddPromotion} +
    + + + Each deploy in this workspace pushes its changes to a dedicated + wm_deploy/** + branch of the repository instead of committing to its tracked branch directly. Merging + that branch promotes the change: the workspace that syncs the tracked branch deploys + it on merge, so set up Git Sync there. Windmill can open the pull request for each + deploy branch, or you can use the open-pr-on-commit workflow. + + {#if !$enterpriseLicense}{/if} +
    {/if}
    - {:else if !showPromotion} + {/if} + + {#if $enterpriseLicense && !showPromotion}
    + (deleting = undefined)} + > + + Deploys of this workspace will stop being committed to + {deleting?.path}. The repository itself and its resource are + left untouched. + + + + + + (settingsPath = undefined)} + > + settingsDrawer?.closeDrawer()} + > + {#if settingsIdx !== -1} + {#key settingsIdx} + + {/key} + {/if} + + + {/if} {/if} + +{#snippet repoRow(repo: GitSyncRepository, idx: number)} + {@const validation = gitSyncContext?.getValidation(idx)} +
    +
    +
    + + {repo.git_repo_resource_path || 'No resource selected'} + + {#if repo.isUnsavedConnection} + Not saved + {:else if validation?.hasChanges} + Unsaved changes + {/if} + {#if repo.legacyImported} + Legacy configuration + {/if} +
    + {rowLabel(idx)} +
    +
    + + (deleting = { idx, path: repo.git_repo_resource_path }) + } + ]} + /> +
    +
    +{/snippet} diff --git a/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte b/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte new file mode 100644 index 0000000000..c89ed45d76 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte @@ -0,0 +1,600 @@ + + + +
    + { + if (e.detail.index === 0 && step > 1) back() + }} + /> + +
    +
    + {#if step === 1} + {#snippet githubIcon()} + + {/snippet} + {#snippet gitlabIcon()} + + {/snippet} + {#snippet resourceIcon()} + + {/snippet} +
    + {@render providerCard( + 'github_app', + githubIcon, + 'GitHub App (recommended)', + 'Connect with the GitHub UI.', + !$enterpriseLicense + )} + {@render providerCard( + 'github_pat', + githubIcon, + 'GitHub personal access token', + 'Generate a token from GitHub to allow Windmill to access your account.' + )} + {@render providerCard( + 'gitlab', + gitlabIcon, + 'GitLab', + 'Generate a token from GitLab to allow Windmill to access your account.' + )} + {#if hasResource || provider === 'existing'} + {@render providerCard( + 'existing', + resourceIcon, + 'Use an existing git_repository resource', + 'Pick a resource that already holds the repository URL and its credentials.' + )} + {/if} +
    + {#if provider === 'existing'} + + {/if} + {:else if step === 5} + {#if done} + + {/if} + {:else if step === 4} + {#if draft} + (applying = v)} + onSuccess={() => { + initialized = true + void saveDraft() + }} + /> + {/if} + {#if saveError} + + {saveError} + + {/if} + {:else if step === 3} + {#if draftIdx !== -1} + + {#key configureAttempt} + + {/key} + {/if} + {#if saveError} + + {saveError} + + {/if} + {:else if $workspaceStore} +
    + {#if provider === 'github_app' && githubApp} + + {:else if gitlabHeld} + + {:else if provider === 'gitlab'} + + {:else} + + {/if} + + {#if connection} +
    + + +
    + Save as resource + + Windmill keeps the repository and its access as a git_repository resource + at this path. + + {#key connection.url} + + {/key} +
    +
    + {/if} + + {#if connectError} + + {connectError} + + {/if} +
    + {/if} +
    + +
    +
    + {#if step > 1 && step < 5} + + {:else if step === 1 && !hasResource && provider !== 'existing'} + + {/if} +
    + {#if step === 5} + + {:else if step === 4} +
    + {#if initialized} + + {:else} + + + {/if} +
    + {:else if step === 3} + {#if draft?.detectionState === 'no-wmill'} + + {:else} + + {/if} + {:else} + + {/if} +
    +
    +
    +
    + +{#snippet providerCard( + key: Provider, + icon: Snippet, + title: string, + subtitle: string, + eeOnly: boolean = false +)} + {#snippet description()} + + {subtitle} + {#if eeOnly}{/if} + + {/snippet} + selectProvider(key)} + /> +{/snippet} diff --git a/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte b/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte new file mode 100644 index 0000000000..047f55ef41 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte @@ -0,0 +1,72 @@ + + + + +
    + {#if savedWithoutInit} + + Only new changes will be pushed to this repository. What this workspace holds today has not + been pushed to it. + + {/if} + + {#if autoPullOn && webhookError} + + {webhookError} + + {:else if autoPullOn} + + New commits to the tracked branch deploy into this workspace automatically. You can adjust + this anytime on the repository's settings. + + {:else if explainAutoPullOff} + + Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull new + commits into this workspace for you. Prefer to control deployment from your own pipeline + (tests, custom gating, deploy on PR merge)? Set up GitHub Actions or a similar CI/CD workflow + instead. + + {/if} + + +
    + +
    +

    Git sync connection saved successfully!

    +

    + Your repository is now configured to receive changes from Windmill. +

    +
    +
    +
    diff --git a/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte index 32dc68a0f4..00ffa285b2 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte @@ -1,81 +1,24 @@ -
    - -
    -
    - -
    -
    -

    Git sync connection saved successfully!

    -

    Your repository is now configured to receive changes from Windmill.

    -
    -
    - - - {#if savedWithoutInit} -
    -

    Repository saved without initialization

    -

    - Only new changes will be pushed to this repository. Existing content in Windmill has not - been initialized to the repository. -

    -
    - {/if} - - - {#if autoPullOn} -
    -

    - - Pull from Git is on -

    -

    - New commits to the tracked branch deploy into this workspace automatically. You can - adjust this anytime on the repository card. -

    -
    - {:else} -
    -

    - - Deploy changes from Git back to Windmill -

    -

    - Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull - new commits into this workspace for you. -

    -
    -

    - Prefer to control deployment from your own pipeline (tests, custom gating, deploy on PR - merge)? Set up GitHub Actions or similar CI/CD workflows instead. -

    - -
    -
    - {/if} -
    +
    diff --git a/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte index 87c6ecb807..dea331bea5 100644 --- a/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte +++ b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte @@ -1,13 +1,9 @@ -
    - -

    Push your current workspace content to the connected Git repository based on the configured - filters.

    -

    Note: This will not update git sync settings in wmill.yaml. Settings can only be pulled from - the repository as it is the source of truth.

    - - - {#if isNewConnection} -
    -

    Settings that will be pushed to repository

    -
    -
    Include paths: {uiState.include_path?.join(', ') || 'None'}
    -
    Exclude paths: {uiState.exclude_path?.join(', ') || 'None'}
    - {#if uiState.extra_include_path?.length > 0} -
    Extra include paths: {uiState.extra_include_path.join(', ')}
    - {/if} -
    Include types: {uiState.include_type?.join(', ') || 'None'}
    -
    -

    To modify these settings, cancel and configure them in the workspace settings.

    -
    + +
    +
    Include paths: {uiState.include_path?.join(', ') || 'None'}
    +
    Exclude paths: {uiState.exclude_path?.join(', ') || 'None'}
    + {#if uiState.extra_include_path?.length} +
    Extra include paths: {uiState.extra_include_path.join(', ')}
    {/if} - - - {#if !previewResult} -
    - -
    - {/if} - - - {#if previewJobId} -
    - {#if previewJobStatus === 'running'} - - {:else if previewJobStatus === 'success'} - - {:else if previewJobStatus === 'failure'} - - {/if} - Preview job: - - {previewJobId} - -
    - {/if} - - - {#if previewError} - - {previewError} - - {/if} - - - {#if previewResult && !previewError} -
    -

    Changes to Push

    - - {#if previewResult.changes?.length > 0} - - {:else} -
    -
    No changes to push to the repository.
    -
    - {/if} -
    - {/if} - - - {#if previewResult && !previewError} - {@const hasChanges = previewResult.changes?.length > 0} - {#if hasChanges} -
    -
    - - {#if isNewConnection && onSaveWithoutInit} - - {/if} -
    -
    - {/if} - {/if} - - - {#if applyJobId} -
    - {#if applyJobStatus === 'running'} - - {:else if applyJobStatus === 'success'} - - {:else if applyJobStatus === 'failure'} - - {/if} - Push job: - - {applyJobId} - -
    - {/if} - - - {#if applyError} - - {applyError} - - {/if} - - -
    - - - {#if showCliInstructions} -
    -
    -# Setup (only needed if local folder not initialized yet)
    -npm install -g windmill-cli
    -wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
    -wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    -
    -# Pull workspace content to git repository
    -wmill sync pull --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    -
    - {/if} -
    +
    Included types: {uiState.include_type?.join(', ') || 'None'}
    +

    + This does not update the git sync settings in wmill.yaml: they + can only be pulled from the repository, which is their source of truth. To change what is + pushed, cancel and edit the filters in the workspace settings. +

    + + {#if open} + {#key gitRepoResourcePath} + + {/key} + {/if} + + + {#snippet actions()} + + {#if isNewConnection && onSaveWithoutInit} + + {/if} + {/snippet} diff --git a/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte b/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte new file mode 100644 index 0000000000..c1ff4e1f0d --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte @@ -0,0 +1,314 @@ + + +{#if repo} +
    + {#if factsError} + +
    + {factsError} + +
    +
    + {/if} + {#if !repo.detectionState || repo.detectionState === 'idle' || repo.detectionState === 'loading'} +
    + + Checking the repository... +
    + {:else if repo.detectionState === 'error'} + +
    + {repo.detectionError || 'The check failed.'} + Check the URL and the token, or go back and connect the repository again. + +
    +
    + {:else if repo.detectionState === 'no-wmill'} + + The repository has no Windmill configuration yet. Choose what to sync, then initialize it + with the current content of this workspace. + + {:else if repo.detectionState === 'has-wmill'} + + The repository already holds a Windmill configuration. Its sync settings are loaded below. + + {/if} + + {#if repo.detectionState === 'no-wmill' || repo.detectionState === 'has-wmill'} + + {#snippet subtitle()} + + {/snippet} + + + {#if mode === 'promotion'} + + {#if managedCredential} + setPromotionOpenPrs(e.detail)} + /> + {:else} + + To open a pull request for each deploy branch, connect the repository through the GitHub + App or give a GitLab repository a project access token — otherwise set up the + open-pr-on-commit workflow in the repository. + + {/if} + {:else if !isFork} +
    + setAutoPull(e.detail)} + > + {#snippet right()} + {#if !$enterpriseLicense}{/if} + {/snippet} + + {#if repo.auto_pull?.enabled} + + {managedCredential + ? 'New commits are delivered instantly by webhook, with polling as a fallback.' + : 'The branch is checked for new commits about every minute.'} + + {:else} + + Leave it off to control deployment from your own pipeline (tests, custom gating, + deploy on PR merge) with + GitHub Actions or a similar CI/CD workflow. + + {/if} + setSyncForks(e.detail)} + /> + {#if managedCredential} + setForkOpenPrs(e.detail)} + > + {#snippet right()} + {#if !$enterpriseLicense}{/if} + {/snippet} + + {/if} +
    + {/if} + {/if} + + {#if repo.detectionState !== 'no-wmill' && repo.detectionState !== 'has-wmill'} + +
    + {#if repo.detectionJobId} + {#if repo.detectionJobStatus === 'success'} + + {:else if repo.detectionJobStatus === 'failure'} + + {/if} + Check job: + + {repo.detectionJobId} + + {/if} +
    + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte b/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte new file mode 100644 index 0000000000..fdea233345 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte @@ -0,0 +1,232 @@ + + +
    + {#if (!setup.loaded || gh.loadingGithubInstallations) && gh.workspaceGithubInstallations.length === 0} +
    + Loading GitHub App installations... +
    + {:else} + {#if usable.length === 0} + + {#if gh.isCheckingInstallation} + Finish installing the Windmill GitHub App in the tab that opened. This dialog picks up the + installation on its own. + {:else} + Install the Windmill GitHub App on the GitHub account or organization that owns the + repository, then come back here. + {/if} + + {:else} + + {/if} +
    diff --git a/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte b/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte new file mode 100644 index 0000000000..2d8c7f2f48 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte @@ -0,0 +1,95 @@ + + +
    + + +
    diff --git a/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts b/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts new file mode 100644 index 0000000000..4af90decf6 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts @@ -0,0 +1,45 @@ +import type { GitSyncRepository } from '../GitSyncContext.svelte' + +/** + * Defaults for a connection that has not been saved yet, once its resource is known. + * Only fills what the user has not set, so it is safe to call again. + */ +export function applyNewConnectionDefaults( + repo: GitSyncRepository, + o: { + mode: 'sync' | 'promotion' + /** Windmill holds a credential for the repository: a GitHub App or a stored token. */ + managedCredential: boolean + isGithubApp: boolean + isFork: boolean + ee: boolean + } +): void { + if (!repo.isUnsavedConnection) return + // Pulling from Git defaults on only where Windmill can receive webhooks. Polling is + // opt-in for token repositories, and forks never get the parent-only defaults (the + // backend rejects them). + if ( + o.mode === 'sync' && + o.managedCredential && + !o.isFork && + o.ee && + repo.auto_pull === undefined + ) { + repo.auto_pull = { enabled: true, mode: 'auto', sync_forks: true } + } + // A promotion deploy pushes a wm_deploy/** branch that exists to be merged; without a + // pull request it is an orphaned branch. Fork PRs stay opt-in everywhere. + if ( + o.mode === 'promotion' && + o.managedCredential && + o.ee && + repo.promotion_open_prs === undefined + ) { + repo.promotion_open_prs = true + } + // Webhook with a polling fallback is the only delivery for app repositories. + if (o.isGithubApp && repo.auto_pull?.mode === 'polling') { + repo.auto_pull = { ...repo.auto_pull, mode: 'auto' } + } +} diff --git a/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts b/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts new file mode 100644 index 0000000000..645973720f --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts @@ -0,0 +1,50 @@ +import { + createGitHubAppState, + loadGithubInstallations, + startInstallationCheck, + stopInstallationCheck, + type GitHubAppState +} from '$lib/githubApp' + +/** + * GitHub App installations for the setup dialog. Owned by the dialog rather than the + * connect form so the installations are known before the user leaves step 1: the + * install page has to be opened synchronously from that click, or the browser blocks + * the new tab. + */ +export class GithubAppSetup { + gh: GitHubAppState = $state(createGitHubAppState()) + loaded = $state(false) + + constructor(private workspace: string) {} + + get usable() { + return this.gh.workspaceGithubInstallations.filter((i) => !i.error) + } + + async reload(): Promise { + try { + await loadGithubInstallations(this.gh, this.workspace) + } catch { + // loadGithubInstallations toasts its own failure + } + this.loaded = true + const usable = this.usable + if (!usable.some((i) => i.installation_id === this.gh.selectedGHAppInstallationId)) { + this.gh.selectedGHAppInstallationId = usable[0]?.installation_id + this.gh.selectedGHAppRepository = undefined + } + } + + /** Must be called from a click handler, synchronously. */ + openInstall(): void { + const url = this.gh.githubInstallationUrl + if (!url) return + window.open(url, '_blank', 'noopener') + startInstallationCheck(this.gh, this.workspace, () => void this.reload()) + } + + dispose(): void { + stopInstallationCheck(this.gh) + } +} diff --git a/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts b/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts new file mode 100644 index 0000000000..4b9cdbe9cb --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import { parseRepoUrl, repoSlug, withToken } from './repoConnection' + +describe('parseRepoUrl', () => { + test('drops credentials, query and fragment from a pasted URL', () => { + const url = parseRepoUrl(' https://someone:secret@github.com/acme/infra.git?utm=1#readme ') + expect(url?.toString()).toBe('https://github.com/acme/infra.git') + }) + + test('refuses what cannot carry a token safely or name a repository', () => { + // http would put the token on the wire, and the rest name no repository at all. + expect(parseRepoUrl('http://github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('ssh://git@github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('https://github.com/acme')).toBeUndefined() + }) +}) + +test('withToken encodes the token so a punctuated one survives the round trip', () => { + const token = 'glpat-a/b@c:d' + const embedded = withToken(parseRepoUrl('https://gitlab.com/acme/infra')!, token, 'gitlab') + expect(embedded).toBe(`https://oauth2:${encodeURIComponent(token)}@gitlab.com/acme/infra`) + expect(new URL(embedded).password).toBe(encodeURIComponent(token)) +}) + +test('repoSlug names the resource after the repository', () => { + expect(repoSlug('https://github.com/acme/My-Repo.git')).toBe('my_repo') + expect(repoSlug('https://gitlab.com/group/sub/project/')).toBe('project') +}) diff --git a/frontend/src/lib/components/git_sync/setup/repoConnection.ts b/frontend/src/lib/components/git_sync/setup/repoConnection.ts new file mode 100644 index 0000000000..81955086fd --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/repoConnection.ts @@ -0,0 +1,117 @@ +import { GitSyncService, ResourceService, VariableService } from '$lib/gen' + +/** What a provider form hands back once the user has picked a repository. */ +export type RepoConnection = { + /** The repository URL, never carrying a secret. */ + url: string + isGithubApp?: boolean + /** A token for Windmill to store and renew server-side (EE GitLab). */ + heldToken?: string + /** `url` with the token embedded, filed as a secret variable because the + * resource value itself is readable by anyone who can read the resource. */ + tokenUrl?: string +} + +/** Username git sends alongside a token: GitHub ignores it for PATs, GitLab + * requires a non-empty one and documents `oauth2`. */ +const TOKEN_USERNAME = { github: 'x-access-token', gitlab: 'oauth2' } as const + +export type TokenProvider = keyof typeof TOKEN_USERNAME + +/** Parses a pasted repository URL, dropping any credentials it carried. */ +export function parseRepoUrl(raw: string): URL | undefined { + let u: URL + try { + u = new URL(raw.trim()) + } catch { + return undefined + } + // https only: the token is embedded in this URL, and plain http would put it on the wire. + if (u.protocol !== 'https:') return undefined + if (u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).length < 2) return undefined + u.username = '' + u.password = '' + u.search = '' + u.hash = '' + return u +} + +export function withToken(url: URL, token: string, provider: TokenProvider): string { + const u = new URL(url.toString()) + u.username = TOKEN_USERNAME[provider] + u.password = token.trim() + return u.toString() +} + +/** A resource name derived from the repository, e.g. `acme/infra.git` → `infra`. */ +export function repoSlug(url: string): string { + const last = + url + .replace(/\.git\/?$/, '') + .replace(/\/+$/, '') + .split('/') + .pop() ?? '' + return last.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() || 'git_repository' +} + +/** + * Writes everything a connection needs and creates the `git_repository` resource at + * `path`. A token-bearing URL goes to a secret variable at the same path, which the + * backend renames along with the resource. + */ +export async function createRepositoryResource( + workspace: string, + path: string, + conn: RepoConnection, + location: { branch?: string; folder?: string } +): Promise { + const branch = location.branch?.trim() + // Relative to the repository root, which is how the sync script joins it. + const folder = location.folder?.trim().replace(/^\/+|\/+$/g, '') + let url = conn.url + let createdVariable = false + if (conn.tokenUrl) { + if (await VariableService.existsVariable({ workspace, path })) { + throw new Error(`A variable already exists at ${path}. Pick another path.`) + } + await VariableService.createVariable({ + workspace, + requestBody: { + path, + value: conn.tokenUrl, + is_secret: true, + description: `Authenticated URL of the git repository ${conn.url}` + } + }) + createdVariable = true + url = `$var:${path}` + } + try { + if (conn.heldToken) { + await GitSyncService.setGitCredential({ + workspace, + requestBody: { repo_url: conn.url, token: conn.heldToken } + }) + } + await ResourceService.createResource({ + workspace, + requestBody: { + path, + resource_type: 'git_repository', + description: `Git repository ${conn.url}`, + value: { + url, + ...(branch ? { branch } : {}), + ...(folder ? { folder } : {}), + is_github_app: !!conn.isGithubApp + } + } + }) + } catch (e) { + // Otherwise a retry at the same path is refused over this run's own secret. + if (createdVariable) { + await VariableService.deleteVariable({ workspace, path }).catch(() => {}) + } + throw e + } +} diff --git a/frontend/src/lib/components/home/ListFilters.svelte b/frontend/src/lib/components/home/ListFilters.svelte index 6fa2865616..1594141ad2 100644 --- a/frontend/src/lib/components/home/ListFilters.svelte +++ b/frontend/src/lib/components/home/ListFilters.svelte @@ -4,7 +4,8 @@ import type { BadgeColor, BadgeIconProps } from '../common/badge/model' import { appIconComponent } from '../icons' import { integrationDisplayName } from '../resourceTypeDisplay' - import { onDestroy, onMount } from 'svelte' + import { untrack } from 'svelte' + import { useHostedPage } from '../hostedPage' interface Props { filters: string[] @@ -39,42 +40,35 @@ inline = false }: Props = $props() - const queryChange: (value: URL) => void = (url: URL) => { - if (syncQuery) { - window.history.pushState(history.state, '', `?${url?.searchParams.toString()}`) - } - } - - const eventListener = (e: PopStateEvent) => { - if (syncQuery) { - loadFilterFromUrl() - } - } - - onMount(() => { - window.addEventListener('popstate', eventListener) - }) - - onDestroy(() => { - window.removeEventListener('popstate', (e) => eventListener(e)) - }) - - loadFilterFromUrl() + const hosted = useHostedPage() function loadFilterFromUrl() { - let queryValue = new URL(window.location.href).searchParams.get(queryName) ?? undefined - selectedFilter = queryValue + const search = hosted ? hosted.search : window.location.search + selectedFilter = new URLSearchParams(search).get(queryName) ?? undefined } - export async function setQuery(url: URL, key: string, value: string | undefined): Promise { - if (value != undefined) { - url.searchParams.set(key, value) - } else { - url.searchParams.delete(key) - } - queryChange(url) + function writeQuery(value: string | undefined) { + if (!syncQuery) return + const params = new URLSearchParams(hosted ? hosted.search : window.location.search) + if (value != undefined) params.set(queryName, value) + else params.delete(queryName) + if (hosted) hosted.setSearch(params.size ? `?${params}` : '') + else window.history.pushState(history.state, '', `?${params}`) } + $effect(() => { + if (!syncQuery) return + if (hosted) { + hosted.search + untrack(loadFilterFromUrl) + return + } + window.addEventListener('popstate', loadFilterFromUrl) + return () => window.removeEventListener('popstate', loadFilterFromUrl) + }) + + untrack(loadFilterFromUrl) + let filtersAndSelected = $derived( selectedFilter ? filters.includes(selectedFilter) @@ -109,11 +103,7 @@ class="inline-flex items-center gap-1 align-middle" onclick={() => { selectedFilter = selectedFilter == filter ? undefined : filter - if (selectedFilter) { - setQuery(new URL(window.location.href), queryName, selectedFilter) - } else { - setQuery(new URL(window.location.href), queryName, undefined) - } + writeQuery(selectedFilter) }} {color} clickable diff --git a/frontend/src/lib/components/hostedPage.ts b/frontend/src/lib/components/hostedPage.ts new file mode 100644 index 0000000000..424c10c450 --- /dev/null +++ b/frontend/src/lib/components/hostedPage.ts @@ -0,0 +1,31 @@ +import { getContext, setContext } from 'svelte' + +// A list page mounted somewhere other than its own route — an AI session's preview tab. The +// document URL there belongs to the host, so the page's view (the filters in its query) has to +// live wherever the host keeps it, and its rows and links open where the host decides instead of +// in a drawer or a top-level navigation. Components under such a host read this instead of +// `window.location`; outside one there is none, and they keep to the document. + +export type HostedPage = { + /** The page's query, `''` or `?…`. Reactive: the host may re-point it. */ + readonly search: string + /** Replace the page's query. */ + setSearch(search: string): void + /** Open one of the page's rows, by path. */ + openItem(path: string): void + /** Follow an in-app link (base included) the page would otherwise navigate to. */ + openLink(href: string): void +} + +const KEY = Symbol('hostedPage') + +/** Reads context: call during component initialisation. */ +export function setHostedPage(host: HostedPage): void { + setContext(KEY, host) +} + +/** The host of this page, or undefined on the page's own route. Reads context: call during + * component initialisation. */ +export function useHostedPage(): HostedPage | undefined { + return getContext(KEY) +} diff --git a/frontend/src/lib/components/instanceBanner.ts b/frontend/src/lib/components/instanceBanner.ts index f1693024b8..4cceb7ad59 100644 --- a/frontend/src/lib/components/instanceBanner.ts +++ b/frontend/src/lib/components/instanceBanner.ts @@ -1,5 +1,3 @@ -import { SettingService } from '$lib/gen' - /** Drives the banner palette and icon; the subset of `AlertType` that fits an announcement. */ export type InstanceBannerSeverity = 'info' | 'warning' | 'error' @@ -123,7 +121,3 @@ export function isInstanceBannerVisible( if (banner == undefined) return false return !banner.dismissible || dismissedFingerprint !== banner.fingerprint } - -export async function fetchInstanceBanner(): Promise { - return resolveInstanceBanner(await SettingService.getGlobal({ key: INSTANCE_BANNER_SETTING })) -} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 1fe45931a2..92988f53b5 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -2,6 +2,7 @@ import type { ButtonType } from './common/button/model' import { allowedOriginsSettingError } from './triggers/http/utils' import { z } from 'zod' import { instanceBannerFormError } from './instanceBanner' +import { ACCENT_COLOR_SETTING, parseAccentColor } from '$lib/accentColor' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import { writable } from 'svelte/store' @@ -72,6 +73,7 @@ export interface Setting { | 'ws_connectivity' | 'retention_overrides' | 'instance_banner' + | 'accent_color' storage: SettingStorage advancedToggle?: { label: string @@ -334,6 +336,22 @@ export const settings: Record = { hideInQuickSetup: true, // Gates Save. The card renders the specific message itself, so no `error` here. isValid: (value: any) => instanceBannerFormError(value) == undefined + }, + { + label: 'Accent color', + description: + 'Recolors buttons, selections and the sidebar for every user, so each environment (e.g. staging vs. production) is recognizable at a glance.', + key: ACCENT_COLOR_SETTING, + fieldType: 'accent_color', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true, + error: 'Must be a hex color like #1f9d55', + isValid: (value: unknown) => + value === undefined || + value === null || + value === '' || + parseAccentColor(value) !== undefined } ], Jobs: [ diff --git a/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte b/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte new file mode 100644 index 0000000000..619d32a7a0 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/AccentColorSetting.svelte @@ -0,0 +1,51 @@ + + +
    + enabled, (v) => ($values[ACCENT_COLOR_SETTING] = v ? DEFAULT_PICK : undefined) + } + options={{ right: 'Use a custom accent color' }} + /> + {#if enabled} +
    + ($values[ACCENT_COLOR_SETTING] = e.currentTarget.value)} + /> +
    + raw ?? '', (v) => ($values[ACCENT_COLOR_SETTING] = String(v).trim() || undefined)} + /> +
    +
    + + Accents keep the default palette's lightness and take this color's hue, so text stays readable + whatever color you pick. Applies to every user after saving. + + {/if} +
    diff --git a/frontend/src/lib/components/jobs/JobPreview.svelte b/frontend/src/lib/components/jobs/JobPreview.svelte index 972cff6799..7f95ef8f63 100644 --- a/frontend/src/lib/components/jobs/JobPreview.svelte +++ b/frontend/src/lib/components/jobs/JobPreview.svelte @@ -15,8 +15,10 @@ import { Badge } from '../common' import { forLater } from '$lib/forLater' import DurationMs from '../DurationMs.svelte' - import { workspaceStore } from '$lib/stores' import { twMerge } from 'tailwind-merge' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() const POPUP_HEIGHT = 320 as const @@ -115,7 +117,7 @@ Arguments
    diff --git a/frontend/src/lib/components/progressBar/ProgressBar.svelte b/frontend/src/lib/components/progressBar/ProgressBar.svelte index 1bf99d0e91..77e21dc205 100644 --- a/frontend/src/lib/components/progressBar/ProgressBar.svelte +++ b/frontend/src/lib/components/progressBar/ProgressBar.svelte @@ -4,6 +4,7 @@ import { twMerge } from 'tailwind-merge' import { Loader2 } from 'lucide-svelte' import { fade } from 'svelte/transition' + import type { Snippet } from 'svelte' // Remove padding/margin, border radius and titles @@ -36,6 +37,8 @@ isScheduled?: boolean // Whether the job was skipped (early-stop labelled as skipped) isSkipped?: boolean + /** Rendered after "Error occurred" when the bar is in its error state. */ + errorAction?: Snippet } let { @@ -56,7 +59,8 @@ isWaitingForEvents = false, isCanceled = false, isScheduled = false, - isSkipped = false + isSkipped = false, + errorAction }: Props = $props() let duration = 200 @@ -148,7 +152,7 @@
    @@ -184,6 +188,9 @@ {/if} {/key} + {#if status == 'error' && errorAction} + {@render errorAction()} + {/if}
    t.id === id)) return select(selectionOfTab(id), opts) } @@ -602,13 +607,20 @@ closeTab(runnableTabId(key)) } - // Ask the UI Builder iframe to open a document. `populateFiles` replays - // `iframeDocument` on iframe load, so record it even when the iframe isn't - // ready yet (the postMessage is then skipped). - function openInIframe(path: string) { + // Ask the UI Builder iframe to open a document, taking the keyboard only when + // the user picked it. For a file whose current content the iframe doesn't hold + // yet, `populateFiles` opens `iframeDocument` in the same message as that + // content: opening first errors on a new file and races the write on a changed + // one (the iframe applies the edit twice). It also opens it on iframe load. + // Documents outside `files` (wmill.ts, ui/, node_modules/) never go through it. + function openInIframe(path: string, focus = false) { iframeDocument = path - if (iframeLoaded) { - iframe?.contentWindow?.postMessage({ type: 'selectFile', path }, '*') + const content = files?.[path] + if (iframeLoaded && (content === undefined || iframeFiles?.[path] === content)) { + iframeFocusPending = undefined + iframe?.contentWindow?.postMessage({ type: 'selectFile', path, focus }, '*') + } else { + iframeFocusPending = focus ? path : undefined } } @@ -730,6 +742,7 @@ summary = update.summary } if (update.files !== undefined) { + iframeFiles = undefined files = update.files } if (update.runnables !== undefined) { @@ -810,6 +823,17 @@ } let iframeLoaded = $state(false) // @hmr:keep + // The files the iframe holds: last posted to it, or last reported by it. Explicit + // replacements (history, YAML) clear it so they are always sent: the iframe can + // hold edits it hasn't reported yet, which a skip would leave in place. + let iframeFiles: Record | undefined + // The document the user picked that `openInIframe` left for `populateFiles` to + // open. It takes the keyboard only if it is still the document being opened. + let iframeFocusPending: string | undefined + // Last keystroke or click in the editor, or edit reported by the iframe. AI + // edits only move the editor to their file once the user has paused this long. + let lastUserInputAt = 0 + const USER_IDLE_MS = 3000 // Briefly drops the `setActiveDocument` echo VS Code fires while we're // pushing the initial file set — the iframe auto-opens a default editor // during boot which we don't want to treat as a user-driven activation. @@ -857,6 +881,10 @@ function populateFiles() { if (files) { + // `files` is reassigned with unchanged content (the session draft sync, history + // restores, the iframe's own reports). Re-sending it makes the iframe reopen its + // document and rewrite the files under the user's cursor. + if (deepEqual(files, iframeFiles)) return suppressSetActiveDocument = true if (suppressTimer !== undefined) clearTimeout(suppressTimer) suppressTimer = setTimeout(() => { @@ -875,10 +903,14 @@ } } function setFilesInIframe(newFiles: Record) { + const target = iframe?.contentWindow + if (!target) return + iframeFiles = { ...newFiles } + iframeFocusPending = undefined const files = Object.fromEntries( Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/')) ) - iframe?.contentWindow?.postMessage( + target.postMessage( { type: 'setFiles', files: files @@ -889,14 +921,20 @@ function setFilesAndSelectInIframe(newFiles: Record, pathToSelect: string) { iframeDocument = pathToSelect + const target = iframe?.contentWindow + if (!target) return + iframeFiles = { ...newFiles } + const focus = iframeFocusPending === pathToSelect + iframeFocusPending = undefined const files = Object.fromEntries( Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/')) ) - iframe?.contentWindow?.postMessage( + target.postMessage( { type: 'setFilesAndSelect', files: files, - pathToSelect: pathToSelect + pathToSelect: pathToSelect, + focus }, '*' ) @@ -1012,14 +1050,15 @@ return frontendFiles }, setFrontendFile: (path, content): LintResult => { - console.log('setting frontend file', path, content) + console.log('setting frontend file', path, `${content.length} chars`) if (!files) { files = {} } files[path] = content - // Combined setFilesAndSelect avoids a race, so let it do the telling. - select({ kind: 'file', path }, { notifyIframe: false }) - setFilesAndSelectInIframe(files, path) + // Follow the AI to the file it edits, but never pull a user who is editing + // off their file. The files effect sends the content. + if (Date.now() - lastUserInputAt < USER_IDLE_MS) ensureFileTab(path) + else select({ kind: 'file', path }) return lint() }, deleteFrontendFile: (path) => { @@ -1027,7 +1066,6 @@ files = {} } delete files[path] - setFilesInIframe(files) }, listBackendRunnables: () => { return Object.entries(runnables).map(([key, runnable]) => ({ @@ -1399,9 +1437,11 @@ if (e.data.type === 'setFiles') { // Normalize Windows-style path separators to Linux-style const normalizedFiles = normalizeFilePaths(e.data.files) + iframeFiles = { ...normalizedFiles } // Only mark pending changes if files actually changed (ignore echo from setFilesInIframe) if (!deepEqual(files, normalizedFiles)) { files = normalizedFiles + lastUserInputAt = Date.now() historyManager.markPendingChanges() } } else if (e.data.type === 'getBundle') { @@ -2015,6 +2055,7 @@ } $effect(() => { iframe?.addEventListener('load', () => { + iframeFiles = undefined iframeLoaded = true }) }) @@ -2080,6 +2121,7 @@ } }) $effect(() => { + // populateFiles must track file entries so in-place AI edits trigger synchronization. iframe && iframeLoaded && files && populateFiles() }) $effect(() => { @@ -2108,7 +2150,7 @@ // future `FileExplorer` caller feeds folder paths back in. function handleSelectPath(path: string) { if (!path || path.endsWith('/')) return - select({ kind: 'file', path }) + select({ kind: 'file', path }, { focus: true }) } // Track previous values for change detection @@ -2202,19 +2244,12 @@ data: RawAppData }) { try { + iframeFiles = undefined files = structuredClone($state.snapshot(entry.files)) runnables = structuredClone($state.snapshot(entry.runnables)) summary = entry.summary replaceData(structuredClone($state.snapshot(entry.data))) - // If the open document survives into the new files, use the combined message - if (iframeDocument && isOpenableDocument(iframeDocument)) { - // Use combined setFilesAndSelect message to avoid race condition - setFilesAndSelectInIframe(entry.files, iframeDocument) - } else { - // Otherwise just set files normally - setFilesInIframe(entry.files) - } populateRunnables() } catch (error) { console.error('Failed to apply entry:', error) @@ -2351,7 +2386,12 @@ gateJobIds={false} extraSourceWindow={() => externalPreviewWindow} /> -
    +
    (lastUserInputAt = Date.now())} + onpointerdowncapture={() => (lastUserInputAt = Date.now())} + class="max-h-full overflow-hidden h-full min-h-0 flex flex-col" +> files, - (newFiles) => { - files = newFiles - setFilesInIframe(newFiles ?? {}) - } - } + bind:files onSelectPath={handleSelectPath} onSelectRunnable={(key) => select({ kind: 'runnable', key })} onDeleteRunnable={deleteRunnable} @@ -2495,7 +2529,7 @@ activateTab(id)} + onSelect={(id) => activateTab(id, { focus: true })} onClose={(id) => closeTab(id)} onReorder={(next) => reorderTabs(next)} > @@ -2580,7 +2614,7 @@ activateTab(id)} + onSelect={(id) => activateTab(id, { focus: true })} onClose={(id) => closeTab(id)} onReorder={(next) => reorderTabs(next)} > diff --git a/frontend/src/lib/components/resources/ResourcesList.svelte b/frontend/src/lib/components/resources/ResourcesList.svelte new file mode 100644 index 0000000000..180474276a --- /dev/null +++ b/frontend/src/lib/components/resources/ResourcesList.svelte @@ -0,0 +1,1623 @@ + + + { + deleteConfirmedCallback = undefined + }} + on:confirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +
    + Are you sure you want to remove {deletePath}? + {#if deleteIsLinked} + + This resource is linked with a variable of the same path. That variable is deleted with it, + unless another resource still references it. + + {/if} + +
    + You can press + SHIFT + while removing a resource to bypass confirmation. +
    +
    +
    +
    + + + + + inferrer?.toggleDrawer?.()}> + + {#snippet actions()} + + {/snippet} + + + + + + {#snippet titleExtra()} + + {/snippet} +
    + {#if resourceTypeViewerObj.description} + + {/if} + {#if resourceTypeViewerObj.isFileset} + + This resource type represents a collection of files. Each file is identified by its + relative path and contains text content. In the CLI, filesets are stored as directories. + + {:else if resourceTypeViewerObj.formatExtension} + + This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension} + extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension}) + + {:else} + + {/if} +
    +
    +
    + + + + {#snippet actions()} + + {/snippet} +
    + + +
    + {#if editResourceType.isFileset} + + This resource type represents a collection of files. The schema cannot be edited. + + {:else if editResourceType.formatExtension} + + This resource type represents a plain text file with a .{editResourceType.formatExtension} + extension. (e.g. my_file.{editResourceType.formatExtension}). The schema only + contains a `content` field and thus cannot be edited. + + {:else} +
    Schema
    +
    + +
    + {/if} +
    +
    +
    +
    + + + + {#snippet actions()} + + {/snippet} +
    + + +
    +
    +
    Schema
    +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    + +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.resources} + +{:else} + + + {#if showCreateButtons} +
    + + +
    + {/if} +
    + (showCreateButtons = v)} /> +
    + { + if (e.detail == 'cache') { + loading.resources = true + loadCache() + } else if (e.detail == 'states') { + loading.resources = true + loadState() + } else if (e.detail == 'theme') { + loading.resources = true + loadTheme() + } + }} + > + + + {#snippet extra()} + + Every resource has a Resource Type attached to it which contains its schema and make + it easy in scripts and flows to accept only resources of a specific resource type. + + {/snippet} + + + {#snippet extra()} + + States are actually resources (but excluded from the Workspace tab for clarity). + States are used by scripts to keep data persistent between runs of the same script by + the same trigger (schedule or user) + + {/snippet} + + + {#snippet extra()} + + Cached results are actually resources (but excluded from the Workspace tab for + clarity). Cache are used by flows's step to cache result to avoid recomputing + unnecessarily + + {/snippet} + + + {#snippet extra()} + + Themes are actually resources (but excluded from the Workspace tab for clarity). They + are CSS for the legacy low-code app editor only, and are added from the CSS panel of + an app rather than from this page. + + {/snippet} + + +
    +
    +
    + + {#if showTable} +
    + {#if loading.resources} + + {#each new Array(6) as _} + + {/each} + {:else if filteredItems?.length == 0} + {#if activeFilters} + + {:else} + appConnect?.open?.(), + aiId: 'resources-empty-add-resource', + aiDescription: 'Add resource' + } + : undefined} + /> + {/if} + {:else} + + + + + Path + Resource type + Description + Status + Actions + + +
    + {#if filteredItems} + {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'resource', path) ?? is_draft} + + + + + +
    + { + handledHash = `#/resource/${path}` + openResourceEditor(path, resource_type) + }} + >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} + + {#if labels?.length} +
    + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} +
    + {/if} + +
    +
    + + { + const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) + if (linkedRt) { + resourceTypeViewerObj = { + rt: linkedRt.name, + //@ts-ignore + schema: linkedRt.schema, + description: linkedRt.description ?? '', + formatExtension: linkedRt.format_extension, + isFileset: linkedRt.is_fileset ?? false + } + resourceTypeViewer?.openDrawer?.() + } else { + sendUserToast( + `Resource type ${resource_type} not found in workspace.`, + true + ) + } + }} + > + + + + +
    + + {removeMarkdown(truncate(description ?? '', 200))} + +
    +
    + +
    +
    + {#if is_linked} + + + {#snippet text()} +
    + This resource is linked with a variable of the same path. They + are deleted and renamed together. +
    + {/snippet} +
    + {/if} +
    +
    + {#if is_refreshed} + + + {#snippet text()} +
    + The OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
    + {/snippet} +
    + {/if} +
    + + {#if is_oauth} +
    + {#if refresh_error} + + + {#snippet text()} +
    + Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
    + {/snippet} +
    + {:else if is_expired} + + + + {#snippet text()} +
    + The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
    + {/snippet} +
    + {:else} + + + {#snippet text()} +
    + The resource was connected through OAuth and the token is not + expired. +
    + {/snippet} +
    + {/if} +
    + {/if} +
    +
    + +
    + {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !operatingUser.current?.operator} + + {/if} + { + shareModal?.openDrawer?.(path, 'resource') + } + }, + { + displayName: 'Edit', + icon: Pen, + disabled: !canWrite || !showCreateButtons, + action: () => { + openResourceEditor(path, resource_type) + } + }, + // The agent form covers an agent's configuration, not everything a + // resource carries: the workspace-specific toggle in particular is + // only in the generic editor. JSON rather than that editor's form, + // which would render the configuration field by field and write a + // default into every one the agent leaves unset. Both write the same + // draft row, so this is a second view of the same edits. + ...(resource_type === 'ai_agent' + ? [ + { + displayName: 'Edit as JSON', + icon: Braces, + disabled: !canWrite || !showCreateButtons, + action: () => { + // The drawer anchors itself in the hash, which the deep-link + // effect would then read and route back to the agent editor. + // Claim it first, as the row's own link does. + handledHash = `#/resource/${path}` + // A host's item tab opens an agent on JSON already. + if (hosted) hosted.openItem(path) + else resourceEditor?.initEdit?.(path, { json: true }) + } + } + ] + : []), + ...(!ws_specific && isDeployable('resource', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'resource') + } + } + ] + : []), + { + displayName: 'Delete', + disabled: !canWrite || !showCreateButtons, + icon: Trash, + type: 'delete', + action: (event) => { + // TODO + // @ts-ignore + if (event?.shiftKey) { + deleteResource(path, account) + } else { + deleteIsLinked = is_linked ?? false + deletePath = path + deleteConfirmedCallback = () => { + deleteResource(path, account) + } + } + } + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RotateCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $operatingWorkspace ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadResources() + } + } + ] + : []) + ]} + /> +
    +
    + {/each} + {/if} + + + {/if} + + {:else if tab == 'types'} + {#if loading.types} + + {#each new Array(6) as _} + + {/each} + {:else if filteredResourceTypes?.length == 0} +
    + +
    + {:else} +
    + + + + Name + Description + Actions + + +
    + {#if filteredResourceTypes} + {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} + + + { + resourceTypeViewerObj = { + rt: name, + //@ts-ignore + schema: schema, + description: description ?? '', + formatExtension: format_extension, + isFileset: is_fileset ?? false + } + + resourceTypeViewer?.openDrawer?.() + }} + > + + + + + +
    + + {removeMarkdown(truncate(description ?? '', 200))} + +
    +
    + + {#if !canWrite} + +
    + + Shared globally + + This resource type is from the 'admins' workspace shared with all + workspaces + + +
    + {:else if operatingUser.current?.is_admin || operatingUser.current?.is_super_admin} +
    + + +
    + {:else} + + Non Editable + + Since resource types are shared with the whole workspace, only admins + can edit/delete them + + + {/if} +
    +
    + {/each} + {/if} + + + + {/if} + {/if} + + +{/if} + + + + + + + { + loadResources() + }} +/> + + + +{#if !hosted && agentEditorTarget()} + {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }} + t.host === undefined} + onRenamed={(from, to) => { + void loadResources() + // Only while the dialog still shows the agent: closed mid-request, it already cleared the + // anchor, and writing it back would reopen the editor on refresh. + if (agentEditorTarget()?.path !== from) return + // Claimed first, as a row click does, so the deep-link effect does not reopen it. + handledHash = `#/resource/${to}` + setPageDrawerAnchor(RESOURCES_PATH, to) + }} + /> + {/await} +{/if} diff --git a/frontend/src/lib/components/resources/resourcesFilter.ts b/frontend/src/lib/components/resources/resourcesFilter.ts index 0526ff87b3..97d1b61c7e 100644 --- a/frontend/src/lib/components/resources/resourcesFilter.ts +++ b/frontend/src/lib/components/resources/resourcesFilter.ts @@ -76,15 +76,14 @@ export function buildResourcesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only resources in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only resources in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte new file mode 100644 index 0000000000..80236a300f --- /dev/null +++ b/frontend/src/lib/components/schedules/SchedulesList.svelte @@ -0,0 +1,718 @@ + + + + + +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules} + +{:else} + + + + +
    +
    + + {#snippet children({ item })} + + + + {/snippet} + + +
    + {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !schedules?.length} + {#if activeFilters} + + {:else} + scheduleEditor?.openNew(false), + aiId: 'schedules-empty-add', + aiDescription: 'Add schedule' + }} + /> + {/if} + {:else if items?.length} +
    + {#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const avg_s = jobs + ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length + : undefined} + +
    +
    + + + { + handledHash = `#${path}` + editSchedule(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > +
    + {summary || script_path}{hasDraft ? '*' : ''} +
    +
    + schedule: {path} +
    +
    + {#if labels?.length} + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} + {/if} + + + {#if paused_until && new Date(paused_until) > new Date()} +
    + Paused until {new Date(paused_until).toLocaleString()} +
    + {/if} + + + + + +
    + {#if error} + + + + + + {#snippet text()} +
    + The schedule disabled itself because there was an error scheduling the + next job: {error} +
    + {/snippet} +
    + {/if} +
    + +
    + + {#key toggleResetVersions[path] ?? 0} + { + if (canWrite) { + setScheduleEnabled(path, e.detail) + } else { + sendUserToast('not enough permission', true) + // Permission denied — bump the row's reset + // counter so the Toggle remounts back to the + // prop value. Without this, the local + // `bind:checked` flip from the user's click + // stays stuck on. + bumpToggleReset(path) + } + }} + /> + {/key} +
    +
    + + + { + if (hosted) hosted.openLink(`${base}${href}`) + else goto(href) + } + }, + { + displayName: `Duplicate schedule`, + icon: Copy, + action: () => { + scheduleEditor?.openNew(is_flow, script_path, path) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canWrite, + action: async () => { + await ScheduleService.deleteSchedule({ + workspace: $operatingWorkspace ?? '', + path + }) + loadSchedules() + } + }, + { + displayName: canWrite ? 'Edit' : 'View', + icon: canWrite ? Pen : Eye, + action: () => { + editSchedule(path, is_flow) + } + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { + kind: 'schedules' + } + }) + } + } + ] + : []), + { + displayName: 'View runs', + icon: List, + href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` + }, + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Run now', + icon: Play, + action: () => { + runScheduleNow(script_path, path, is_flow, $operatingWorkspace!) + } + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, 'schedule') + } + } + ]} + /> +
    +
    +
    + {#if loadingSchedulesWithJobStats} +
    + + Job stats loading... +
    + {:else} +
    + {#if avg_s} +
    Avg: {(avg_s / 1000).toFixed(2)}s
    + {/if} + {#each jobs ?? [] as job} + {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} + + +
    +
    + +
    +
    +
    + {/each} +
    + {/if} +
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    +
    + {/each} +
    + {:else} + + {/if} +
    + {#if items && items?.length > 15 && nbDisplayed < items.length} +
    + {nbDisplayed} items out of {items.length} + +
    + {/if} +
    +{/if} + + { + loadSchedules() + }} +/> diff --git a/frontend/src/lib/components/schedules/schedulesFilter.ts b/frontend/src/lib/components/schedules/schedulesFilter.ts index 24fb1c90a3..4b887e5aa4 100644 --- a/frontend/src/lib/components/schedules/schedulesFilter.ts +++ b/frontend/src/lib/components/schedules/schedulesFilter.ts @@ -73,15 +73,14 @@ export function buildSchedulesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only schedules in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only schedules in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/select/GenericDropdown.svelte b/frontend/src/lib/components/select/GenericDropdown.svelte index 78a88a6457..a2bd6a044b 100644 --- a/frontend/src/lib/components/select/GenericDropdown.svelte +++ b/frontend/src/lib/components/select/GenericDropdown.svelte @@ -53,6 +53,9 @@ const spaceAbove = inputR.y const isBelow = fitsBelow || spaceBelow >= spaceAbove let [x, y] = disablePortal ? [0, 0] : [inputR.x, inputR.y] + // The list grows to its widest item, so one wider than the room right of its input + // would run off screen: slide it left instead. + if (!disablePortal) x = Math.max(0, Math.min(x, window.innerWidth - listR.width)) if (isBelow) return { width: inputR.width, height: listR.height, x: x, y: y + inputR.height, isBelow } else { diff --git a/frontend/src/lib/components/select/MultiSelect.svelte b/frontend/src/lib/components/select/MultiSelect.svelte index 419984b20d..0ebb824865 100644 --- a/frontend/src/lib/components/select/MultiSelect.svelte +++ b/frontend/src/lib/components/select/MultiSelect.svelte @@ -18,6 +18,7 @@ let { items, placeholder = 'Select items', + searchPlaceholder = 'Search...', value = $bindable(), class: className = '', style, @@ -42,6 +43,9 @@ items?: Item[] value: Value[] placeholder?: string + /** Placeholder of the dropdown's search field. Worth setting where the list is there to + * create entries rather than pick from, so the field says what to type. */ + searchPlaceholder?: string class?: string style?: string filterText?: string @@ -96,7 +100,12 @@ function onAddValue(item: ProcessedItem) { if (item.__is_create && onCreateItem) { + const before = currentValue.length onCreateItem(item.value) + // Cleared only once the entry exists: it is then in the list, and leaving it in the + // field would filter the list down to it. A consumer that refused the value (a + // duplicate, a name it rejects) keeps what was typed, to be corrected. + if ((value?.length ?? 0) !== before) filterText = '' } else { value = [...currentValue, item.value] } @@ -192,7 +201,7 @@ bind:value={filterText} inputProps={{ onblur: (e) => (e.preventDefault(), searchInputEl?.focus()), - placeholder: 'Search...' + placeholder: searchPlaceholder }} class="!pr-7 !bg-surface" /> diff --git a/frontend/src/lib/components/sessions/PageListView.svelte b/frontend/src/lib/components/sessions/PageListView.svelte new file mode 100644 index 0000000000..7dab54c494 --- /dev/null +++ b/frontend/src/lib/components/sessions/PageListView.svelte @@ -0,0 +1,131 @@ + + +{#snippet loading()} +
    + +
    +{/snippet} + + +
    + {#key `${path}:${workspaceId}:${reloadNonce}`} + {#if listed?.kind === 'variable'} + {#await import('$lib/components/variables/VariablesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'resource'} + {#await import('$lib/components/resources/ResourcesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'schedule'} + {#await import('$lib/components/schedules/SchedulesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'trigger'} + {#await import('$lib/components/triggers/TriggerList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {/if} + {/key} +
    diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index b88907bf02..f1d630caca 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -105,6 +105,7 @@ // A page item's editor reads its draft only when it loads, so a reload remounts it. let pageItemReloadNonce = $state(0) + let pageListReloadNonce = $state(0) export function reload() { // A live editor shares the runtime store the chat mutates, so generic chat @@ -116,6 +117,10 @@ pageItemReloadNonce++ return } + if (slot.kind === 'pagelist') { + pageListReloadNonce++ + return + } try { const win = frame?.contentWindow if (!win) return @@ -334,6 +339,27 @@ {/await} {/if} +{:else if slot.kind === 'pagelist' && mounted && runtime} +
    + {#if overlayHostEl} + {#await import('./PageListView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {/if} +
    {:else if slot.kind === 'artifact' && mounted}
    — put it back on the active session button // so subsequent arrow keys keep navigating the list. @@ -608,14 +609,14 @@ // After deleting the open session, land somewhere usable: the newest remaining // session, else a fresh one. The page derives the visible session from the - // `session_name` query, so leaving the URL on a deleted session would fall + // `session` query, so leaving the URL on a deleted session would fall // through to recovery and open a blank one rather than their recent work. async function openReplacementSession() { const next = sessionState.sessions[0] if (next) await activate(next) else { const fresh = createSession() - await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`) + await goto(sessionPageHref(fresh.id)) } } @@ -1297,7 +1298,7 @@

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

    {#if pendingDeleteForkId} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index b56b72c2be..775b1b150f 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -46,6 +46,7 @@ reconcileAfterWorkspaceChange, renameSession, selectSession, + sessionPageHref, sessionState, setSessionArchived, syncWorkspaceTo, @@ -164,10 +165,10 @@ async function resetToNewSession() { const fresh = createSession() selectSession(fresh.id) - // The page derives the visible session from the `session_name` query, not + // The page derives the visible session from the `session` query, not // currentSessionId: navigate so the URL leaves the deleted/archived session, // which would otherwise fall through to recovery or stay on the archived one. - await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`) + await goto(sessionPageHref(fresh.id)) } // If the session targets a forked workspace that's still accessible, @@ -536,7 +537,7 @@

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

    {#if sessionForkId} @@ -568,7 +569,7 @@

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

    {#if sessionForkId} diff --git a/frontend/src/lib/components/sessions/pageDrawerSession.ts b/frontend/src/lib/components/sessions/pageDrawerSession.ts index 16d8d9204a..0a3e7435ca 100644 --- a/frontend/src/lib/components/sessions/pageDrawerSession.ts +++ b/frontend/src/lib/components/sessions/pageDrawerSession.ts @@ -21,7 +21,6 @@ import { type TriggerKind } from './previewPaths' import type { OpenInSessionSource } from './OpenInSessionButton.svelte' -import { isSessionPreviewFrame } from './sessionMode.svelte' // The draft each page's drawer edits. The preview loads the page in its own // document and reads the draft back from the server, so opening a session has to @@ -84,34 +83,6 @@ export function setPageDrawerAnchor(pagePath: string, itemPath: string | undefin history.replaceState(history.state, '', `${pathname}${search}${anchor}`) } -/** - * Inside a session preview frame, hand a list page row up to the session, which edits it in - * a tab of its own. True when handed off: the caller must then not open its drawer. False - * off that page, and outside a preview frame, where the drawer is how the row is edited. - */ -export function handOffPageDrawer(pagePath: string, itemPath: string | undefined): boolean { - if (!itemPath || !isSessionPreviewFrame()) return false - if (stripBase(window.location.pathname) !== pagePath) return false - try { - window.parent.postMessage( - { type: 'wm.session.openPageItem', pagePath, path: itemPath }, - window.location.origin - ) - } catch { - return false - } - // A frame left on a row's hash claims a row nobody has open here, and reopens its tab on - // every reload. Not through the router: these pages open their drawer from the hash. Once - // more after the event: a row link's `href="#"` lands after its click handler. - const dropAnchor = () => { - const { pathname, search, hash } = window.location - if (hash) history.replaceState(history.state, '', `${pathname}${search}`) - } - dropAnchor() - setTimeout(dropAnchor, 0) - return true -} - /** * Drop the row a list page deep-links, once its drawer closes. The hash is how the row was * requested; leaving it behind makes the location claim a drawer that is no longer open — diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 13d5abca33..58eca9504d 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -79,6 +79,11 @@ export function pageItemListPath(ref: PageItemRef): string { } } +/** Whether a (base-stripped) path is the list page of a page item kind. */ +export function isPageItemListPath(path: string): boolean { + return pageItemForListPath(path, '') !== undefined +} + /** 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) diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index 3bc76882e5..143ce42975 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -284,6 +284,17 @@ describe('resolvePreviewTab', () => { expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' }) }) + it('mounts the list page of a page item kind in process, whatever its filters', () => { + expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({ + kind: 'pagelist', + path: '/variables' + }) + expect(resolvePreviewTab('/kafka_triggers')).toEqual({ + kind: 'pagelist', + path: '/kafka_triggers' + }) + }) + it('routes any script item to a live editor', () => { expect(resolvePreviewTab('/scripts/edit/f/foo/bar')).toEqual({ kind: 'editor', diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 13e7626e70..de21660f3f 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -3,6 +3,7 @@ import { AUDIT_LOGS_PATH, FOLDERS_PATH, GROUPS_PATH, + isPageItemListPath, pageItemForListPath, pageItemListPath, pageItemPageHref, @@ -530,13 +531,14 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:') // fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts // its per-(kind,path) cell editor; a `/pipeline/` route mounts the // data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path` -// is the folder); everything else (static pages, regular drag-and-drop apps, any -// other route) stays an iframe. +// is the folder); the list page of a page item kind mounts that list; 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 } | { kind: 'artifact'; id: string; version?: number } | { kind: 'runform'; toolCallId: string } | { kind: 'pageitem'; ref: PageItemRef } + | { kind: 'pagelist'; path: string } | { kind: 'iframe' } export function resolvePreviewTab(url: string): PreviewSlot { @@ -546,22 +548,24 @@ 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 pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder } } - const route = parsePreviewItemRoute(url) - if (!route) return { kind: 'iframe' } + const item = parsePreviewItemRoute(url) + if (!item) return { kind: 'iframe' } const editorKind: SessionTargetKind | undefined = - route.kind === 'script' + item.kind === 'script' ? 'script' - : route.kind === 'flow' + : item.kind === 'flow' ? 'flow' - : route.kind === 'app' && route.raw_app + : item.kind === 'app' && item.raw_app ? 'raw_app' : undefined if (!editorKind) return { kind: 'iframe' } - return { kind: 'editor', editorKind, path: route.itemPath } + return { kind: 'editor', editorKind, path: item.itemPath } } /** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when @@ -576,6 +580,7 @@ export function workspacePageHref(location: string): string | undefined { case 'pageitem': return pageItemPageHref(slot.ref) case 'editor': + case 'pagelist': case 'iframe': return location default: { diff --git a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts index bfa1621822..f30d66ca45 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts @@ -1169,7 +1169,6 @@ function unpackBackup( const active = chats.find((c) => c.id === head.chatId) const session: Session = { ...(head as unknown as Session), - name: '', // Everything in the backup has been read here: no unread badge, and the last // activity is the backup's own time. lastSeenCount: active?.displayMessages.length ?? 0, diff --git a/frontend/src/lib/components/sessions/sessionMirror.test.ts b/frontend/src/lib/components/sessions/sessionMirror.test.ts index 7676662d31..55a1acc138 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.test.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.test.ts @@ -1077,7 +1077,7 @@ describe('sessionMirror restore', () => { expect(pullMock).toHaveBeenCalledTimes(2) await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s9'])) const restored = sessionState.sessions[0] - expect(restored.name).toBe('session-1') + expect(restored.name).toBeUndefined() expect(restored.summary).toBe('remote') expect(restored.lastSeenCount).toBe(1) expect((await readStoredChat('c9', EMAIL))?.displayMessages).toHaveLength(1) diff --git a/frontend/src/lib/components/sessions/sessionMirrorPlan.ts b/frontend/src/lib/components/sessions/sessionMirrorPlan.ts index caea48c792..5d40c35e3b 100644 --- a/frontend/src/lib/components/sessions/sessionMirrorPlan.ts +++ b/frontend/src/lib/components/sessions/sessionMirrorPlan.ts @@ -78,7 +78,7 @@ export function isFallbackStorage(name: string): boolean { /** * The part of a session record the backup keeps. Left out on purpose: `name` (a - * per-browser counter), the unsent-draft fields (`pending_*`, `draftPrompt`, + * per-browser legacy label), the unsent-draft fields (`pending_*`, `draftPrompt`, * `autoSendDraftAt`), `workspace_root_id` (derived on import), `transient`, `restoredAt` * (this browser's clock), and the two fields reading a session bumps (`lastSeenCount`, * `lastActivityAt`) — so opening a session and reading its new messages never costs a push. diff --git a/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts b/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts index 635bd68601..443f972334 100644 --- a/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts @@ -1,6 +1,6 @@ import { SvelteSet } from 'svelte/reactivity' -// Session ids opened as a stand-in for a `session_name` this browser doesn't +// Session ids opened as a stand-in for a `session` URL this browser doesn't // hold. Kept in memory rather than on the Session record: persisted, the notice // would replay on every reload of a session the user has since made their own. const recovered = new SvelteSet() diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index fbb84e1431..6edc8ab1dc 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -406,7 +406,8 @@ function createRuntime(session: Session): SessionRuntime { const tab = owner?.displayedTab if (!tab) return undefined const slotKind = resolvePreviewTab(tab.url).kind - if (slotKind !== 'iframe' && slotKind !== 'pageitem') return undefined + if (slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist') + return undefined return previewLocationContext(whereIs(tab)) } // Pre-flight: materialise the (still-transient) session, then commit diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 3014e6d5e7..c5a2f28455 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -82,7 +82,9 @@ export type SessionSummarySource = 'placeholder' | 'generated' | 'manual' export type Session = { id: string - name: string + // Per-browser `session-N` label that older sessions-page URLs carried; only read + // to resolve those links. + name?: string // Committed strictly at first user-message send. Undefined for drafts // that have never been sent — those scope by `pending_workspace_id` // instead and don't show the fork bar. @@ -1005,8 +1007,8 @@ if (BROWSER) { }) } -export function findSessionByName(name: string): Session | undefined { - return sessionState.sessions.find((s) => s.name === name) +export function sessionPageHref(id: string): string { + return `/sessions?session=${encodeURIComponent(id)}` } // Bumped to ask the active session's composer to re-focus even when @@ -1045,15 +1047,6 @@ export function findEmptyLandingSession(): Session | undefined { ) } -// Session names are a per-browser counter (`session-N`) that the sessions page puts in -// its URL, so a new or restored record takes the number after the highest in use. -function nextSessionNumber(sessions: Session[]): number { - const numbers = sessions - .map((s) => /^session-(\d+)$/.exec(s.name)?.[1]) - .map((n) => (n ? parseInt(n, 10) : 0)) - return (numbers.length ? Math.max(...numbers) : 0) + 1 -} - export function createSession(): Session { // Reuse an existing untouched draft from the active family rather than pile a // blank entry on every `+`, so several pending sessions can still be built up @@ -1074,7 +1067,6 @@ export function createSession(): Session { return reusable } sessionState.sessions = sessionState.sessions.filter((s) => !isDiscardableDraft(s)) - const next = nextSessionNumber(sessionState.sessions) // Start in the workspace you're in. The one exception: a root you can't // deploy to (locked, no bypass) steers to its dev, since a session there // couldn't edit anything. The picker lets you switch. @@ -1100,7 +1092,6 @@ export function createSession(): Session { const summary = `${adj.charAt(0).toUpperCase() + adj.slice(1)} session` const session: Session = { id: createLongHash(), - name: `session-${next}`, summary, summarySource: 'placeholder', pending_workspace_id: pending && pending.length > 0 ? pending : undefined, @@ -1459,11 +1450,10 @@ export async function importSessions(records: Session[], email: string): Promise try { const tx = db.transaction('sessions', 'readwrite') const existing = new Set((await tx.store.getAllKeys()).map(String)) - let next = nextSessionNumber([...(await tx.store.getAll()), ...sessionState.sessions]) const restoredAt = Date.now() for (const r of records) { if (existing.has(r.id) || deletedSessionIds.has(r.id)) continue - const record: Session = { ...r, name: `session-${next++}`, restoredAt } + const record: Session = { ...r, restoredAt } delete record.transient delete record.workspace_root_id ensureSessionRootId(record) diff --git a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts index dddfe795f7..fe0ad84d78 100644 --- a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts +++ b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts @@ -814,33 +814,28 @@ describe('sessionState IndexedDB persistence', () => { await vi.waitFor(() => expect(sessionState.sessions).toEqual([])) }) - // A restored backup must never replace what this browser has, come back after the - // user deleted it here, or take a name the sessions page already routes by. - it('importSessions adds only unknown, undeleted records under fresh names', async () => { + // A restored backup must never replace what this browser has, or come back after the + // user deleted it here. + it('importSessions adds only unknown, undeleted records', async () => { const user = freshUser() await login(user) - const local = session({ id: 'local', name: 'session-3', createdAt: 1, summary: 'mine' }) + const local = session({ id: 'local', createdAt: 1, summary: 'mine' }) await putSession(local) sessionState.sessions.push(local) deleteSession('local') await flush() - await putSession(session({ id: 'kept', name: 'session-5', createdAt: 2, summary: 'kept' })) + await putSession(session({ id: 'kept', createdAt: 2, summary: 'kept' })) const imported = await importSessions( [ - session({ id: 'local', name: 'session-1', createdAt: 1, summary: 'remote copy' }), - session({ id: 'kept', name: 'session-1', createdAt: 2, summary: 'remote copy' }), - session({ id: 'new', name: 'session-1', createdAt: 3, workspace_id: 'ws' }) + session({ id: 'local', createdAt: 1, summary: 'remote copy' }), + session({ id: 'kept', createdAt: 2, summary: 'remote copy' }), + session({ id: 'new', createdAt: 3, workspace_id: 'ws' }) ], user.email ) expect(imported).toEqual(['new']) - await vi.waitFor(() => - expect(sessionState.sessions.map((s) => [s.id, s.name])).toEqual([ - ['new', 'session-6'], - ['kept', 'session-5'] - ]) - ) + await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['new', 'kept'])) expect(sessionState.sessions.find((s) => s.id === 'kept')?.summary).toBe('kept') // The wrong user's name gets nothing written. diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index a7a492b874..36a91ee453 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -4,6 +4,7 @@ import { workspaceStore } from '$lib/stores' import { createSession, selectSession, + sessionPageHref, sessionInCurrentFamily, sessionLastActivityAt, sessionState, @@ -81,7 +82,7 @@ function resumableSession(): Session | undefined { export async function enterSessionMode(opts?: { replace?: boolean }): Promise { const target = resumableSession() ?? createSession() selectSession(target.id) - await goto(`/sessions?session_name=${encodeURIComponent(target.name)}`, { + await goto(sessionPageHref(target.id), { replaceState: opts?.replace ?? false }) } @@ -197,7 +198,7 @@ async function openInSession( navRouteOffered = true } selectSession(session.id) - await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`) + await goto(sessionPageHref(session.id)) } // Open an editor's own hand-off, running its `beforeOpen` (which persists the @@ -243,5 +244,5 @@ export async function startSessionWithPrompt( // An empty prompt has nothing to send; leave the composer focused instead. if (opts?.autoSend && prompt.trim()) setSessionAutoSend(session.id) selectSession(session.id) - await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`) + await goto(sessionPageHref(session.id)) } diff --git a/frontend/src/lib/components/sessions/sessionSwitch.test.ts b/frontend/src/lib/components/sessions/sessionSwitch.test.ts index 23ca52a693..d550ad8df4 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.test.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.test.ts @@ -62,7 +62,7 @@ describe('enterSessionMode — restore is scoped to the active family', () => { try { await enterSessionMode() expect(sessionState.currentSessionId).toBe('sw-in-family') - expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-911', { + expect(goto).toHaveBeenCalledWith('/sessions?session=sw-in-family', { replaceState: false }) } finally { @@ -82,7 +82,7 @@ describe('enterSessionMode — restore is scoped to the active family', () => { try { await enterSessionMode() expect(sessionState.currentSessionId).toBe('sw-local') - expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-913', { + expect(goto).toHaveBeenCalledWith('/sessions?session=sw-local', { replaceState: false }) } finally { diff --git a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts index 7e89d3b945..64f7f7e6c2 100644 --- a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts +++ b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts @@ -94,7 +94,10 @@ export function useUserDraftSync(opts: UserDraftSyncOptions): void // re-apply only advances the sig, never reverts an edit or fires a save. if (sig === lastInboundSig) return lastInboundSig = sig - codec.applyDraftToStore(incoming) + // Copies both ways: a store sharing proxies with the draft makes every local + // edit re-fire this effect as if a draft came in, reassigning the store on + // each keystroke. + codec.applyDraftToStore($state.snapshot(incoming) as Draft) }) }) @@ -125,7 +128,9 @@ export function useUserDraftSync(opts: UserDraftSyncOptions): void untrack(() => { const current = UserDraft.get(codec.itemKind, path, { workspace }) if (current && codec.sig(current) === sig) return - const toSave = codec.storeToDraft(current) ?? draft + const toSave = $state.snapshot(codec.storeToDraft(current) ?? draft) as Draft + // Our own write comes back through the handle; the inbound side must skip it. + lastInboundSig = codec.sig(toSave) UserDraft.save(codec.itemKind, path, toSave, { workspace }) }) } diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index dc0244da9d..1024aefa72 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -63,7 +63,8 @@ !label.startsWith('embed_app:') && !label.startsWith('sdk_app:') && !label.startsWith('impersonation:') && - !label.startsWith('cli-login:') + !label.startsWith('cli-login:') && + !label.startsWith('remote-deploy:') ) } diff --git a/frontend/src/lib/components/sidebar/sidebarChrome.ts b/frontend/src/lib/components/sidebar/sidebarChrome.ts index 71ff703d27..4c58202416 100644 --- a/frontend/src/lib/components/sidebar/sidebarChrome.ts +++ b/frontend/src/lib/components/sidebar/sidebarChrome.ts @@ -1,6 +1,6 @@ // Sidebar rail background. Kept here as the single source for every host that // renders or mimics the sidebar (app layout, kitchen-sink harness). -// The dark value is a theme-driven CSS variable so it adapts across dark -// variants (default vs GitHub dark) — see tailwind.config.cjs `--sidebar-bg-dark`. -export const SIDEBAR_BG = '#F3F3F7' +// Both are CSS variables (see tailwind.config.cjs `--sidebar-bg-*`) so they adapt +// across dark variants and to the instance accent tint (accentColor.ts). +export const SIDEBAR_BG = 'var(--sidebar-bg-light)' export const SIDEBAR_BG_DARK = 'var(--sidebar-bg-dark)' diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts index bc82a9269e..df8e668fba 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts @@ -6,18 +6,18 @@ describe('workspaceMenuHref', () => { expect( workspaceMenuHref({ pathname: '/sessions', - searchParams: new URLSearchParams('session_name=foo'), + searchParams: new URLSearchParams('session=foo'), id: 'wm-fork-bar', sameFamily: true }) - ).toBe('/sessions?session_name=foo&workspace=wm-fork-bar') + ).toBe('/sessions?session=foo&workspace=wm-fork-bar') }) it('drops the open session for a cross-family target', () => { expect( workspaceMenuHref({ pathname: '/sessions', - searchParams: new URLSearchParams('session_name=foo'), + searchParams: new URLSearchParams('session=foo&session_name=session-5'), id: 'other-root', sameFamily: false }) @@ -52,7 +52,7 @@ describe('workspaceMenuHref', () => { expect( workspaceMenuHref({ pathname: '/runs', - searchParams: new URLSearchParams('workspace=old&job=123&session_name=s'), + searchParams: new URLSearchParams('workspace=old&job=123&session=s'), id: 'target', sameFamily: true, landOnHome: true diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts index 6c8290b071..57342f2b08 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts @@ -25,6 +25,7 @@ export function workspaceMenuHref(args: { const params = new URLSearchParams(args.searchParams) params.set('workspace', args.id) if (!args.sameFamily) { + params.delete('session') params.delete('session_name') } return `${args.pathname}?${params.toString()}` diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte new file mode 100644 index 0000000000..501eebd90f --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggerList.svelte @@ -0,0 +1,983 @@ + + +{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)} + {@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind) + ? row.marked + : undefined} +
    + {#if marked} + + {@html marked} + + {:else if triggerKind === 'http'} + {#if row.summary} + {row.summary} + {:else} + {row.http_method!.toUpperCase()} + /{isCloudHosted() || row.workspaced_route || globalHttpWorkspacedRoute + ? row.workspace_id + '/' + row.route_path + : row.route_path} + {/if} + {:else if triggerKind === 'websocket'} + {websocketUrl(row.url!)} + {:else if triggerKind === 'kafka'} + {row.kafka_resource_path} - {row.topics!.join(', ')} + {:else if triggerKind === 'nats'} + {row.nats_resource_path} - {row.subjects!.join(', ')} + {:else if triggerKind === 'email'} + {emailAddress(row)} + {:else if triggerKind === 'gcp'} + {row.path} - {row.topic_id} + {:else if triggerKind === 'azure'} + {row.path} - {row.topic_name + ? row.topic_name + : (row.scope_resource_id?.split('/')?.pop() ?? '')} ({row.azure_mode}, {row.subscription_name}) + {:else} + {row.path} + {/if}{hasDraft ? '*' : ''} +
    + {#if ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)} +
    {row.path}
    + {:else if triggerKind === 'postgres'} +
    + {row.postgres_resource_path} +
    + {/if} +
    + {#if triggerKind === 'http' && row.static_asset_config} + file: {row.static_asset_config.s3} + {:else} + runnable: {row.script_path} + {/if} +
    +{/snippet} + +{#snippet statusDot(row: TriggerW)} + {@const status = config.status} + {#if status} + {@const ping = status.pings + ? row.last_server_ping + ? new Date(row.last_server_ping) + : undefined + : new Date()} + {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} + {@const effectiveMode = row.draft_only ? 'disabled' : row.mode} + {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} +
    + {#if (enabled && (!pinging || row.error)) || (!enabled && row.error) || (enabled && !row.server_id)} + + + + + + {#snippet text()} +
    + {#if enabled} + {#if !row.server_id} + {status.starting} + {:else} + {status.notConnected}{row.error ? ': ' + row.error : ''} + {/if} + {:else} + {status.disabled}: {row.error} + {/if} +
    + {/snippet} +
    + {:else if enabled} + + + + + {#snippet text()} +
    + {status.connected}{status.shuttingDown && !row.server_id ? ' (shutting down...)' : ''} +
    + {/snippet} +
    + {/if} +
    + {/if} +{/snippet} + + { + isDeleting = false + pendingDelete = undefined + }} + on:confirmed={confirmPendingDelete} +> +
    + Are you sure you want to remove this trigger? + {#if pendingDelete && triggerKind === 'postgres'} + + + {:else if pendingDelete && triggerKind === 'gcp'} + + {:else if pendingDelete?.subscription_name && triggerKind === 'azure'} + + {/if} +
    +
    + + +{#await EDITORS[triggerKind]() then Editor} + +{/await} +{#if triggerKind === 'http'} + {#await import('./http/RoutesGenerator.svelte') then Generator} + + {/await} + {#await import('./http/OpenAPISpecGenerator.svelte') then Generator} + + {/await} +{/if} + + (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} +/> + +{#if operatorBlocked} + +{:else} + + +
    + {#if triggerKind === 'http'} + + + {/if} + {#if canCreate} + + {/if} +
    +
    + + {#if config.cloudDisabled && isCloudHosted()} + + {config.title} are disabled in the multi-tenant cloud. + +
    + {/if} +
    +
    + +
    +
    Filter by path of
    + + {#snippet children({ item })} + + + {/snippet} + +
    + + +
    + {#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')} + + {:else if isAdmin} + + {/if} +
    +
    + {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !triggers?.length} + editor?.openNew(false), + aiId: config.empty.aiId, + aiDescription: config.empty.aiDescription + } + : undefined} + /> + {:else if items?.length} +
    + {#each items.slice(0, nbDisplayed) as row (row.path)} + {@const { + path, + edited_by, + edited_at, + script_path, + is_flow, + extra_perms, + canWrite, + mode, + retry, + error_handler_path, + error_handler_args, + labels, + draft_only, + is_draft + } = row} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, config.draftKind, path) ?? is_draft} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const effectiveMode = draft_only ? 'disabled' : mode} + {@const live = isLive(row)} + {@const copy = copyUrl(row)} + +
    +
    + + + { + if (hosted) e.preventDefault() + openEdit(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > + {@render rowTitle(row, hasDraft)} + + + + + {#if live} + {@render statusDot(row)} + {/if} + +
    + + {#if live} + onToggleMode(path, newMode)} + triggerMode={effectiveMode} + includeModalConfig={{ + triggerPath: path, + triggerKind, + runnableConfig: { + path: script_path, + kind: is_flow ? 'flow' : 'script', + retry, + errorHandlerPath: error_handler_path, + errorHandlerArgs: error_handler_args + } + }} + {canWrite} + hideToggleLabels + hideDropdown + /> + {/if} +
    + +
    + {#if copy} + + {/if} + + openLink(href) + }, + ...(canWrite && !draft_only && mode !== 'suspended' + ? [ + { + displayName: 'Suspend job execution', + icon: Pause, + action: () => { + onToggleMode(path, 'suspended') + } + } + ] + : []), + { + displayName: canWrite ? 'Edit' : 'View', + icon: canWrite ? Pen : Eye, + action: () => openEdit(path, is_flow) + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { kind: config.deployKind } + }) + } + } + ] + : []), + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, config.shareKind as any) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canWrite || (config.adminOnly && !isAdmin), + action: () => deleteTrigger(row) + } + ]} + /> +
    +
    +
    +
    + {#if edited_by}
    edited by {edited_by}
    {/if} +
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    +
    +
    +
    + {/each} +
    + {:else} + + {/if} +
    + {#if items && items?.length > 15 && nbDisplayed < items.length} + {nbDisplayed} items out of {items.length} + + {/if} +
    +{/if} + + { + loadTriggers() + }} +/> diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte index 7e3c2c5ac1..3afcdf1a43 100644 --- a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { untrack } from 'svelte' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -176,7 +175,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.amqp.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 697bc00ad4..12b6bf0533 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -156,7 +155,6 @@ isFlow: boolean, defaultValues?: Record ) { - if (handOffPageDrawer(TRIGGER_PAGES.azure.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 30f848004f..fe472cb8c4 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -143,7 +142,6 @@ defaultConfig?: Partial, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.email.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 92902aa44b..03c4a12261 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -167,7 +166,6 @@ defaultValues?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.gcp.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 2b28ae0d8d..5fc2999704 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -2,7 +2,6 @@ import { Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -274,7 +273,6 @@ isFlow: boolean, defaultConfig?: Partial ) { - if (handOffPageDrawer(TRIGGER_PAGES.http.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() drawerLoading = true diff --git a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte index 91b2ee7e9e..71b54a39e0 100644 --- a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte @@ -16,16 +16,20 @@ import RouteEditor from './RouteEditor.svelte' import { generateHttpTriggerFromOpenApi, type Source } from './utils' import { isCloudHosted } from '$lib/cloud' - import { usedTriggerKinds, userStore } from '$lib/stores' + import { usedTriggerKinds } from '$lib/stores' import FileInput from '../../common/fileInput/FileInput.svelte' import { emptyStringTrimmed, sendUserToast } from '$lib/utils' import FolderPicker from '../../FolderPicker.svelte' import Required from '$lib/components/Required.svelte' import { Drawer, DrawerContent } from '$lib/components/common' import { get } from 'svelte/store' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() type Props = { closeFn: () => Promise @@ -137,7 +141,9 @@ } } - let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) + let userIsAdmin = $derived( + operatingUser.current?.is_admin || operatingUser.current?.is_super_admin + ) async function generateHttpTrigger() { try { diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 3fb5668f44..ea604bc5a8 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -178,7 +177,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.kafka.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index f3a42f0d49..3627bd2d4d 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { untrack } from 'svelte' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -173,7 +172,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.mqtt.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 16997be11a..045f7155d3 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -161,7 +160,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.nats.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 23ce3fc43c..546b4d2457 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Button, TabContent } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths' @@ -262,7 +261,6 @@ defaultConfig?: Record, fixedScriptPath_?: string ) { - if (handOffPageDrawer(TRIGGER_PAGES.postgres.path, ePath)) return // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". operatingUser.forgetFailures() let loadingTimeout = setTimeout(() => { diff --git a/frontend/src/lib/components/triggers/scheduled/utils.ts b/frontend/src/lib/components/triggers/scheduled/utils.ts index a0a1b8daad..064116a194 100644 --- a/frontend/src/lib/components/triggers/scheduled/utils.ts +++ b/frontend/src/lib/components/triggers/scheduled/utils.ts @@ -1,33 +1,33 @@ -import { JobService, ScheduleService } from "$lib/gen" -import { sendUserToast } from "$lib/utils" +import { JobService, ScheduleService } from '$lib/gen' +import { sendUserToast } from '$lib/utils' export async function runScheduleNow( - path: string, - schedulePath: string, - isFlow: boolean, - workspace_id: string + path: string, + schedulePath: string, + isFlow: boolean, + workspace_id: string ): Promise { - try { - const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath - const args = ( - await ScheduleService.getSchedule({ - workspace: workspace_id, - path: schedulePath - }) - ).args - const run = await runByPath({ - path, - requestBody: args ?? {}, - workspace: workspace_id - }) + try { + const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath + const { args, tag } = await ScheduleService.getSchedule({ + workspace: workspace_id, + path: schedulePath + }) + const run = await runByPath({ + path, + requestBody: args ?? {}, + workspace: workspace_id, + // scheduled flows run on the flow's own tag, like the cron path + tag: (!isFlow && tag) || undefined + }) - sendUserToast(`Schedule ${path} will run now`, false, [ - { - label: 'Go to the run page', - callback: () => window.open('/run/' + run + '?workspace=' + workspace_id, '_blank') - } - ]) - } catch (err) { - sendUserToast(`Cannot run schedule now: ${err.body}`, true) - } -} \ No newline at end of file + sendUserToast(`Schedule ${path} will run now`, false, [ + { + label: 'Go to the run page', + callback: () => window.open('/run/' + run + '?workspace=' + workspace_id, '_blank') + } + ]) + } catch (err) { + sendUserToast(`Cannot run schedule now: ${err.body}`, true) + } +} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index a6a6b5e7a8..c4a5724ecf 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -2,7 +2,6 @@ import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common' import { clearPageDrawerAnchor, - handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths' @@ -14,8 +13,11 @@ import LabelsInput from '$lib/components/LabelsInput.svelte' import Required from '$lib/components/Required.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' + import { loadSchema } from '$lib/infer' import PipelineLockedRunnableInfo from '$lib/components/triggers/PipelineLockedRunnableInfo.svelte' - import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte' + import ErrorOrRecoveryHandler, { + handlerFullPath + } from '$lib/components/ErrorOrRecoveryHandler.svelte' import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import Dropdown from '$lib/components/DropdownV2.svelte' @@ -111,7 +113,7 @@ // already-bound script. We swap the runnable ScriptPicker for a read-only // viewer so the trigger can't be silently reassigned off the pipeline. let fixedScriptPath = $state('') - let runnable: Script | Flow | undefined = $state() + let runnable: Pick + + + +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.variables} + +{:else} + + + {#if showCreateButtons} +
    + {#if tab == 'contextual' && (operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)} + + {:else} + + {/if} +
    + {/if} +
    + (showCreateButtons = v)} /> + + + + { + loadVariables() + }} + /> + +
    + + + + {#snippet extra()} + + Contextual variables are passed as environment variables when running a script and + depends on the execution context. + + {/snippet} + + + {#if tab == 'workspace'} + + {/if} +
    + + {#if tab == 'workspace'} +
    + {#if !filteredItems} + + {#each new Array(3) as _} + + {/each} + {:else if filteredItems.length == 0} + {#if activeFilters} + + {:else} + variableEditor?.initNew(), + aiId: 'variables-empty-add-variable', + aiDescription: 'Add variable' + } + : undefined} + /> + {/if} + {:else} + + +
    + + Path + Value + Description + Status + Actions + + + + {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft} + + + + + +
    + { + handledHash = `#${path}` + editVariable(path) + }} + href="#{path}" + > + {path}{hasDraft ? '*' : ''} + + + {#if labels?.length} + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} + {/if} + +
    +
    + + +
    + {#if value} + {truncate(value, 20)} + {:else} + ∗∗∗∗ + {/if} +
    + {#if is_secret} + + + {#snippet text()} + This item is secret + {/snippet} + + {/if} +
    +
    + + {truncate(description ?? '', 50)} + + + +
    + {#if is_linked} + + + {#snippet text()} +
    + This variable is linked with a resource of the same path. They are + deleted and renamed together. +
    + {/snippet} +
    + {/if} + {#if account} + + + {#snippet text()} +
    + This OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
    + {/snippet} +
    + {/if} + + {#if is_refreshed} +
    + {#if refresh_error} + + +
    + + +
    + + {#snippet text()} +
    + Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
    + {/snippet} +
    + {:else if is_expired} + + + {#snippet text()} +
    + The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
    + {/snippet} +
    + {:else} + + + {#snippet text()} +
    + The variable was connected through OAuth and the token is not + expired. +
    + {/snippet} +
    + {/if} +
    + {/if} +
    +
    + + { + let owner = isOwner(path, operatingUser.current, $operatingWorkspace) + return [ + { + displayName: 'Edit', + icon: Pen, + action: () => editVariable(path), + disabled: !canWrite || !showCreateButtons + }, + { + displayName: 'Delete', + icon: Trash, + type: 'delete', + action: (event) => { + if (event['shiftKey']) { + deleteVariable(path, account) + } else { + deleteIsLinked = is_linked ?? false + deleteConfirmedCallback = () => { + deleteVariable(path, account) + } + } + }, + disabled: !owner || !showCreateButtons + }, + ...(!ws_specific && + isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'variable') + } + } + ] + : []), + { + displayName: 'Permissions', + action: () => { + shareModal?.openDrawer(path, 'variable') + }, + icon: Shield + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RefreshCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $operatingWorkspace ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadVariables() + } + } + ] + : []) + ] + }} + /> + +
    + {/each} + + + {/if} + + {:else if tab == 'contextual'} +
    + {#if loading.contextual} + + {#each new Array(8) as _} + + {/each} + {:else} + + {#if contextualVariables.filter((x) => x.is_custom).length === 0} + contextualVariableEditor?.initNew(), + aiId: 'variables-empty-add-contextual-variable', + aiDescription: 'Add contextual variable' + } + : undefined} + /> + {:else} + x.is_custom)} + keys={['name', 'value']} + getRowActions={operatingUser.current?.is_admin || + operatingUser.current?.is_super_admin + ? (row) => { + return [ + { + displayName: 'Edit', + action: () => contextualVariableEditor?.editVariable(row.name, row.value) + }, + { + displayName: 'Delete', + type: 'delete', + action: () => { + deleteContextualVariable(row) + } + } + ] + } + : undefined} + /> + {/if} + + !x.is_custom)} + keys={['name', 'value', 'description']} + /> + {/if} +
    + {/if} + + +{/if} + + { + deleteConfirmedCallback = undefined + }} + on:confirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +
    + Are you sure you want to remove this variable? + {#if deleteIsLinked} + + This variable is linked with a resource of the same path. The linked resource will also be + deleted. + + {/if} + +
    + You can press + SHIFT + while removing a variable to bypass confirmation. +
    +
    +
    +
    diff --git a/frontend/src/lib/components/variables/variablesFilter.ts b/frontend/src/lib/components/variables/variablesFilter.ts index 5f7ce1e51a..d609182da0 100644 --- a/frontend/src/lib/components/variables/variablesFilter.ts +++ b/frontend/src/lib/components/variables/variablesFilter.ts @@ -64,15 +64,14 @@ export function buildVariablesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only variables in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only variables in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 3957f47b74..05159e3057 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -21,6 +21,7 @@ getKnownModelContextWindow, getModelContextWindowFromTable } from '../copilot/modelConfig' + import { resolveRequestReasoning } from '../copilot/reasoningRegistry' import { supportsAutocomplete } from '../copilot/utils' import TestAiKey from '../copilot/TestAIKey.svelte' import Label from '../Label.svelte' @@ -650,7 +651,8 @@ min={1} max={2_000_000} getDefault={(provider, model) => ({ - tokens: getModelMaxTokens(provider, model), + // What a chat sends at the default effort; the budget depends on it. + tokens: getModelMaxTokens(provider, model, resolveRequestReasoning({ provider, model })), assumed: false })} /> diff --git a/frontend/src/lib/components/workspaceSettings/FilterList.svelte b/frontend/src/lib/components/workspaceSettings/FilterList.svelte index fccc59681d..599015aa49 100644 --- a/frontend/src/lib/components/workspaceSettings/FilterList.svelte +++ b/frontend/src/lib/components/workspaceSettings/FilterList.svelte @@ -1,5 +1,6 @@ @@ -33,34 +26,14 @@ {/if} -
    - {#each items as item, idx (item)} - - {item} - - - {/each} - - (newItem = e.currentTarget.value)} - onkeydown={(e) => e.key === 'Enter' && (addItem(), e.preventDefault())} - /> - -
    + diff --git a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte index dcbef04a51..87409a8134 100644 --- a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte @@ -1,13 +1,10 @@ -
    - -
    +
    +
    - - Git Sync filter settings +

    {title}

    {#if isLegacyRepo} This repository uses legacy configuration format and inherits settings from @@ -142,22 +136,14 @@ {/if}
    -
    - {#if !collapsed} {#if isEditable} -
    -
    +
    +
    @@ -337,14 +323,9 @@
    -
    -
    - {isInitialSetup ? 'Configure initial sync settings' : 'Review migration settings'} -
    -
    {:else} -
    +
    @@ -454,5 +435,4 @@ git push
    {/if} - {/if}
    diff --git a/frontend/src/lib/instanceUi.ts b/frontend/src/lib/instanceUi.ts new file mode 100644 index 0000000000..b9b87d139c --- /dev/null +++ b/frontend/src/lib/instanceUi.ts @@ -0,0 +1,9 @@ +import { writable } from 'svelte/store' +import type { GetInstanceUiResponse } from '$lib/gen' + +/** + * The last `/settings/instance_ui` response: the announcement banner and accent color. + * `undefined` until one lands, and left as is when a fetch fails, so a transient error + * never retracts an announcement still in force. Written only by `InstanceUiSync`. + */ +export const instanceUi = writable(undefined) diff --git a/frontend/src/lib/logout.test.ts b/frontend/src/lib/logout.test.ts new file mode 100644 index 0000000000..160bce9934 --- /dev/null +++ b/frontend/src/lib/logout.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('./onboardingProfile', () => ({ noteSessionEmail: vi.fn() })) +vi.mock('./components/sidebar/accountSetup.svelte', () => ({ + accountSetup: { reset: vi.fn() } +})) +vi.mock('./storeUtils', () => ({ clearStores: vi.fn() })) +vi.mock('$lib/gen', () => ({ UserService: { logout: vi.fn(async () => {}) } })) + +import { clearUser } from './logout' + +describe('clearUser', () => { + beforeEach(() => { + sessionStorage.clear() + vi.clearAllMocks() + }) + + // Logging out while impersonating must not leave the impersonator's own token behind. + it('drops the impersonation token backup', async () => { + sessionStorage.setItem('pre_impersonation_token', 'a-token') + sessionStorage.setItem('pre_impersonation_email', 'admin@windmill.dev') + + await clearUser() + + expect(sessionStorage.getItem('pre_impersonation_token')).toBeNull() + expect(sessionStorage.getItem('pre_impersonation_email')).toBeNull() + }) +}) diff --git a/frontend/src/lib/logout.ts b/frontend/src/lib/logout.ts index 1c0915dfa9..58fce16fe3 100644 --- a/frontend/src/lib/logout.ts +++ b/frontend/src/lib/logout.ts @@ -6,11 +6,24 @@ import { clearStores } from './storeUtils' // Note: logout and logoutWithRedirect have been moved to logoutKit.ts // as they depend on SvelteKit navigation +// The impersonation backup holds the impersonator's own auth token, so it must go on +// logout too, not only when impersonation is ended from the banner — otherwise a usable +// token stays readable for the lifetime of the tab. +function clearImpersonationFromStorage(): void { + try { + sessionStorage.removeItem('pre_impersonation_token') + sessionStorage.removeItem('pre_impersonation_email') + } catch (e) { + console.error('error interacting with session storage', e) + } +} + export async function clearUser() { try { noteSessionEmail(undefined) accountSetup.reset() clearStores() + clearImpersonationFromStorage() await UserService.logout() } catch (error) {} } diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index a9e2b9f3b2..2197998857 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -708,6 +708,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -764,6 +768,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -1031,10 +1039,12 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "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')" }, "path": { "type": "string" @@ -1087,10 +1097,12 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "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')" }, "tag": { "type": "string" diff --git a/frontend/src/lib/remoteDeploy.test.ts b/frontend/src/lib/remoteDeploy.test.ts new file mode 100644 index 0000000000..7064dc83b6 --- /dev/null +++ b/frontend/src/lib/remoteDeploy.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { PENDING_TTL_MS, remoteDeployAuthorizeUrl, takePendingConnect } from './remoteDeploy' + +const target = { base_url: 'https://prod.example.com', workspace_id: 'prod' } + +function startConnect(workspace = 'dev'): string { + const url = new URL(remoteDeployAuthorizeUrl(target, workspace, '/deploy/flow/f/a')) + return url.searchParams.get('state')! +} + +describe('remote deploy connect state', () => { + beforeEach(() => { + localStorage.clear() + vi.stubGlobal('window', { location: { origin: 'https://dev.example.com' } }) + }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + // The state is what keeps any other page from planting a token as the user's. + it('matches only the state it issued, and only once', () => { + const state = startConnect() + expect(takePendingConnect('forged')).toBeUndefined() + expect(takePendingConnect(state)).toMatchObject({ workspace: 'dev', target }) + expect(takePendingConnect(state)).toBeUndefined() + }) + + it('keeps concurrent attempts apart', () => { + const first = startConnect('dev') + const second = startConnect('staging') + expect(takePendingConnect(first)?.workspace).toBe('dev') + expect(takePendingConnect(second)?.workspace).toBe('staging') + }) + + it('expires an attempt left unfinished, and drops it from storage', () => { + vi.useFakeTimers() + const abandoned = startConnect() + vi.advanceTimersByTime(PENDING_TTL_MS + 60_000) + startConnect() + expect(localStorage.length).toBe(1) + expect(takePendingConnect(abandoned)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/remoteDeploy.ts b/frontend/src/lib/remoteDeploy.ts new file mode 100644 index 0000000000..ad887504fb --- /dev/null +++ b/frontend/src/lib/remoteDeploy.ts @@ -0,0 +1,101 @@ +import { base } from '$lib/base' +import type { RemoteDeployConnection, RemoteDeployTarget } from '$lib/gen' +import { randomSecret } from '$lib/utils/uuid' + +/** + * The workspace argument that aims a generated-client call at `workspace`'s deploy target on + * another instance. The client fills `{workspace}` with `encodeURI`, which keeps the slashes, so + * `/w/{workspace}/flows/create` reaches `/w//remote_deploy/proxy//flows/create`, + * which the backend forwards to the remote workspace with the caller's token for it. + */ +export function remoteDeployWorkspace( + workspace: string, + connection: RemoteDeployConnection +): string { + return `${workspace}/remote_deploy/proxy/${connection.proxy_key}` +} + +export function remoteDeployLabel(target: RemoteDeployTarget): string { + return `${target.workspace_id} on ${remoteHost(target.base_url)}` +} + +export function remoteHost(url: string): string { + try { + return new URL(url).host + } catch { + return url + } +} + +// Connecting by signing in on the remote: the drawer opens the remote's authorize page, which mints +// a token and sends it back in the fragment of `CALLBACK_PATH`; that page stores it through +// `connect` and tells the drawer on `CHANNEL`. + +/** The path the remote's authorize page only ever sends a token to. */ +export const CALLBACK_PATH = '/remote_deploy/callback' +export const CHANNEL = 'windmill-remote-deploy' +const PENDING_PREFIX = 'remote-deploy-connect:' +export const PENDING_TTL_MS = 15 * 60_000 + +type PendingConnect = { + workspace: string + /** Sent back with the token, so it only reaches the instance it came from. */ + target: RemoteDeployTarget + returnTo: string + at: number +} + +export type RemoteDeployEvent = + | { type: 'connected'; workspace: string } + | { type: 'failed'; workspace: string; error: string } + +/** + * The remote authorize URL for connecting `workspace`. Records a `state` for the callback to + * check: without it, any page could send this instance a token of its choosing to store as the + * user's, and their deploys would land on the remote as someone else. `localStorage` rather than + * `sessionStorage`, because the callback runs in the window the remote sends back to, which is + * not always this one; one entry per attempt, so two tabs connecting at once do not cancel out. + */ +export function remoteDeployAuthorizeUrl( + target: RemoteDeployTarget, + workspace: string, + returnTo: string +): string { + dropExpiredConnects() + const state = randomSecret(16) + const pending: PendingConnect = { workspace, target, returnTo, at: Date.now() } + localStorage.setItem(PENDING_PREFIX + state, JSON.stringify(pending)) + const params = new URLSearchParams({ + workspace: target.workspace_id, + callback: `${window.location.origin}${base}${CALLBACK_PATH}`, + state + }) + return `${target.base_url}/user/remote_deploy_authorize?${params}` +} + +/** An attempt abandoned before its callback would otherwise stay in storage for good. */ +function dropExpiredConnects() { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i) + if (!key?.startsWith(PENDING_PREFIX)) continue + let at = 0 + try { + at = JSON.parse(localStorage.getItem(key) ?? '{}').at ?? 0 + } catch {} + if (Date.now() - at > PENDING_TTL_MS) localStorage.removeItem(key) + } +} + +/** The attempt `state` names, used once: a replayed callback finds nothing to match. */ +export function takePendingConnect(state: string): PendingConnect | undefined { + const key = PENDING_PREFIX + state + let pending: PendingConnect | undefined + try { + pending = JSON.parse(localStorage.getItem(key) ?? 'null') ?? undefined + } catch {} + localStorage.removeItem(key) + if (!pending || Date.now() - pending.at > PENDING_TTL_MS) { + return undefined + } + return pending +} diff --git a/frontend/src/lib/svelte5UtilsKit.svelte.ts b/frontend/src/lib/svelte5UtilsKit.svelte.ts index 7b6420ab7e..d77b96df06 100644 --- a/frontend/src/lib/svelte5UtilsKit.svelte.ts +++ b/frontend/src/lib/svelte5UtilsKit.svelte.ts @@ -3,6 +3,8 @@ import { z } from 'zod' import { serializeParam } from '$lib/utils/serializeParam' +import { untrack } from 'svelte' +import { useHostedPage } from '$lib/components/hostedPage' export type SearchParamsResult = S extends z.ZodObject @@ -58,12 +60,14 @@ function deserializeParam(raw: string, fieldSchema: z.ZodType): unknown { export function useSearchParams(schema: S): SearchParamsResult { const shape: Record = (schema as any).shape ?? {} const keys = Object.keys(shape) + const hosted = useHostedPage() + const currentSearch = () => (hosted ? hosted.search : window.location.search) // Reactive snapshot of search params - one $state cell per key const values: Record = $state( Object.fromEntries( keys.map((k) => { - const raw = new URLSearchParams(window.location.search).get(k) + const raw = new URLSearchParams(untrack(currentSearch)).get(k) const parsed = raw != null ? deserializeParam(raw, shape[k]) : null return [k, parsed] }) @@ -71,7 +75,7 @@ export function useSearchParams(schema: S): SearchParamsRes ) function syncFromUrl() { - const sp = new URLSearchParams(window.location.search) + const sp = new URLSearchParams(currentSearch()) for (const k of keys) { const raw = sp.get(k) const parsed = raw != null ? deserializeParam(raw, shape[k]) : null @@ -79,8 +83,13 @@ export function useSearchParams(schema: S): SearchParamsRes } } - // Keep in sync when the user navigates back/forward + // Keep in sync when the user navigates back/forward, or the host re-points the page $effect(() => { + if (hosted) { + hosted.search + untrack(syncFromUrl) + return + } window.addEventListener('popstate', syncFromUrl) return () => window.removeEventListener('popstate', syncFromUrl) }) @@ -94,12 +103,16 @@ export function useSearchParams(schema: S): SearchParamsRes }, set(v: unknown) { ;(values as any)[key] = v - const sp = new URLSearchParams(window.location.search) + const sp = new URLSearchParams(untrack(currentSearch)) if (v == null) { sp.delete(key) } else { sp.set(key, serializeParam(v)) } + if (hosted) { + hosted.setSearch(sp.size ? `?${sp}` : '') + return + } const hash = window.location.hash const newUrl = sp.toString() ? `${window.location.pathname}?${sp}${hash}` diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0f9eb78da4..b90751b9d3 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1677,6 +1677,8 @@ export type Item = { iconColor?: string /** Extra props for `icon`, for an icon that does not take lucide's `size`. */ iconProps?: Record + customSubmenu?: import('svelte').Component + customSubmenuProps?: Record href?: string hrefTarget?: '_blank' | '_self' | '_parent' | '_top' disabled?: boolean diff --git a/frontend/src/lib/components/copilot/chat/safeHref.test.ts b/frontend/src/lib/utils/safeHref.test.ts similarity index 100% rename from frontend/src/lib/components/copilot/chat/safeHref.test.ts rename to frontend/src/lib/utils/safeHref.test.ts diff --git a/frontend/src/lib/components/copilot/chat/safeHref.ts b/frontend/src/lib/utils/safeHref.ts similarity index 57% rename from frontend/src/lib/components/copilot/chat/safeHref.ts rename to frontend/src/lib/utils/safeHref.ts index 3acdabb464..1d583fa2f7 100644 --- a/frontend/src/lib/components/copilot/chat/safeHref.ts +++ b/frontend/src/lib/utils/safeHref.ts @@ -1,10 +1,8 @@ const SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:'] /** - * The href a rendered markdown link may carry, or undefined for one that must be dropped. - * - * Markdown reaches the chat renderers from a model, and from another member for a shared - * artifact, and `svelte-exmarkdown` passes `javascript:` and `data:` hrefs through untouched. + * The href a link built from untrusted content may carry, or undefined for one that must be + * dropped: `javascript:`, `data:` and any other non-navigating scheme. * Relative links resolve against `base` (the page), so they stay. */ export function safeHref(href: string | undefined, base: string): string | undefined { diff --git a/frontend/src/lib/utils_deployable.ts b/frontend/src/lib/utils_deployable.ts index 7d517c3bb4..63ea88adcb 100644 --- a/frontend/src/lib/utils_deployable.ts +++ b/frontend/src/lib/utils_deployable.ts @@ -151,7 +151,10 @@ export async function getTriggersDeployData( kind: TriggerKind, path: string, workspace: string, - onBehalfOf?: string + onBehalfOf?: string, + /** Root URL of the instance the trigger is deployed to, when that is not this one. A GCP or + * Azure push subscription posts to the instance serving the trigger, so it has to name that one. */ + targetBaseUrl?: string ) { const preservePermissionedAs = onBehalfOf !== undefined @@ -247,7 +250,9 @@ export async function getTriggersDeployData( ...gcpTrigger, delivery_config: gcpTrigger.delivery_config ?? undefined, base_endpoint: - gcpTrigger.delivery_type === 'push' ? `${window.location.origin}${base}` : undefined, + gcpTrigger.delivery_type === 'push' + ? (targetBaseUrl ?? `${window.location.origin}${base}`) + : undefined, permissioned_as: onBehalfOf, preserve_permissioned_as: preservePermissionedAs } @@ -311,6 +316,11 @@ export async function getTriggersDeployData( return { data: { ...azureTrigger, + // Not stored on the trigger, but a push subscription is created from it. + base_endpoint: + azureTrigger.azure_mode === 'namespace_pull' + ? undefined + : (targetBaseUrl ?? `${window.location.origin}${base}`), permissioned_as: onBehalfOf, preserve_permissioned_as: preservePermissionedAs }, diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index 228e32fc06..d56c1f6541 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -362,6 +362,11 @@ export interface DeployItemParams { * between the two probes. The result then carries `conflict`. */ createOnly?: boolean + /** + * Root URL of the instance `workspaceTo` lives on, when the deploy crosses instances. Only what + * a payload must state about where it landed needs it — a GCP or Azure push trigger's endpoint. + */ + targetBaseUrl?: string } /** @@ -380,7 +385,8 @@ export async function deployItem( additionalInformation, onBehalfOf, onBehalfOfPrincipal, - createOnly + createOnly, + targetBaseUrl } = params if (kind === 'trigger') { @@ -399,7 +405,8 @@ export async function deployItem( additionalInformation.triggers.kind, path, workspaceFrom, - onBehalfOf + onBehalfOf, + targetBaseUrl ) if (alreadyExists) { // Strip operational state so the update doesn't flip the target's @@ -796,33 +803,25 @@ export function deployPermissionForKinds( } /** - * Whether the current user may deploy into `workspace`. Mirrors `check_deploy_rules` in + * The workspace's deploy-protection rules alone. Mirrors `check_deploy_rules` in * windmill-common so the UI can disable the action with a reason instead of letting the * click 403: `DisableDirectDeployment` is evaluated before `RestrictDeployToDeployers`, so * the same message wins here as on the server when both block; admins and superadmins bypass * both rules, while `wm_deployers` members bypass only the latter. * - * The operator refusal is not part of that mirror. The server refuses operators in the item - * handlers instead, and for fewer kinds, so refusing them for everything here is deliberately - * stricter than the server rather than a faithful copy of it. + * Separate from `checkDeployPermission`, which adds a blanket operator refusal: the server + * refuses operators per kind, in the item handlers, not in these rules. * * Fails open on any error — the server still enforces on the actual deploy. - * Shared by the session dock and the compare page so both gate identically. */ -export async function checkDeployPermission( +export async function checkDeployRules( workspace: string, - /** Pre-fetched `whoami` for `workspace`, to save a round trip when the caller already has one. */ - whoami?: User + /** Pre-fetched identity for `workspace`, to save a round trip. Narrowed to the fields + * read so a caller holding a `UserExt` can pass it without a cast. */ + whoami?: Pick ): Promise { try { const me = whoami ?? (await UserService.whoami({ workspace })) - if (me.operator) { - return { - ok: false, - reason: "You're an operator in this workspace — operators can't deploy", - refusedBy: 'operator' - } - } const userInfo = { is_admin: !!me.is_admin, is_super_admin: !!me.is_super_admin, @@ -870,6 +869,36 @@ export async function checkDeployPermission( } } +/** + * Whether the current user may deploy into `workspace` at all: the protection rules above, + * plus a refusal of every operator. + * + * That refusal is not part of the `check_deploy_rules` mirror. The server refuses operators + * in the item handlers instead, and for fewer kinds, so refusing them for everything here is + * deliberately stricter than the server rather than a faithful copy of it. A caller mirroring + * one handler wants `checkDeployRules`. + * + * Shared by the session dock and the compare page so both gate identically. + */ +export async function checkDeployPermission( + workspace: string, + whoami?: Pick +): Promise { + try { + const me = whoami ?? (await UserService.whoami({ workspace })) + if (me.operator) { + return { + ok: false, + reason: "You're an operator in this workspace — operators can't deploy", + refusedBy: 'operator' + } + } + return await checkDeployRules(workspace, me) + } catch { + return { ok: true } + } +} + /** * Whether `me` may write at `path` in `workspace` — the per-item half of the deploy gate, which * `checkDeployPermission`'s workspace-level rules don't cover. Advisory only: it exists so the UI diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index bfae9bb5d2..25d2fd156f 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -81,6 +81,7 @@ import { pruneMeaninglessDrafts } from '$lib/userDraftPrune' import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' import InstanceBanner from '$lib/components/InstanceBanner.svelte' + import InstanceUiSync from '$lib/components/InstanceUiSync.svelte' import { onDestroy, setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -90,7 +91,11 @@ import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' import { copilotInfo } from '$lib/aiStore' - import { parsePreviewItemRoute } from '$lib/components/sessions/previewPaths' + import { + isPageItemListPath, + parsePreviewItemRoute, + stripBase + } from '$lib/components/sessions/previewPaths' import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte' import { sessionState } from '$lib/components/sessions/sessionState.svelte' import { restoreSessionBackups } from '$lib/components/sessions/sessionMirror.svelte' @@ -431,6 +436,21 @@ } catch {} return } + // A list page mounts in process too, where its rows open as tabs of their own. + const listUrl = navigation.to?.url + if (listUrl && isPageItemListPath(stripBase(listUrl.pathname))) { + navigation.cancel() + const u = new URL(listUrl.href) + u.searchParams.delete('nomenubar') + u.searchParams.delete('workspace') + try { + window.parent.postMessage( + { type: 'wm.session.openList', href: u.pathname + u.search + u.hash }, + window.location.origin + ) + } catch {} + return + } const runTarget = previewRunTarget(navigation.to?.url) if (runTarget) { navigation.cancel() @@ -1418,11 +1438,13 @@
    {/if}
    + + {#if $enterpriseLicense && !menuHidden} - + {/if} {#if $userStore?.is_service_account} diff --git a/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte index 360defa995..8a21a3a61f 100644 --- a/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte @@ -1,570 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - AMQP triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - amqpTriggerEditor?.openNew(false), - aiId: 'amqp-triggers-empty-add', - aiDescription: 'Add AMQP trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_amqp', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - amqpTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - AMQP trigger is starting... - {:else} - AMQP trigger is not connected{error ? ': ' + error : ''} - {/if} - {:else} - AMQP trigger was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - AMQP trigger is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'amqp', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - amqpTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'amqp' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'amqp_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await AmqpTriggerService.deleteAmqpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte index 11afa9f94b..2193288ccf 100644 --- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte @@ -1,668 +1,5 @@ - { - deleteAzureTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deleteAzureTriggerCallback) { - isDeleting = true - if (deleteSubscription && subscriptionDeletePayload) { - try { - const msg = await AzureTriggerService.deleteAzureSubscription({ - workspace: $workspaceStore ?? '', - path: subscriptionDeletePayload.azure_resource_path, - requestBody: { - azure_mode: subscriptionDeletePayload.azure_mode, - scope_resource_id: subscriptionDeletePayload.scope_resource_id, - topic_name: subscriptionDeletePayload.topic_name, - subscription_name: subscriptionDeletePayload.subscription_name - } - }) - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - await deleteAzureTriggerCallback() - } - deleteAzureTriggerCallback = undefined - subscriptionToDelete = undefined - subscriptionDeletePayload = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - {#if subscriptionToDelete} - - {/if} -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - Azure Event Grid triggers are disabled in the multi-tenant cloud. - - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - azureTriggerEditor?.openNew(false), - aiId: 'azure-triggers-empty-add', - aiDescription: 'Add Azure Event Grid trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, workspace_id, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_azure', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - {@const is_pull = azure_mode === 'namespace_pull'} - {@const topic_label = topic_name - ? topic_name - : (scope_resource_id?.split('/')?.pop() ?? '')} - -
    -
    - - - azureTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path} - {topic_label} ({azure_mode}, {subscription_name}){hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - - {#if is_pull} -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Azure Event Grid trigger is starting... - {:else} - Could not connect to Azure Event Grid{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - Connected to Azure Event Grid{!server_id - ? ' (shutting down...)' - : ''}
    - {/snippet} -
    - {/if} -
    - {/if} - -
    - - {#if is_pull} - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'azure', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> - {/if} -
    - -
    - {#if !is_pull} - - {/if} - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - azureTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'azure' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'azure_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - isDeleting = false - deleteSubscription = false - subscriptionToDelete = subscription_name - subscriptionDeletePayload = { - azure_mode, - scope_resource_id, - topic_name: topic_name ?? undefined, - subscription_name, - azure_resource_path - } - deleteAzureTriggerCallback = async () => { - const message = await AzureTriggerService.deleteAzureTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(message) - loadTriggers() - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte index 87c9558dbb..8fb28777c8 100644 --- a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte @@ -1,553 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - {#if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} - -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - emailTriggerEditor?.openNew(false), - aiId: 'email-triggers-empty-add', - aiDescription: 'Add email trigger' - } - : undefined} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { workspace_id, workspaced_local_part, path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, marked, local_part, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_email', path) ?? is_draft} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const emailAddress = getEmailAddress( - local_part, - workspaced_local_part ?? false, - workspace_id, - emailDomain ?? '' - )} - -
    -
    - - - emailTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {emailAddress} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'email', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - emailTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'routes' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'email_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: - !canWrite || !($userStore?.is_admin || $userStore?.is_super_admin), - action: async () => { - try { - await EmailTriggerService.deleteEmailTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(`Successfully deleted Email trigger: ${path}`) - loadTriggers() - } catch (error) { - sendUserToast(error.body || error.message, true) - } - } - } - ]} - /> -
    -
    -
    -
    - {#if edited_by}
    edited by {edited_by}
    {/if} -
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    -
    -
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 24c7553378..3b61eb7801 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -1,654 +1,5 @@ - { - deleteGcpTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deleteGcpTriggerCallback) { - isDeleting = true - if (deleteSubscription && deleteSubscriptionCallback) { - try { - await deleteSubscriptionCallback() - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - await deleteGcpTriggerCallback() - } - deleteGcpTriggerCallback = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - GCP Pub/Sub triggers are disabled in the multi-tenant cloud. - - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - gcpTriggerEditor?.openNew(false), - aiId: 'gcp-triggers-empty-add', - aiDescription: 'Add GCP Pub/Sub trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { gcp_resource_path, project_id, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_gcp', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - gcpTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path} - {topic_id}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - - {#if delivery_type !== 'push'} -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - GCP Pub/Sub trigger is starting... - {:else} - Could not connect to GCP Pub/Sub{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - Connected to GCP Pub/Sub{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - {/if} - -
    - - {#if delivery_type !== 'push'} - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'gcp', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> - {/if} -
    - -
    - {#if delivery_type === 'push'} - - {/if} - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - gcpTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'gcp' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'gcp_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - isDeleting = false - deleteSubscription = false - subscriptionToDelete = subscription_id - currentTopic = topic_id - deleteSubscriptionCallback = async () => { - const requestBody = { subscription_id, project_id } - const message = gcp_resource_path - ? await GcpTriggerService.deleteGcpSubscription({ - workspace: $workspaceStore ?? '', - path: gcp_resource_path, - requestBody - }) - : await GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({ - workspace: $workspaceStore ?? '', - requestBody - }) - sendUserToast(message) - } - deleteGcpTriggerCallback = async () => { - const message = await GcpTriggerService.deleteGcpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(message) - loadTriggers() - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte index 6ee790818a..c4d4f904f6 100644 --- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte @@ -1,595 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - - - - {#if isCloudHosted()} - - Kafka triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - kafkaTriggerEditor?.openNew(false), - aiId: 'kafka-triggers-empty-add', - aiDescription: 'Add Kafka trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, kafka_resource_path, topics, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_kafka', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - kafkaTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {kafka_resource_path} - {topics.join(', ')} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Consumer is starting... - {:else} - Consumer is not connected{error ? ': ' + error : ''} - {/if} - {:else} - Consumer was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Consumer is connected
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'kafka', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await KafkaTriggerService.deleteKafkaTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - kafkaTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'kafka' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'kafka_trigger') - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    Edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte index 126dfda031..41c30157ff 100644 --- a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte @@ -1,570 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - MQTT triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - mqttTriggerEditor?.openNew(false), - aiId: 'mqtt-triggers-empty-add', - aiDescription: 'Add MQTT trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_mqtt', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - mqttTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - MQTT trigger is starting... - {:else} - MQTT trigger is not connected{error ? ': ' + error : ''} - {/if} - {:else} - MQTT trigger was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - MQTT trigger is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'mqtt', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - mqttTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'mqtt' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'mqtt_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await MqttTriggerService.deleteMqttTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte index 834869891a..ae96a52cfc 100644 --- a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte @@ -1,592 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - - - - {#if isCloudHosted()} - - NATS triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - natsTriggerEditor?.openNew(false), - aiId: 'nats-triggers-empty-add', - aiDescription: 'Add NATS trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, nats_resource_path, subjects, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_nats', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - natsTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {nats_resource_path} - {subjects.join(', ')} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Consumer is starting... - {:else} - Consumer is not connected{error ? ': ' + error : ''} - {/if} - {:else} - Consumer was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Consumer is connected
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'nats', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - natsTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'nats' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'nats_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await NatsTriggerService.deleteNatsTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte index d1c07a7e75..a25078abe1 100644 --- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte @@ -1,674 +1,5 @@ - { - isDeleting = false - deletePostgresTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deletePostgresTriggerCallback) { - let msg: string - isDeleting = true - if (deleteReplicationSlot && deleteReplicationSlotCallback) { - try { - msg = await deleteReplicationSlotCallback() - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - if (deletePublication && deletePublicationCallback) { - try { - msg = await deletePublicationCallback() - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - msg = await deletePostgresTriggerCallback() - sendUserToast(msg) - } - deletePostgresTriggerCallback = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - - - -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - Postgres triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - postgresTriggerEditor?.openNew(false), - aiId: 'postgres-triggers-empty-add', - aiDescription: 'Add Postgres trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { postgres_resource_path, publication_name, replication_slot_name, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_postgres', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - postgresTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - {postgres_resource_path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Postgres trigger is starting... - {:else} - Could not connect to database{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Connected to database{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'postgres', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - publicationToDelete = publication_name - replicationSlotToDelete = replication_slot_name - isDeleting = false - deletePublication = false - deleteReplicationSlot = false - deletePublicationCallback = async () => { - return await PostgresTriggerService.deletePostgresPublication({ - workspace: $workspaceStore!, - path: postgres_resource_path, - publication: publication_name - }) - } - - deleteReplicationSlotCallback = async () => { - return await PostgresTriggerService.deletePostgresReplicationSlot({ - workspace: $workspaceStore!, - path: postgres_resource_path, - requestBody: { - name: replication_slot_name - } - }) - } - - deletePostgresTriggerCallback = async () => { - const msg = await PostgresTriggerService.deletePostgresTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - return msg - } - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - postgresTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'postgres' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'postgres_trigger') - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte new file mode 100644 index 0000000000..c702f66471 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte @@ -0,0 +1,74 @@ + + + + {#if status === 'connecting'} +
    + {:else if status === 'connected'} +

    Connected. You can close this tab.

    + {:else} + {error} + {#if returnTo} +
    + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index bf62818170..8973bf83b4 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -1,1588 +1,5 @@ - { - deleteConfirmedCallback = undefined - }} - on:confirmed={() => { - if (deleteConfirmedCallback) { - deleteConfirmedCallback() - } - deleteConfirmedCallback = undefined - }} -> -
    - Are you sure you want to remove {deletePath}? - {#if deleteIsLinked} - - This resource is linked with a variable of the same path. That variable is deleted with it, - unless another resource still references it. - - {/if} - -
    - You can press - SHIFT - while removing a resource to bypass confirmation. -
    -
    -
    -
    - - - - - inferrer?.toggleDrawer?.()}> - - {#snippet actions()} - - {/snippet} - - - - - - {#snippet titleExtra()} - - {/snippet} -
    - {#if resourceTypeViewerObj.description} - - {/if} - {#if resourceTypeViewerObj.isFileset} - - This resource type represents a collection of files. Each file is identified by its - relative path and contains text content. In the CLI, filesets are stored as directories. - - {:else if resourceTypeViewerObj.formatExtension} - - This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension} - extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension}) - - {:else} - - {/if} -
    -
    -
    - - - - {#snippet actions()} - - {/snippet} -
    - - -
    - {#if editResourceType.isFileset} - - This resource type represents a collection of files. The schema cannot be edited. - - {:else if editResourceType.formatExtension} - - This resource type represents a plain text file with a .{editResourceType.formatExtension} - extension. (e.g. my_file.{editResourceType.formatExtension}). The schema only - contains a `content` field and thus cannot be edited. - - {:else} -
    Schema
    -
    - -
    - {/if} -
    -
    -
    -
    - - - - {#snippet actions()} - - {/snippet} -
    - - -
    -
    -
    Schema
    -
    - -
    -
    - -
    - -
    -
    -
    -
    -
    - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources} - -{:else} - - - {#if showCreateButtons} -
    - - -
    - {/if} -
    - (showCreateButtons = v)} /> -
    - { - if (e.detail == 'cache') { - loading.resources = true - loadCache() - } else if (e.detail == 'states') { - loading.resources = true - loadState() - } else if (e.detail == 'theme') { - loading.resources = true - loadTheme() - } - }} - > - - - {#snippet extra()} - - Every resource has a Resource Type attached to it which contains its schema and make - it easy in scripts and flows to accept only resources of a specific resource type. - - {/snippet} - - - {#snippet extra()} - - States are actually resources (but excluded from the Workspace tab for clarity). - States are used by scripts to keep data persistent between runs of the same script by - the same trigger (schedule or user) - - {/snippet} - - - {#snippet extra()} - - Cached results are actually resources (but excluded from the Workspace tab for - clarity). Cache are used by flows's step to cache result to avoid recomputing - unnecessarily - - {/snippet} - - - {#snippet extra()} - - Themes are actually resources (but excluded from the Workspace tab for clarity). They - are CSS for the legacy low-code app editor only, and are added from the CSS panel of - an app rather than from this page. - - {/snippet} - - -
    -
    -
    - - {#if showTable} -
    - {#if loading.resources} - - {#each new Array(6) as _} - - {/each} - {:else if filteredItems?.length == 0} - {#if activeFilters} - - {:else} - appConnect?.open?.(), - aiId: 'resources-empty-add-resource', - aiDescription: 'Add resource' - } - : undefined} - /> - {/if} - {:else} - - - - - Path - Resource type - Description - Status - Actions - - -
    - {#if filteredItems} - {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft} - - - - - -
    - { - handledHash = `#/resource/${path}` - openResourceEditor(path, resource_type) - }} - >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} - - {#if labels?.length} -
    - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} -
    - {/if} - -
    -
    - - { - const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) - if (linkedRt) { - resourceTypeViewerObj = { - rt: linkedRt.name, - //@ts-ignore - schema: linkedRt.schema, - description: linkedRt.description ?? '', - formatExtension: linkedRt.format_extension, - isFileset: linkedRt.is_fileset ?? false - } - resourceTypeViewer?.openDrawer?.() - } else { - sendUserToast( - `Resource type ${resource_type} not found in workspace.`, - true - ) - } - }} - > - - - - -
    - - {removeMarkdown(truncate(description ?? '', 200))} - -
    -
    - -
    -
    - {#if is_linked} - - - {#snippet text()} -
    - This resource is linked with a variable of the same path. They - are deleted and renamed together. -
    - {/snippet} -
    - {/if} -
    -
    - {#if is_refreshed} - - - {#snippet text()} -
    - The OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
    - {/snippet} -
    - {/if} -
    - - {#if is_oauth} -
    - {#if refresh_error} - - - {#snippet text()} -
    - Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
    - {/snippet} -
    - {:else if is_expired} - - - - {#snippet text()} -
    - The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
    - {/snippet} -
    - {:else} - - - {#snippet text()} -
    - The resource was connected through OAuth and the token is not - expired. -
    - {/snippet} -
    - {/if} -
    - {/if} -
    -
    - -
    - {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} - - {/if} - { - shareModal?.openDrawer?.(path, 'resource') - } - }, - { - displayName: 'Edit', - icon: Pen, - disabled: !canWrite || !showCreateButtons, - action: () => { - openResourceEditor(path, resource_type) - } - }, - // The agent form covers an agent's configuration, not everything a - // resource carries: the workspace-specific toggle in particular is - // only in the generic editor. JSON rather than that editor's form, - // which would render the configuration field by field and write a - // default into every one the agent leaves unset. Both write the same - // draft row, so this is a second view of the same edits. - ...(resource_type === 'ai_agent' - ? [ - { - displayName: 'Edit as JSON', - icon: Braces, - disabled: !canWrite || !showCreateButtons, - action: () => { - // The drawer anchors itself in the hash, which the deep-link - // effect would then read and route back to the agent editor. - // Claim it first, as the row's own link does. - handledHash = `#/resource/${path}` - resourceEditor?.initEdit?.(path, { json: true }) - } - } - ] - : []), - ...(!ws_specific && isDeployable('resource', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'resource') - } - } - ] - : []), - { - displayName: 'Delete', - disabled: !canWrite || !showCreateButtons, - icon: Trash, - type: 'delete', - action: (event) => { - // TODO - // @ts-ignore - if (event?.shiftKey) { - deleteResource(path, account) - } else { - deleteIsLinked = is_linked ?? false - deletePath = path - deleteConfirmedCallback = () => { - deleteResource(path, account) - } - } - } - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RotateCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadResources() - } - } - ] - : []) - ]} - /> -
    -
    - {/each} - {/if} -
    - - {/if} - - {:else if tab == 'types'} - {#if loading.types} - - {#each new Array(6) as _} - - {/each} - {:else if filteredResourceTypes?.length == 0} -
    - -
    - {:else} -
    - - - - Name - Description - Actions - - -
    - {#if filteredResourceTypes} - {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} - - - { - resourceTypeViewerObj = { - rt: name, - //@ts-ignore - schema: schema, - description: description ?? '', - formatExtension: format_extension, - isFileset: is_fileset ?? false - } - - resourceTypeViewer?.openDrawer?.() - }} - > - - - - - -
    - - {removeMarkdown(truncate(description ?? '', 200))} - -
    -
    - - {#if !canWrite} - -
    - - Shared globally - - This resource type is from the 'admins' workspace shared with all - workspaces - - -
    - {:else if $userStore?.is_admin || $userStore?.is_super_admin} -
    - - -
    - {:else} - - Non Editable - - Since resource types are shared with the whole workspace, only admins - can edit/delete them - - - {/if} -
    -
    - {/each} - {/if} -
    - - - {/if} - {/if} - - -{/if} - - - - - - - { - loadResources() - }} -/> - - - -{#if agentEditorTarget()} - {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }} - t.host === undefined} - onRenamed={(from, to) => { - void loadResources() - // Only while the dialog still shows the agent: closed mid-request, it already cleared the - // anchor, and writing it back would reopen the editor on refresh. - if (agentEditorTarget()?.path !== from) return - // Claimed first, as a row click does, so the deep-link effect does not reopen it. - handledHash = `#/resource/${to}` - setPageDrawerAnchor(RESOURCES_PATH, to) - }} - /> - {/await} -{/if} + diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte index 53de5457d1..a79a1a0471 100644 --- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte @@ -1,592 +1,5 @@ - - - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - -
    - - - -
    -
    -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - routeEditor?.openNew(false), - aiId: 'routes-empty-add', - aiDescription: 'Add route' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { summary, workspace_id, workspaced_route, mode, path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_http', path) ?? is_draft} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - -
    -
    - - - routeEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else if summary} - {summary} - {:else} - {http_method.toUpperCase()} - /{isCloudHosted() || workspaced_route || globalHttpWorkspacedRoute - ? workspace_id + '/' + route_path - : route_path} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - {#if static_asset_config} - file: {static_asset_config.s3} - {:else} - runnable: {script_path} - {/if} -
    -
    - - - -
    - - onToggleMode(path, mode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'http', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - routeEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'routes' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'http_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - try { - await HttpTriggerService.deleteHttpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(`Successfully deleted HTTP route: ${path}`) - loadTriggers() - } catch (error) { - sendUserToast(error.body || error.message, true) - } - } - } - ]} - /> -
    -
    -
    -
    - {#if edited_by}
    edited by {edited_by}
    {/if} -
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    -
    -
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 6e78801b8b..5fc7c1209d 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -40,8 +40,10 @@ EllipsisVertical, Share2, Globe, - Users + Users, + Play } from 'lucide-svelte' + import { forLater } from '$lib/forLater' import { isJobResolvable } from '$lib/utils' import { @@ -152,12 +154,41 @@ string, { modules: import('$lib/gen').FlowModule[]; groups?: any[] } > = $state({}) - const restart = useNestedRestartState({ + const selectedRestart = useNestedRestartState({ selectedJobStep: () => selectedJobStep, job: () => job, graphModuleStates: () => graphModuleStates, expandedSubflows: () => expandedSubflows }) + // When the selection can't be restarted from (nothing clicked, Input/Result, + // a step inside a parallel loop), the button targets the top-level step that + // failed the run, so it is visible without hunting for it in a large graph. + // A continue-on-error step keeps its `Failure` status on a run that succeeds, + // so only a failed run counts, and its last `Failure` is the one that ended it. + const failedTopLevelStep = $derived.by(() => { + if (job?.type !== 'CompletedJob' || job.success !== false || job.canceled) return undefined + const failures = job.flow_status?.modules?.filter((m) => m.type === 'Failure') ?? [] + return failures[failures.length - 1]?.id + }) + const failedRestart = useNestedRestartState({ + selectedJobStep: () => failedTopLevelStep, + job: () => job, + graphModuleStates: () => graphModuleStates, + expandedSubflows: () => expandedSubflows + }) + const selectionRestartable = $derived( + selectedJobStep !== undefined && + (selectedRestart.topLevelRestartable || selectedRestart.nestedRestartSupported) + ) + const restartStep = $derived(selectionRestartable ? selectedJobStep : failedTopLevelStep) + const restart = $derived(selectionRestartable ? selectedRestart : failedRestart) + function canRestart(state: ReturnType) { + return ( + job?.type === 'CompletedJob' && + job.job_kind === 'flow' && + (state.topLevelRestartable || state.nestedRestartSupported) + ) + } let testIsLoading = $state(false) let jobLoader: JobLoader | undefined = $state(undefined) @@ -251,6 +282,27 @@ } } + // A schedule's upcoming tick sits on a whole second and the backend refuses it; a tick + // deferred by a concurrency limit lands on a sub-second instant and can still start now. + let canRunNow = $derived( + job?.type === 'QueuedJob' && + !job.running && + !job.suspend && + !!job.scheduled_for && + forLater(job.scheduled_for) && + !(job.schedule_path && new Date(job.scheduled_for).getMilliseconds() === 0) + ) + + async function runJobNow(id: string) { + try { + await JobService.runQueuedJobNow({ workspace: $workspaceStore!, id }) + sendUserToast(`job ${id} will start as soon as a worker is available`) + getJob() + } catch (err) { + sendUserToast(`could not start job now: ${err?.body ?? err}`, true) + } + } + // Initialize view tab to logs since result is now outside tabs function initView(): void { // Result is now displayed outside tabs, so always default to logs @@ -643,6 +695,36 @@ /> {/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} + @@ -738,13 +820,13 @@ {/if} @@ -772,7 +854,7 @@ ]} > {#snippet buttonReplacement()} - {/snippet} @@ -794,7 +876,7 @@ class="h-auto" > {#snippet buttonReplacement()} - {/if} + {#if canRunNow} + + {/if} {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} {#if !forceCancel} {/if} - {#if job?.type === 'CompletedJob' && job?.job_kind === 'flow' && selectedJobStep !== undefined && (restart.topLevelRestartable || restart.nestedRestartSupported) && job.id} - { - goto('/run/' + newJobId + '?workspace=' + $workspaceStore) - }} - flowPath={job.script_path} - flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined} - disabled={!$enterpriseLicense} - enterpriseOnly={!$enterpriseLicense} - /> + {#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 showEditButton} @@ -951,7 +1025,7 @@ target: { kind: isScript ? 'script' : 'flow', path: job?.script_path ?? '' }, workspaceId: $workspaceStore ?? undefined }} - btnProps={{ unifiedSize: 'md' }} + btnProps={{ unifiedSize: 'sm' }} /> {/if} {/if} @@ -962,9 +1036,8 @@ onEditInForkClick(e, isScript ? 'script' : 'flow', job?.script_path ?? '', { hasHref: true })} - unifiedSize="md" + unifiedSize="sm" variant="default" - size="sm" startIcon={{ icon: Pen }}>{editInForkLabel($workspaceStore, $userWorkspaces)} {/if} @@ -973,7 +1046,7 @@ {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - -
    -
    - - {#snippet children({ item })} - - - - {/snippet} - - -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !schedules?.length} - {#if activeFilters} - - {:else} - scheduleEditor?.openNew(false), - aiId: 'schedules-empty-add', - aiDescription: 'Add schedule' - }} - /> - {/if} - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_schedule', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const avg_s = jobs - ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length - : undefined} - -
    -
    - - - { - handledHash = `#${path}` - scheduleEditor?.openEdit(path, is_flow) - }} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {summary || script_path}{hasDraft ? '*' : ''} -
    -
    - schedule: {path} -
    -
    - {#if labels?.length} - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} - {/if} - - - {#if paused_until && new Date(paused_until) > new Date()} -
    - Paused until {new Date(paused_until).toLocaleString()} -
    - {/if} - - - - - -
    - {#if error} - - - - - - {#snippet text()} -
    - The schedule disabled itself because there was an error scheduling the - next job: {error} -
    - {/snippet} -
    - {/if} -
    - -
    - - {#key toggleResetVersions[path] ?? 0} - { - if (canWrite) { - setScheduleEnabled(path, e.detail) - } else { - sendUserToast('not enough permission', true) - // Permission denied — bump the row's reset - // counter so the Toggle remounts back to the - // prop value. Without this, the local - // `bind:checked` flip from the user's click - // stays stuck on. - bumpToggleReset(path) - } - }} - /> - {/key} -
    -
    - - - { - goto(href) - } - }, - { - displayName: `Duplicate schedule`, - icon: Copy, - action: () => { - scheduleEditor?.openNew(is_flow, script_path, path) - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await ScheduleService.deleteSchedule({ - workspace: $workspaceStore ?? '', - path - }) - loadSchedules() - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - scheduleEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'schedules' - } - }) - } - } - ] - : []), - { - displayName: 'View runs', - icon: List, - href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` - }, - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Run now', - icon: Play, - action: () => { - runScheduleNow(script_path, path, is_flow, $workspaceStore!) - } - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'schedule') - } - } - ]} - /> -
    -
    -
    - {#if loadingSchedulesWithJobStats} -
    - - Job stats loading... -
    - {:else} -
    - {#if avg_s} -
    Avg: {(avg_s / 1000).toFixed(2)}s
    - {/if} - {#each jobs ?? [] as job} - {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} - - -
    -
    - -
    -
    -
    - {/each} -
    - {/if} -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} -
    - {nbDisplayed} items out of {items.length} - -
    - {/if} - -{/if} - - { - loadSchedules() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index e773e7603f..689afdbb9f 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -33,6 +33,7 @@ isTearingDownOpenSession, selectSession, sessionInCurrentFamily, + sessionPageHref, sessionState, type SessionPreviewTab } from '$lib/components/sessions/sessionState.svelte' @@ -70,7 +71,7 @@ type PreviewTarget } from '$lib/components/sessions/previewRouter' import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload' - import { pageItemForListPath } from '$lib/components/sessions/previewPaths' + import { isPageItemListPath, stripBase } from '$lib/components/sessions/previewPaths' import { leafKeyFor, loadKind, @@ -127,28 +128,37 @@ } }) - const sessionName = $derived(page.url.searchParams.get('session_name') ?? '') - - // Unfiltered resolution by name: drives the recovery effect below and the - // active-session lookup. - const sessionByName = $derived( - sessionName ? sessionState.sessions.find((s) => s.name === sessionName) : undefined - ) + const sessionId = $derived(page.url.searchParams.get('session') ?? '') + // Links from before sessions were addressed by id name a per-browser `session-N`. + const legacySessionName = $derived(page.url.searchParams.get('session_name') ?? '') + const requestsSession = $derived(!!sessionId || !!legacySessionName) // Opening a session deliberately does NOT switch the global workspace: the // chat targets the session's own workspace via the manager's workspace // resolver, so the user's active (navigation-mode) workspace is left alone. - // Resolve by name without applying the sidebar scope filter so an open - // chat survives within-family workspace switches. - const activeSession = $derived(sessionState.sessions.find((s) => s.name === sessionName)) + // Resolved without applying the sidebar scope filter so an open chat survives + // within-family workspace switches. + const activeSession = $derived( + sessionId + ? sessionState.sessions.find((s) => s.id === sessionId) + : legacySessionName + ? sessionState.sessions.find((s) => s.name === legacySessionName) + : undefined + ) + + $effect(() => { + if (embedded || sessionId || !activeSession) return + const id = activeSession.id + untrack(() => void goto(sessionPageHref(id), { replaceState: true })) + }) // Family reconcile: a workspace switch can land this page with no session // selected or with another family's session in the URL (the sidebar picker's // link navigation keeps the route), and a chat must not bleed across // families. Re-enter session mode scoped to the active family: keep the open // chat when it belongs there, else its most recent active session, else a - // fresh one. A `session_name` that resolves to nothing is left to the + // fresh one. A `session` that resolves to nothing is left to the // recovery effect below. $effect(() => { if (embedded || !sessionState.hydrated) return @@ -161,15 +171,14 @@ $workspaceStore $userWorkspaces const current = activeSession - const shouldReenter = current ? !sessionInCurrentFamily(current) : !sessionName + const shouldReenter = current ? !sessionInCurrentFamily(current) : !requestsSession if (!shouldReenter) return untrack(() => void enterSessionMode({ replace: true })) }) // Held across the swap so the session layout mounts once, after the navigation - // settles. A recovered session often takes the very name that was missing, so - // the URL resolves before `goto` returns; mounting the panes mid-navigation - // makes Splitpanes miss the collapsed pane's `maxSize: 0` and leave it open. + // settles: mounting the panes mid-navigation makes Splitpanes miss the collapsed + // pane's `maxSize: 0` and leave it open. let recovering = $state(false) // Bounds the wait for the workspace list the guard above needs. The list can @@ -194,7 +203,7 @@ // they just deleted couldn't be found. if (isTearingDownOpenSession()) return if ($usersWorkspaceStore === undefined && !workspaceListOverdue) return - if (!sessionName || sessionByName) return + if (!requestsSession || activeSession) return untrack(() => void recoverToNewSession()) }) @@ -276,7 +285,7 @@ const target = findEmptyLandingSession() ?? createSession() selectSession(target.id) markSessionRecovered(target.id) - await goto(`/sessions?session_name=${encodeURIComponent(target.name)}`, { + await goto(sessionPageHref(target.id), { replaceState: true }) await tick() @@ -825,12 +834,11 @@ owner?.navigate({ type: 'item', item }) return } - // A row opened on a list page inside a preview tab: its editor opens as a tab of its - // own, leaving the list where it is. - if (d.type === 'wm.session.openPageItem') { - if (typeof d.pagePath !== 'string' || typeof d.path !== 'string') return - const ref = pageItemForListPath(d.pagePath, d.path) - if (ref) owner?.open({ type: 'pageitem', ref }) + // A preview frame navigating to a list page: re-point the active tab to the list + // mounted in process, whose rows open as tabs of their own. + if (d.type === 'wm.session.openList') { + if (typeof d.href !== 'string' || !isPageItemListPath(stripBase(d.href))) return + owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) }) return } // A job clicked inside a preview tab: open the run detail in a NEW tab so the @@ -911,9 +919,9 @@
    - {:else if !sessionName} + {:else if !requestsSession}
    No session selected — pick one in the sidebar.
    - {:else if !sessionByName || recovering} + {:else if !activeSession || recovering}
    diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index 912d171d86..0f4155d6b3 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -1,567 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - SQS triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - sqsTriggerEditor?.openNew(false), - aiId: 'sqs-triggers-empty-add', - aiDescription: 'Add SQS trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_sqs', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - sqsTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - SQS trigger is starting... - {:else} - Could not connect to SQS{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Connected to SQS{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'sqs', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - sqsTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'sqs' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'sqs_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - try { - await SqsTriggerService.deleteSqsTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } catch (error) { - sendUserToast(error.body, true) - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + 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 new file mode 100644 index 0000000000..2362b7f116 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte @@ -0,0 +1,132 @@ + + + + {#if framed} + + This page cannot be used inside another page. Open it in its own tab. + + {:else if invalid} + + This authorization link is malformed. Start again from the deploy drawer of the other + instance. + + {:else} +
    +

    + {callback!.origin} asks to deploy into workspace + {workspace} on this instance as + {$usersWorkspaceStore?.email ?? 'you'}. +

    +

    + It will receive a token limited to that workspace, with your permissions in it, valid for up + to 90 days. Only authorize if you just started this from {callback!.host}. You can revoke + the token at any time in your account settings, under tokens. +

    + {#if unencrypted} + + {callback!.origin} is served over plain http, so the token will cross the network unencrypted + on its way there. + + {/if} + {#if error} + {error} + {/if} +
    +
    + + +
    + {/if} + {#if framed || invalid} +
    + +
    + {/if} +
    diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index 873e812987..839695361e 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -1,722 +1,5 @@ - - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables} - -{:else} - - - {#if showCreateButtons} -
    - {#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)} - - {:else} - - {/if} -
    - {/if} -
    - (showCreateButtons = v)} /> - - - - { - loadVariables() - }} - /> - -
    - - - - {#snippet extra()} - - Contextual variables are passed as environment variables when running a script and - depends on the execution context. - - {/snippet} - - - {#if tab == 'workspace'} - - {/if} -
    - - {#if tab == 'workspace'} -
    - {#if !filteredItems} - - {#each new Array(3) as _} - - {/each} - {:else if filteredItems.length == 0} - {#if activeFilters} - - {:else} - variableEditor?.initNew(), - aiId: 'variables-empty-add-variable', - aiDescription: 'Add variable' - } - : undefined} - /> - {/if} - {:else} - - -
    - - Path - Value - Description - Status - Actions - - - - {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'variable', path) ?? is_draft} - - - - - -
    - { - handledHash = `#${path}` - variableEditor?.editVariable(path) - }} - href="#{path}" - > - {path}{hasDraft ? '*' : ''} - - - {#if labels?.length} - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} - {/if} - -
    -
    - - -
    - {#if value} - {truncate(value, 20)} - {:else} - ∗∗∗∗ - {/if} -
    - {#if is_secret} - - - {#snippet text()} - This item is secret - {/snippet} - - {/if} -
    -
    - - {truncate(description ?? '', 50)} - - - -
    - {#if is_linked} - - - {#snippet text()} -
    - This variable is linked with a resource of the same path. They are - deleted and renamed together. -
    - {/snippet} -
    - {/if} - {#if account} - - - {#snippet text()} -
    - This OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
    - {/snippet} -
    - {/if} - - {#if is_refreshed} -
    - {#if refresh_error} - - -
    - - -
    - - {#snippet text()} -
    - Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
    - {/snippet} -
    - {:else if is_expired} - - - {#snippet text()} -
    - The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
    - {/snippet} -
    - {:else} - - - {#snippet text()} -
    - The variable was connected through OAuth and the token is not - expired. -
    - {/snippet} -
    - {/if} -
    - {/if} -
    -
    - - { - let owner = isOwner(path, $userStore, $workspaceStore) - return [ - { - displayName: 'Edit', - icon: Pen, - action: () => variableEditor?.editVariable(path), - disabled: !canWrite || !showCreateButtons - }, - { - displayName: 'Delete', - icon: Trash, - type: 'delete', - action: (event) => { - if (event['shiftKey']) { - deleteVariable(path, account) - } else { - deleteIsLinked = is_linked ?? false - deleteConfirmedCallback = () => { - deleteVariable(path, account) - } - } - }, - disabled: !owner || !showCreateButtons - }, - ...(!ws_specific && - isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'variable') - } - } - ] - : []), - { - displayName: 'Permissions', - action: () => { - shareModal?.openDrawer(path, 'variable') - }, - icon: Shield - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RefreshCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadVariables() - } - } - ] - : []) - ] - }} - /> - -
    - {/each} -
    - - {/if} - - {:else if tab == 'contextual'} -
    - {#if loading.contextual} - - {#each new Array(8) as _} - - {/each} - {:else} - - {#if contextualVariables.filter((x) => x.is_custom).length === 0} - contextualVariableEditor?.initNew(), - aiId: 'variables-empty-add-contextual-variable', - aiDescription: 'Add contextual variable' - } - : undefined} - /> - {:else} - x.is_custom)} - keys={['name', 'value']} - getRowActions={$userStore?.is_admin || $userStore?.is_super_admin - ? (row) => { - return [ - { - displayName: 'Edit', - action: () => contextualVariableEditor?.editVariable(row.name, row.value) - }, - { - displayName: 'Delete', - type: 'delete', - action: () => { - deleteContextualVariable(row) - } - } - ] - } - : undefined} - /> - {/if} - - !x.is_custom)} - keys={['name', 'value', 'description']} - /> - {/if} -
    - {/if} - - -{/if} - - { - deleteConfirmedCallback = undefined - }} - on:confirmed={() => { - if (deleteConfirmedCallback) { - deleteConfirmedCallback() - } - deleteConfirmedCallback = undefined - }} -> -
    - Are you sure you want to remove this variable? - {#if deleteIsLinked} - - This variable is linked with a resource of the same path. The linked resource will also be - deleted. - - {/if} - -
    - You can press - SHIFT - while removing a variable to bypass confirmation. -
    -
    -
    -
    + diff --git a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte index 35a167c296..176c2ccbe5 100644 --- a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte @@ -1,579 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - WebSocket triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - websocketTriggerEditor?.openNew(false), - aiId: 'websocket-triggers-empty-add', - aiDescription: 'Add WebSocket trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, url, is_flow, extra_perms, canWrite, marked, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_websocket', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - websocketTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {url.startsWith('$script:') - ? 'URL: ' + url.replace('$script:', 'result of script ') - : url.startsWith('$flow:') - ? 'URL: ' + url.replace('$flow:', 'result of flow ') - : url} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - WebSocket is starting... - {:else} - WebSocket is not connected{error ? ': ' + error : ''} - {/if} - {:else} - WebSocket was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - WebSocket is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'websocket', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - websocketTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'websockets' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'websocket_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await WebsocketTriggerService.deleteWebsocketTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 424aa0267e..f055e92ca3 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -8,8 +8,11 @@ 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 from '$lib/components/ErrorOrRecoveryHandler.svelte' + import ErrorOrRecoveryHandler, { + handlerFullPath + } from '$lib/components/ErrorOrRecoveryHandler.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' @@ -517,7 +520,7 @@ webhook = '' } } catch (error) { - sendUserToast(`Failed to save webhook: ${error}`, true) + sendUserToast(`Failed to save webhook: ${error?.body ?? error}`, true) } } @@ -906,7 +909,7 @@ await WorkspaceService.editErrorHandler({ workspace: $workspaceStore!, requestBody: { - path: `${errorHandlerItemKind}/${errorHandlerScriptPath}`, + path: handlerFullPath(errorHandlerSelected, errorHandlerItemKind, errorHandlerScriptPath), extra_args: errorHandlerExtraArgs, muted_on_cancel: errorHandlerMutedOnCancel, muted_on_user_path: errorHandlerMutedOnUserPath, @@ -1552,6 +1555,12 @@ + +
    + Deploy to another instance + +
    {:else if tab == 'rulesets'} + @@ -195,6 +207,77 @@ That's the full round-trip.` + +
    + + +
    + +
    +
    Sizes
    +
    + {#each toggleSizes as size} + + {/each} +
    +
    + +
    +
    Colors
    +
    + {#each toggleColors as color} + + {/each} +
    +
    + +
    +
    Labels
    +
    + + + + +
    +
    + +
    +
    Quarter-pixel offsets
    +
    + Zoom a 1x screen in on these: the knob must keep a visible ring of track on every side in + both states. It reads as overflowing the track once that ring drops below a device pixel. +
    +
    + {#each toggleSizes as size} +
    + {#each subpixelOffsets as offset} +
    + +
    + {/each} +
    + {/each} +
    +
    +
    The three markdownProse presets, same source. Each is sized for its own diff --git a/frontend/src/routes/test_dev/diff-comparison/+page.svelte b/frontend/src/routes/test_dev/diff-comparison/+page.svelte new file mode 100644 index 0000000000..c473dbcb64 --- /dev/null +++ b/frontend/src/routes/test_dev/diff-comparison/+page.svelte @@ -0,0 +1,139 @@ + + + + Diff comparison + + +
    +
    + {#each cases as item} + + {/each} +
    + +
    +
    +

    Monaco

    +
    + {#key activeCase.id} + + {/key} +
    +
    + +
    +

    Native

    +
    + {#key activeCase.id} + + {/key} +
    +
    +
    +
    diff --git a/frontend/src/routes/user/login_link/+page.svelte b/frontend/src/routes/user/login_link/+page.svelte deleted file mode 100644 index 48d1d356fb..0000000000 --- a/frontend/src/routes/user/login_link/+page.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - {#if token} - - {/if} - diff --git a/frontend/src/routes/user/login_link_expired/+page.svelte b/frontend/src/routes/user/login_link_expired/+page.svelte index 6c99d35408..b160985bb2 100644 --- a/frontend/src/routes/user/login_link_expired/+page.svelte +++ b/frontend/src/routes/user/login_link_expired/+page.svelte @@ -17,7 +17,7 @@ diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index a5aea63715..5c623fbadc 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -603,6 +603,12 @@ const config = { backgroundColor: 'rgb(var(--color-surface-primary))', color: 'rgb(var(--color-text-primary))', + // Sidebar rail chrome — consumed via var(--sidebar-bg-light) (see sidebarChrome.ts). + // The `-base` vars hold the theme's color; the accent (accentColor.ts) tints + // `--sidebar-bg-*` from them, which a self-reference could not do. + '--sidebar-bg-light-base': '#F3F3F7', + '--sidebar-bg-light': 'var(--sidebar-bg-light-base)', + '--vscode-editorSuggestWidget-background': '#f3f3f3', '--vscode-editorHoverWidget-foreground': '#616161', '--vscode-editorHoverWidget-border': '#c8c8c8', @@ -622,7 +628,8 @@ const config = { ...darkColorVariables, // Sidebar rail chrome — consumed via var(--sidebar-bg-dark) (see sidebarChrome.ts). - '--sidebar-bg-dark': '#1e232e', + '--sidebar-bg-dark-base': '#1e232e', + '--sidebar-bg-dark': 'var(--sidebar-bg-dark-base)', '--vscode-editorSuggestWidget-background': '#252526', '--vscode-editorHoverWidget-foreground': '#cccccc', @@ -638,7 +645,7 @@ const config = { // GitHub's canvas.inset — the authentic recessed surface, sitting below // the canvas (#0d1117) like the sidebar-vs-primary relationship in the // default dark theme. - '--sidebar-bg-dark': '#010409', + '--sidebar-bg-dark-base': '#010409', // Monaco popup chrome — the &.dark defaults above are VS Code's greys; // override so the widgets match the GitHub Dark palette in this variant. diff --git a/lsp/Pipfile b/lsp/Pipfile index ab4f26aab3..79d2672f4a 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.815.0" +wmill = ">=1.817.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 6ed1038148..48f573bab1 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.815.0 + version: 1.817.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index b35fbd5ecc..ad6c0e8bc1 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.815.0' + ModuleVersion = '1.817.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 7a9f55641e..baab12d832 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.815.0" +version = "1.817.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index e375863aa3..26441041a1 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.815.0", + "version": "1.817.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 49c63650e7..4badfa307b 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.815.0", + "version": "1.817.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 297171ea20..2edd9ebec3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.815.0 +1.817.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index c65c508fe7..e07061650d 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.815.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.815.0", + "version": "1.817.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 ec013fca2e..01bb378dcd 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.815.0", + "version": "1.817.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",