diff --git a/.agents/skills/local-review-codex/SKILL.md b/.agents/skills/local-review-codex/SKILL.md new file mode 100644 index 0000000000..cc932f7a4b --- /dev/null +++ b/.agents/skills/local-review-codex/SKILL.md @@ -0,0 +1,50 @@ +--- +name: local-review-codex +description: Run the CI Codex PR review locally against this branch's unpushed work (committed + uncommitted) before pushing. Same policy, model, and reasoning effort as the codex-pr-review GitHub action. +--- + +# Local Codex Review (pre-push) + +Runs the exact same review Codex performs in CI (`.github/workflows/codex-pr-review.yml`), +but locally and scoped to work you have not pushed yet — so you catch what CI would flag +before the PR exists. Use this before `git push` on a non-trivial change. + +**Correspondence with CI** — identical: +- Policy: `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test coverage). +- Model: `gpt-5.6-sol`, `model_reasoning_effort="xhigh"`. +- Output: markdown starting with `## Codex Review`, findings tagged P0 / P1 / P2 with file:line. + +**Differences from CI** — local-only: +- Scope is the current branch vs `main` at the merge-base, **including uncommitted changes** (CI reviews a pushed PR diff). +- Sandbox is `read-only` (CI uses `danger-full-access` on an ephemeral runner). Codex reads the diff and files but cannot modify your working tree. +- Fresh context is inherent: `codex exec` is a separate cold process, so it does not anchor on the current chat session — the same reason `local-review` insists on a subagent. + +## Prerequisites + +- `codex` CLI **>= 0.144.1** installed and authed (`codex login` or `OPENAI_API_KEY`). Older CLIs reject `gpt-5.6-sol` with "requires a newer version of Codex". Upgrade with `npm install --global @openai/codex@0.144.1` (may need `sudo` for a global install). Keep this in sync with the pin in `.github/workflows/codex-pr-review.yml`. +- `git fetch` the base ref if it's stale, so the merge-base is accurate. + +## Run + +```bash +bash .agents/skills/local-review-codex/run.sh # review vs main (default) +bash .agents/skills/local-review-codex/run.sh # review vs a different base ref +``` + +Invoke with `bash` (or run the executable directly) — the script needs Bash for +`set -o pipefail`; `sh` is Dash on Debian/Ubuntu and would fail. If `main` isn't a +local branch (e.g. a fresh single-branch checkout), the runner falls back to +`origin/main` automatically. + +The script computes `BASE_SHA = git merge-base HEAD `, feeds Codex `REVIEW.md` plus a +diff context pointing at `git diff ` (which folds in uncommitted edits), and prints +the review. It writes only temp files — nothing lands in the working tree. + +## Relaying the result + +Print the Codex output verbatim. Do not re-summarize or filter it — the value of a cold Codex +pass is surfacing what the current session would rationalize away. Then decide with the user +whether to address findings before pushing. + +For a Claude-native review instead, use `local-review` (branch-diff-reviewer subagent). This +skill is the Codex counterpart; run both for independent perspectives. diff --git a/.agents/skills/local-review-codex/run.sh b/.agents/skills/local-review-codex/run.sh new file mode 100755 index 0000000000..d6491099c2 --- /dev/null +++ b/.agents/skills/local-review-codex/run.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Local Codex review — mirrors the .github/workflows/codex-pr-review.yml CI job, +# but scoped to this branch's unpushed work (committed + uncommitted) so you can +# review before pushing. Same policy (REVIEW.md), same model (gpt-5.6-sol) and +# reasoning effort (xhigh) as CI. Runs read-only: Codex cannot modify your tree. +# +# Usage: run.sh [BASE_REF] (BASE_REF defaults to "main") +set -euo pipefail + +BASE_REF="${1:-main}" +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +if ! command -v codex >/dev/null 2>&1; then + echo "codex CLI not found. Install with: npm install --global @openai/codex@0.144.1" >&2 + exit 1 +fi + +# Resolve the base to a concrete commit, preferring a local ref but falling back to +# the remote-tracking ref — checkouts (CI, single-branch clones) often have only +# origin/main, not a local main. +if git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then + BASE_COMMITISH="$BASE_REF" +elif git rev-parse --verify --quiet "origin/${BASE_REF}^{commit}" >/dev/null; then + BASE_COMMITISH="origin/${BASE_REF}" +else + echo "Base ref '$BASE_REF' not found as '$BASE_REF' or 'origin/$BASE_REF'. Try: git fetch origin $BASE_REF" >&2 + exit 1 +fi + +# Diff from the merge-base so only this branch's changes are reviewed. Using the +# base SHA with a single-ref `git diff` also folds in uncommitted working-tree edits, +# but `git diff` never sees untracked files — those are gathered separately below so +# brand-new files (a whole new module, a new skill dir) are not silently skipped. +BASE_SHA="$(git merge-base HEAD "$BASE_COMMITISH")" +HEAD_SHA="$(git rev-parse HEAD)" +UNTRACKED="$(git ls-files --others --exclude-standard)" + +if [ "$BASE_SHA" = "$HEAD_SHA" ] && git diff --quiet "$BASE_SHA" && [ -z "$UNTRACKED" ]; then + echo "No changes vs $BASE_REF — nothing to review." >&2 + exit 0 +fi + +PROMPT="$(mktemp)" +OUT="$(mktemp)" +trap 'rm -f "$PROMPT" "$OUT"' EXIT + +# REVIEW.md is the shared policy CI feeds Codex. Append the local output-format +# and diff context inline (CI reads these from a generated context file; inlining +# keeps the working tree clean — no scratch files land in the repo). +cat REVIEW.md > "$PROMPT" +cat >> "$PROMPT" < no .pdb and no LNK1318 type-server limit). + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" # Tests' poll-time stack frames (deep nested async fn chains in # debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky # overflows under parallel-test contention. diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 0be727d4bd..5a6dc27b82 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -90,7 +90,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-workspaces: backend - toolchain: 1.93.0 + toolchain: 1.97.0 - name: Fix stale v8 build cache working-directory: ./backend run: | diff --git a/.github/workflows/build_windows_worker_.yml b/.github/workflows/build_windows_worker_.yml index 7fe2d4e416..2d573c6959 100644 --- a/.github/workflows/build_windows_worker_.yml +++ b/.github/workflows/build_windows_worker_.yml @@ -33,7 +33,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-workspaces: backend - toolchain: 1.93.0 + toolchain: 1.97.0 - name: Substitute EE code shell: bash diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index ea622914bc..b395db98be 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -166,7 +166,7 @@ jobs: - name: Install Codex CLI if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' - run: npm install --global @openai/codex@0.128.0 + run: npm install --global @openai/codex@0.144.1 - name: Configure Codex auth if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' @@ -280,7 +280,7 @@ jobs: cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md codex exec \ -C "$GITHUB_WORKSPACE" \ - -m gpt-5.5 \ + -m gpt-5.6-sol \ -c 'model_reasoning_effort="xhigh"' \ -s danger-full-access \ -o codex-final-message.md \ diff --git a/.github/workflows/git-commands.yaml b/.github/workflows/git-commands.yaml index 3443b7e649..bf9009c800 100644 --- a/.github/workflows/git-commands.yaml +++ b/.github/workflows/git-commands.yaml @@ -80,7 +80,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-workspaces: backend - toolchain: 1.93.0 + toolchain: 1.97.0 - name: Install xmlsec and gssapi build-time deps run: | diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml index 12b75932ce..3ff7d8b85c 100644 --- a/.github/workflows/git-sync-test.yml +++ b/.github/workflows/git-sync-test.yml @@ -121,7 +121,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-workspaces: backend - toolchain: 1.93.0 + toolchain: 1.97.0 - uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/publish_windows_worker.yml b/.github/workflows/publish_windows_worker.yml index f1745d03fd..019de201e6 100644 --- a/.github/workflows/publish_windows_worker.yml +++ b/.github/workflows/publish_windows_worker.yml @@ -35,7 +35,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: cache-workspaces: backend - toolchain: 1.93.0 + toolchain: 1.97.0 - name: Substitute EE code shell: bash diff --git a/AGENTS.md b/AGENTS.md index 83b68f7d36..fa7889c4c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Backend patterns**: use the `rust-backend` skill when writing Rust code - **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill. - **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead. -- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. +- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy, `gpt-5.6-sol`, `xhigh` reasoning; requires the `codex` CLI >= 0.144.1. - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. diff --git a/CHANGELOG.md b/CHANGELOG.md index bb9eda6725..a0dc63ad42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,116 @@ # Changelog +## [1.756.0](https://github.com/windmill-labs/windmill/compare/v1.755.0...v1.756.0) (2026-07-12) + + +### Features + +* **triggers:** serve binary HTTP-route responses via base64 transfer encoding ([#10058](https://github.com/windmill-labs/windmill/issues/10058)) ([29f4cd4](https://github.com/windmill-labs/windmill/commit/29f4cd4b6f58a29b83b84a7a9b8a439d20ade00e)), closes [#5986](https://github.com/windmill-labs/windmill/issues/5986) + + +### Bug Fixes + +* replicate all secrets on fork when external backend is configured ([#10060](https://github.com/windmill-labs/windmill/issues/10060)) ([92b7f37](https://github.com/windmill-labs/windmill/commit/92b7f375a90de2f78565ca06a13c79ff04eda44d)) +* **sessions:** sync AI-session preview with workspace edits + stop phantom autosave (WIN-2160) ([#10061](https://github.com/windmill-labs/windmill/issues/10061)) ([5cde2d5](https://github.com/windmill-labs/windmill/commit/5cde2d5b6746be9f2d0be3a98ecdaf08777a6395)) + +## [1.755.0](https://github.com/windmill-labs/windmill/compare/v1.754.0...v1.755.0) (2026-07-11) + + +### Features + +* add per-workspace job-retention override ([#10050](https://github.com/windmill-labs/windmill/issues/10050)) ([ff774c4](https://github.com/windmill-labs/windmill/commit/ff774c46bff4bff1c532e512b163225aa7c41c11)) +* **apps:** authorize deployed-app S3 reads on-behalf of the author for logged-in viewers ([#10048](https://github.com/windmill-labs/windmill/issues/10048)) ([1e192f2](https://github.com/windmill-labs/windmill/commit/1e192f2d864b8a4671e900726972737406bc388a)) +* **mcp:** add multi-workspace MCP tokens via the gateway endpoint ([#10043](https://github.com/windmill-labs/windmill/issues/10043)) ([8343203](https://github.com/windmill-labs/windmill/commit/8343203ec2cea28a2ffd5b4ac636497e861fa3ce)) + + +### Bug Fixes + +* clearer errors on auto-draft save failure (WIN-2157) ([#10053](https://github.com/windmill-labs/windmill/issues/10053)) ([04eb7dd](https://github.com/windmill-labs/windmill/commit/04eb7ddd3906c28bec1711e276473a87c7b9500f)) +* **docker:** pin ansible tool interpreter to a persistent path ([#10054](https://github.com/windmill-labs/windmill/issues/10054)) ([6f49a1f](https://github.com/windmill-labs/windmill/commit/6f49a1f6a904442fcae9bb703f095b0a3ef61268)) +* enforce read authorization when signing S3 objects ([#10049](https://github.com/windmill-labs/windmill/issues/10049)) ([5844c32](https://github.com/windmill-labs/windmill/commit/5844c32ac5d08081b3de7f3d11b8b98eb1e1ad9a)) +* **frontend:** don't re-seed empty editor on stale ?new_draft after draft exists ([#10044](https://github.com/windmill-labs/windmill/issues/10044)) ([e668193](https://github.com/windmill-labs/windmill/commit/e668193a93b4a7df50459b31f2dc5f9a9b23d0fe)) +* **frontend:** keep draft autosave alive after AI-session round-trip ([#10052](https://github.com/windmill-labs/windmill/issues/10052)) ([7d02d9a](https://github.com/windmill-labs/windmill/commit/7d02d9a1e47760f287a71f6e09cd6fe45efb5635)) +* **frontend:** mint draft path for new SDK builder items so autosave attaches ([#10056](https://github.com/windmill-labs/windmill/issues/10056)) ([a89b896](https://github.com/windmill-labs/windmill/commit/a89b896ce5638f42f334055f9ffe6971b047aa84)) +* **frontend:** show nested restart button for subflows nested in containers ([#10042](https://github.com/windmill-labs/windmill/issues/10042)) ([3b07817](https://github.com/windmill-labs/windmill/commit/3b0781761b70667c5961bcb15d41c907716fa9e7)) +* **frontend:** show optimistic user message and fork-creation label before beforeSend ([#10037](https://github.com/windmill-labs/windmill/issues/10037)) ([1c88242](https://github.com/windmill-labs/windmill/commit/1c88242849a02b927f59e0a67c4b4707371b784f)) +* keep agent-worker server job-completed processor alive & self-healing ([#10033](https://github.com/windmill-labs/windmill/issues/10033)) ([ab38e14](https://github.com/windmill-labs/windmill/commit/ab38e1418e67be8bcc37391bb21d2f86d1ca3fc6)) + +## [1.754.0](https://github.com/windmill-labs/windmill/compare/v1.753.0...v1.754.0) (2026-07-10) + + +### Features + +* add multi-select mode to copilot askUserQuestion ([#10016](https://github.com/windmill-labs/windmill/issues/10016)) ([7569798](https://github.com/windmill-labs/windmill/commit/756979852c3245d06c5c73eb60e9a09fd59635c5)) + + +### Bug Fixes + +* accept bunnative language in AI chat flow step validation ([#10030](https://github.com/windmill-labs/windmill/issues/10030)) ([5a460db](https://github.com/windmill-labs/windmill/commit/5a460dbec6e2b81e01aa2c36cb504dde4ff6b24a)) +* **backend:** propagate script timeout when restarting perpetual scripts ([#10029](https://github.com/windmill-labs/windmill/issues/10029)) ([6c521e9](https://github.com/windmill-labs/windmill/commit/6c521e9d87724e43ebe3b7fce8b30a3671ef3d89)) +* **frontend:** name the draft in AI chat test-run confirmation ([#10024](https://github.com/windmill-labs/windmill/issues/10024)) ([9036ac7](https://github.com/windmill-labs/windmill/commit/9036ac789f358103b8d639a6b94708c452f52f90)) +* **frontend:** open new script/flow/app in AI session (not-found + friendly tab) ([#10028](https://github.com/windmill-labs/windmill/issues/10028)) ([0353569](https://github.com/windmill-labs/windmill/commit/03535691d607d8a9d1c1fa1c9c284fa3b6051d40)) +* **frontend:** persist forked "Copy of X" script drafts ([#10021](https://github.com/windmill-labs/windmill/issues/10021)) ([c537d45](https://github.com/windmill-labs/windmill/commit/c537d45e4982f30a44026d6644d5340e56f16ef9)) +* **frontend:** persist per-session preview panel resize width ([#10031](https://github.com/windmill-labs/windmill/issues/10031)) ([5387076](https://github.com/windmill-labs/windmill/commit/5387076c1c6fb35a99843aadd2055d3ac381d6cf)) +* **frontend:** scope raw-app, flow and script editors to the session workspace ([#10015](https://github.com/windmill-labs/windmill/issues/10015)) ([c000bbc](https://github.com/windmill-labs/windmill/commit/c000bbca283f5d61cff8a39458764b2b2dd2b58f)) +* resolve fork family/picker for superadmin visiting a non-member workspace ([#10023](https://github.com/windmill-labs/windmill/issues/10023)) ([368fd2d](https://github.com/windmill-labs/windmill/commit/368fd2d9e4b3ffb66e64934d9a622eea291cde5a)) +* scope AI-session flow/script editors to the session workspace ([#10025](https://github.com/windmill-labs/windmill/issues/10025)) ([c5060a1](https://github.com/windmill-labs/windmill/commit/c5060a1e9af5a704e90f92d325abf29626ecd28a)) +* **security:** drop --allow-run from Deno sandbox (GHSA-gj6h-vw66-mr8f) ([#10039](https://github.com/windmill-labs/windmill/issues/10039)) ([c029d6d](https://github.com/windmill-labs/windmill/commit/c029d6dcde44a3d16dee23a80afad920a0535b73)) +* **security:** remove git from Deno sandbox allow-run (GHSA-gj6h-vw66-mr8f) ([#10038](https://github.com/windmill-labs/windmill/issues/10038)) ([689b20a](https://github.com/windmill-labs/windmill/commit/689b20a4704a3dda8d9437b4793c0d16eb1f780f)) +* session preview tab labels, splitter hover, and diff-drawer sizing ([#10008](https://github.com/windmill-labs/windmill/issues/10008)) ([c139eed](https://github.com/windmill-labs/windmill/commit/c139eed631548113b843b466f5505bf6a01f17d3)) +* **sessions:** open test pane when enabling debug so the debug UI is visible ([#9998](https://github.com/windmill-labs/windmill/issues/9998)) ([d7a9b46](https://github.com/windmill-labs/windmill/commit/d7a9b46ab95108c7669b47b7c4be6d8c7964a9e6)) +* sync theme into session page preview iframes on toggle ([#10018](https://github.com/windmill-labs/windmill/issues/10018)) ([3704d00](https://github.com/windmill-labs/windmill/commit/3704d00956dea3b8e562a894d5330e052a753cb3)) + + +### Performance Improvements + +* index v2_job(parent_job) to speed up run child-job listing ([#10034](https://github.com/windmill-labs/windmill/issues/10034)) ([9feda57](https://github.com/windmill-labs/windmill/commit/9feda57c15bddc7ef481579b73636b88c2a143c5)) +* skip redundant retry-chain job query for successful top-level scripts ([#10035](https://github.com/windmill-labs/windmill/issues/10035)) ([15f9e9b](https://github.com/windmill-labs/windmill/commit/15f9e9b48fc326aa3d776191aabaed22f4c41e74)) + +## [1.753.0](https://github.com/windmill-labs/windmill/compare/v1.752.0...v1.753.0) (2026-07-08) + + +### Features + +* AI chat background jobs tray with detach, approval and preview ([#9982](https://github.com/windmill-labs/windmill/issues/9982)) ([286da00](https://github.com/windmill-labs/windmill/commit/286da005ef2faad1d193640f74f8e96999358707)) +* condensed top bar for session preview editors ([#10011](https://github.com/windmill-labs/windmill/issues/10011)) ([b847ca2](https://github.com/windmill-labs/windmill/commit/b847ca2bc7f06f494aa802d4350d6f032ba2bb58)) +* **db-health:** add connection sizing guidance ([#10014](https://github.com/windmill-labs/windmill/issues/10014)) ([f28ea9c](https://github.com/windmill-labs/windmill/commit/f28ea9cb991bbda32ed1b7a37a5f1b3552a589a8)) +* **sessions:** scoped preview refresh + multi-target live editors + pipeline preview ([#10006](https://github.com/windmill-labs/windmill/issues/10006)) ([32c398f](https://github.com/windmill-labs/windmill/commit/32c398f27de8cd5b1478ef60d247c13705b6b50f)) +* shared tab system, universal markdown code blocks, subtle scrollbars ([#10003](https://github.com/windmill-labs/windmill/issues/10003)) ([a00ee51](https://github.com/windmill-labs/windmill/commit/a00ee5196b2c013e9672ab029f5477079ac5da21)) + + +### Bug Fixes + +* bump bundled Go CLIs to patched versions to clear image CVEs ([#9996](https://github.com/windmill-labs/windmill/issues/9996)) ([d467161](https://github.com/windmill-labs/windmill/commit/d467161117444d7d9b18def627e90d9622512e02)) +* name the offending item when a fork fails on a NUL escape ([#10013](https://github.com/windmill-labs/windmill/issues/10013)) ([99d0047](https://github.com/windmill-labs/windmill/commit/99d00475156def6faad255c4e728923253f9169f)) +* preserve worker group tag override on 'Run again' ([#10004](https://github.com/windmill-labs/windmill/issues/10004)) ([c4cb2f3](https://github.com/windmill-labs/windmill/commit/c4cb2f373b6361f0f3ce6b1c8e32a4c010207760)) +* replicate external secret backend secrets when forking a workspace ([#10007](https://github.com/windmill-labs/windmill/issues/10007)) ([f65fe7b](https://github.com/windmill-labs/windmill/commit/f65fe7bf585d353f7d88746e947d68e2f351e516)) +* session preview editors and picker dropdown overflow ([#10010](https://github.com/windmill-labs/windmill/issues/10010)) ([fb12b23](https://github.com/windmill-labs/windmill/commit/fb12b23e0169ba2cdcf454a27dcf814a2caf26b3)) + +## [1.752.0](https://github.com/windmill-labs/windmill/compare/v1.751.0...v1.752.0) (2026-07-07) + + +### Features + +* add fork_parent_workspace claim to OIDC tokens for fork workspaces ([#9987](https://github.com/windmill-labs/windmill/issues/9987)) ([7efeae2](https://github.com/windmill-labs/windmill/commit/7efeae26d821b10667b6e3edd220468f6ae48936)) +* add SQL migrations for data tables ([#9693](https://github.com/windmill-labs/windmill/issues/9693)) ([e47aeda](https://github.com/windmill-labs/windmill/commit/e47aedac0a4af40dd697d5fc4d54dd3c8efe9ab8)) +* **cli:** clarify fork-branch workspace auto-targeting in output ([#9988](https://github.com/windmill-labs/windmill/issues/9988)) ([88c2d0e](https://github.com/windmill-labs/windmill/commit/88c2d0e8e32c218787c01daed80ef41efc39dd11)) +* open runs/schedules pages from AI chat in session preview tabs ([#9976](https://github.com/windmill-labs/windmill/issues/9976)) ([4bb82ad](https://github.com/windmill-labs/windmill/commit/4bb82ad6cdb62eae7b69b1054714333e54558632)) +* **raw-apps:** runtime-error overlay + AI import-React instruction ([#9966](https://github.com/windmill-labs/windmill/issues/9966)) ([8df613b](https://github.com/windmill-labs/windmill/commit/8df613b4d2f88765f49cc988a894ca323c4ec4f7)) +* **sessions:** v2 unified sidebar with family/fork scoping and preview router ([#9816](https://github.com/windmill-labs/windmill/issues/9816)) ([9503190](https://github.com/windmill-labs/windmill/commit/95031903ebe223dc03b49a6bcd3e4ee67cefc4bb)) +* smooth bursty AI chat streaming with a typewriter reveal ([#9991](https://github.com/windmill-labs/windmill/issues/9991)) ([a6276b5](https://github.com/windmill-labs/windmill/commit/a6276b590082d06480434a8ea002c335ea1cfb59)) +* update base image to debian 13 (trixie) ([#9973](https://github.com/windmill-labs/windmill/issues/9973)) ([c5c1ead](https://github.com/windmill-labs/windmill/commit/c5c1eadeb18e509a98d1e787206c0438417683fc)) + + +### Bug Fixes + +* **ai-agent:** align agent_actions_success with agent_actions for mcp and websearch ([#9983](https://github.com/windmill-labs/windmill/issues/9983)) ([87f8d46](https://github.com/windmill-labs/windmill/commit/87f8d46aafffd5e88a336192c51e0c95ff2e6f18)) +* **ai:** flow writer builds approval steps as scripts, not identity ([#9985](https://github.com/windmill-labs/windmill/issues/9985)) ([6b01caa](https://github.com/windmill-labs/windmill/commit/6b01caaf26a4f0a08f643db4e22a70e27d0dc554)) +* clear old path asset usage when renaming a script ([#9979](https://github.com/windmill-labs/windmill/issues/9979)) ([927b8d0](https://github.com/windmill-labs/windmill/commit/927b8d064f693384978184992b8f8a1cd708e711)) +* **cli:** auto-derive cascade triggers in --local pipeline graph ([#9978](https://github.com/windmill-labs/windmill/issues/9978)) ([edfe7b4](https://github.com/windmill-labs/windmill/commit/edfe7b415af6670c5855b7a0b52db4c1f7781964)) +* **pipelines:** live materialize/dataset editing — stale graph, phantom drafts, stale Save-all deploys ([#9990](https://github.com/windmill-labs/windmill/issues/9990)) ([f7efb64](https://github.com/windmill-labs/windmill/commit/f7efb646bf1f2e132d1e3ff031b142383ae01c5e)) +* **sessions:** auto-rename regression + preview-panel and fork nits ([#9993](https://github.com/windmill-labs/windmill/issues/9993)) ([804178f](https://github.com/windmill-labs/windmill/commit/804178f5e1c904c3f8e35e2b660f33c78964c6eb)) +* **sessions:** scope fork session Edits to session-edited items only ([#9989](https://github.com/windmill-labs/windmill/issues/9989)) ([7046dc6](https://github.com/windmill-labs/windmill/commit/7046dc6dfb474ef49313377855bb2bd60294e25a)) + ## [1.751.0](https://github.com/windmill-labs/windmill/compare/v1.750.0...v1.751.0) (2026-07-06) diff --git a/Dockerfile b/Dockerfile index cb5cbb57e7..f6eb31b0a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG DEBIAN_IMAGE=debian:trixie-slim -ARG RUST_IMAGE=rust:1.93-slim-trixie +ARG RUST_IMAGE=rust:1.97-slim-trixie FROM debian:trixie-slim AS nsjail @@ -135,8 +135,8 @@ FROM ${DEBIAN_IMAGE} ARG TARGETPLATFORM ARG POWERSHELL_VERSION=7.5.0 -ARG KUBECTL_VERSION=1.28.7 -ARG HELM_VERSION=3.14.3 +ARG KUBECTL_VERSION=1.36.2 +ARG HELM_VERSION=3.21.2 # NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte ARG GO_VERSION=1.26.0 ARG APP=/usr/src/app @@ -310,7 +310,7 @@ COPY --from=nsjail /nsjail/nsjail /bin/nsjail # crane: pulls + flattens images for the sandboxed container runtime (`# sandbox `). # Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md. -ARG CRANE_VERSION=v0.20.6 +ARG CRANE_VERSION=v0.21.7 RUN arch="$(dpkg --print-architecture)"; \ case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \ wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \ diff --git a/ai_evals/cases/flow.yaml b/ai_evals/cases/flow.yaml index 5f4abafc48..a21ae81f17 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -359,6 +359,8 @@ - request_approval - finalize_purchase topLevelStepTypes: + - id: request_approval + type: [rawscript, script] - id: finalize_purchase type: rawscript schemaRequiredPaths: @@ -373,6 +375,7 @@ judgeChecklist: - "the flow includes an approval step named `request_approval`" - "`request_approval` pauses the flow and asks the approver for a comment" + - "`request_approval` is a real script step that generates approval/resume URLs (e.g. via `getResumeUrls`) so approvers receive an actionable link, not a no-op passthrough (identity) step" - one approval is enough to continue - "the flow includes a final step named `finalize_purchase`" - "`finalize_purchase` returns an approved status object after approval" diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 66e5fd3cb7..0f7f2a4717 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -872,6 +872,207 @@ - fetches the logs for the requested job id - explains the failure from the returned logs (connection refused to the upstream API) +# --- Page navigation (open_page) --- +# The assistant should take the user to a Windmill page (Runs/Schedules) with the +# right filters via open_page, rather than describing where to click or dumping the +# data. No draft is produced, so the global judge is skipped and we validate the +# tool call and its arguments. + +- id: global-openpage1-runs-failed-of-script + prompt: |- + Take me to the failed runs of the script at f/evals/global/greet_user so I can see what's going wrong. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - open_page + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: open_page + field: page + stringIncludesAnyOf: + - runs + - tool: open_page + field: status + stringIncludesAnyOf: + - failure + - tool: open_page + field: path + stringIncludesAnyOf: + - f/evals/global/greet_user + skipJudge: true + judgeChecklist: + - opens the Runs page filtered to the failed runs of f/evals/global/greet_user + - applies both the failure status and the script path as filters + - does not write, deploy, or delete anything + +- id: global-openpage2-runs-of-schedule + prompt: |- + Open the runs page filtered to the jobs triggered by the schedule f/evals/global/nightly_digest. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - open_page + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: open_page + field: page + stringIncludesAnyOf: + - runs + - tool: open_page + field: schedule_path + stringIncludesAnyOf: + - f/evals/global/nightly_digest + skipJudge: true + judgeChecklist: + - opens the Runs page filtered to jobs triggered by the f/evals/global/nightly_digest schedule + - passes the schedule path as the filter + - does not write, deploy, or delete anything + +- id: global-openpage3-open-schedule + prompt: |- + Open the schedule f/evals/global/nightly_digest so I can review and edit it. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - open_page + forbiddenToolsUsed: + - write_schedule + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: open_page + field: page + stringIncludesAnyOf: + - schedules + - tool: open_page + field: open + stringIncludesAnyOf: + - f/evals/global/nightly_digest + skipJudge: true + judgeChecklist: + - opens the Schedules page and targets the f/evals/global/nightly_digest schedule for editing + - passes the schedule path so the editor opens on it + - does not write, deploy, or delete anything + +- id: global-openpage4-workspace-settings-tab + prompt: |- + Take me to the Git sync configuration for this workspace. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - open_page + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: open_page + field: page + stringIncludesAnyOf: + - workspace_settings + - tool: open_page + field: tab + stringIncludesAnyOf: + - git_sync + skipJudge: true + judgeChecklist: + - opens the Workspace settings page on the git_sync tab + - does not write, deploy, or delete anything + +- id: global-openpage5-audit-logs-user + prompt: |- + Open the audit logs filtered to actions performed by the user admin. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - open_page + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: open_page + field: page + stringIncludesAnyOf: + - audit_logs + - tool: open_page + field: username + stringIncludesAnyOf: + - admin + skipJudge: true + judgeChecklist: + - opens the Audit logs page filtered to the admin user + - does not write, deploy, or delete anything + +- id: global-openpage6-triggers-kind + prompt: |- + Take me to the Kafka triggers for this workspace. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - open_page + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: open_page + field: page + stringIncludesAnyOf: + - triggers + - tool: open_page + field: trigger_kind + stringIncludesAnyOf: + - kafka + skipJudge: true + judgeChecklist: + - opens the Kafka triggers page + - does not write, deploy, or delete anything + +- id: global-closepage1-close-runs-tab + prompt: |- + You just opened the runs page for me in the side panel. Close that tab, I'm done looking at it. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - close_page + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: close_page + field: match + stringIncludesAnyOf: + - runs + skipJudge: true + judgeChecklist: + - closes the runs preview tab in the side panel + - does not write, deploy, or delete anything + # --- Documentation search (search_docs) --- # Pure product-knowledge questions: the assistant should consult the docs via # search_docs and answer conversationally, not draft or mutate anything. No diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 52667fa321..f142bc8d36 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -47,7 +47,7 @@ export interface FlowValidationSpec { }>; topLevelStepTypes?: Array<{ id: string; - type: string; + type: string | string[]; }>; moduleRules?: Array<{ id: string; diff --git a/ai_evals/core/validators.ts b/ai_evals/core/validators.ts index 23a6709f9b..7570ccd12d 100644 --- a/ai_evals/core/validators.ts +++ b/ai_evals/core/validators.ts @@ -1378,11 +1378,14 @@ function validateFlowRequirements( continue; } + const allowedTypes = Array.isArray(requiredStep.type) + ? requiredStep.type + : [requiredStep.type]; checks.push( check( `${requiredStep.id} type matches required`, - getModuleType(module) === requiredStep.type, - `expected ${requiredStep.type}, got ${getModuleType(module) ?? "(missing)"}` + allowedTypes.includes(getModuleType(module) ?? ""), + `expected ${allowedTypes.join(" or ")}, got ${getModuleType(module) ?? "(missing)"}` ) ); } diff --git a/backend/.sqlx/query-056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf.json b/backend/.sqlx/query-056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf.json new file mode 100644 index 0000000000..abafce2d82 --- /dev/null +++ b/backend/.sqlx/query-056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id as \"workspace_id!\", MIN(completed_at) as oldest\n FROM v2_job_completed\n WHERE workspace_id = ANY($1::text[])\n GROUP BY workspace_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "oldest", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf" +} diff --git a/backend/.sqlx/query-0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051.json b/backend/.sqlx/query-0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051.json new file mode 100644 index 0000000000..e022f764c8 --- /dev/null +++ b/backend/.sqlx/query-0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n (SELECT MIN(completed_at) FROM v2_job_completed) as true_oldest,\n (SELECT MIN(completed_at) FROM v2_job_completed\n WHERE workspace_id <> ALL($1::text[])) as global_oldest,\n (SELECT COUNT(*) FROM v2_job_completed) as total", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "true_oldest", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "global_oldest", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "total", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051" +} diff --git a/backend/.sqlx/query-1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838.json b/backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json similarity index 51% rename from backend/.sqlx/query-1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838.json rename to backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json index be045d9e75..96147d531e 100644 --- a/backend/.sqlx/query-1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838.json +++ b/backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json @@ -1,12 +1,17 @@ { "db_name": "PostgreSQL", - "query": "SELECT restart_unless_cancelled FROM script WHERE hash = $1 AND workspace_id = $2", + "query": "SELECT restart_unless_cancelled, timeout FROM script WHERE hash = $1 AND workspace_id = $2", "describe": { "columns": [ { "ordinal": 0, "name": "restart_unless_cancelled", "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "timeout", + "type_info": "Int4" } ], "parameters": { @@ -16,8 +21,9 @@ ] }, "nullable": [ + true, true ] }, - "hash": "1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838" + "hash": "1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6" } diff --git a/backend/.sqlx/query-20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e.json b/backend/.sqlx/query-20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e.json new file mode 100644 index 0000000000..cf55e44fe4 --- /dev/null +++ b/backend/.sqlx/query-20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.workspace_id = $5\n AND jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz)\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "UuidArray", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e" +} diff --git a/backend/.sqlx/query-4556f04f9da4adffb296b9c45bd97c9af65dd72a682d9d704a2dafb563001f83.json b/backend/.sqlx/query-4556f04f9da4adffb296b9c45bd97c9af65dd72a682d9d704a2dafb563001f83.json new file mode 100644 index 0000000000..e70052886f --- /dev/null +++ b/backend/.sqlx/query-4556f04f9da4adffb296b9c45bd97c9af65dd72a682d9d704a2dafb563001f83.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n COUNT(*) FILTER (WHERE worker LIKE $1) as \"live_workers!\",\n COUNT(DISTINCT worker_instance) FILTER (WHERE worker LIKE $1) as \"live_instances!\",\n COUNT(*) FILTER (WHERE worker LIKE $2) as \"live_agent_workers!\"\n FROM worker_ping\n WHERE ping_at > now() - interval '30 seconds'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "live_workers!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "live_instances!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "live_agent_workers!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "4556f04f9da4adffb296b9c45bd97c9af65dd72a682d9d704a2dafb563001f83" +} diff --git a/backend/.sqlx/query-67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb.json b/backend/.sqlx/query-67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb.json new file mode 100644 index 0000000000..df8b5cce99 --- /dev/null +++ b/backend/.sqlx/query-67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name FROM workspace WHERE deleted = false ORDER BY name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb" +} diff --git a/backend/.sqlx/query-73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463.json b/backend/.sqlx/query-73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463.json new file mode 100644 index 0000000000..99621f180f --- /dev/null +++ b/backend/.sqlx/query-73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($2::text[] IS NULL OR workspace_id NOT IN (\n SELECT u FROM unnest($2::text[]) AS u WHERE u IS NOT NULL\n ))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463" +} diff --git a/backend/.sqlx/query-80e233d7db456ec7030486b009b6cfbf0fa45c2463b861c824fb9f1c0c46192f.json b/backend/.sqlx/query-80e233d7db456ec7030486b009b6cfbf0fa45c2463b861c824fb9f1c0c46192f.json new file mode 100644 index 0000000000..bdba1e899a --- /dev/null +++ b/backend/.sqlx/query-80e233d7db456ec7030486b009b6cfbf0fa45c2463b861c824fb9f1c0c46192f.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT setting::bigint as \"v!\" FROM pg_settings WHERE name = 'superuser_reserved_connections'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "v!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "80e233d7db456ec7030486b009b6cfbf0fa45c2463b861c824fb9f1c0c46192f" +} diff --git a/backend/.sqlx/query-8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d.json b/backend/.sqlx/query-8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d.json new file mode 100644 index 0000000000..1f90b91084 --- /dev/null +++ b/backend/.sqlx/query-8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM v2_job_completed\n WHERE workspace_id = $1\n AND completed_at <= now() - ($2::bigint::text || ' s')::interval", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d" +} diff --git a/backend/.sqlx/query-a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614.json b/backend/.sqlx/query-a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614.json deleted file mode 100644 index 24e387a783..0000000000 --- a/backend/.sqlx/query-a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($3::timestamptz IS NULL OR completed_at >= $3)\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "completed_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Timestamptz" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614" -} diff --git a/backend/.sqlx/query-a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json b/backend/.sqlx/query-a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json new file mode 100644 index 0000000000..43679e5287 --- /dev/null +++ b/backend/.sqlx/query-a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, scopes FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf" +} diff --git a/backend/.sqlx/query-b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c.json b/backend/.sqlx/query-b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c.json new file mode 100644 index 0000000000..5dbd6329b6 --- /dev/null +++ b/backend/.sqlx/query-b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE workspace_id = $4\n AND completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz)\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c" +} diff --git a/backend/.sqlx/query-b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781.json b/backend/.sqlx/query-b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781.json new file mode 100644 index 0000000000..ce93a35191 --- /dev/null +++ b/backend/.sqlx/query-b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace.id, workspace.name\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n WHERE usr.email = $1 AND usr.disabled = false AND workspace.deleted = false\n ORDER BY workspace.name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781" +} diff --git a/backend/.sqlx/query-b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json b/backend/.sqlx/query-b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json new file mode 100644 index 0000000000..d05cc5cdd4 --- /dev/null +++ b/backend/.sqlx/query-b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM variable\n WHERE workspace_id = $1 AND is_secret = true AND value != ''", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3" +} diff --git a/backend/.sqlx/query-c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json b/backend/.sqlx/query-c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json deleted file mode 100644 index b2e218e511..0000000000 --- a/backend/.sqlx/query-c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "completed_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8", - "UuidArray", - "Timestamptz" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75" -} diff --git a/backend/.sqlx/query-c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json b/backend/.sqlx/query-c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json new file mode 100644 index 0000000000..ffe341848c --- /dev/null +++ b/backend/.sqlx/query-c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz)\n AND ($5::text[] IS NULL OR jc.workspace_id NOT IN (\n SELECT u FROM unnest($5::text[]) AS u WHERE u IS NOT NULL\n ))\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "UuidArray", + "Timestamptz", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6" +} diff --git a/backend/.sqlx/query-e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9.json b/backend/.sqlx/query-e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9.json new file mode 100644 index 0000000000..10bf1a6061 --- /dev/null +++ b/backend/.sqlx/query-e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz)\n AND ($4::text[] IS NULL OR workspace_id NOT IN (\n SELECT u FROM unnest($4::text[]) AS u WHERE u IS NOT NULL\n ))\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamptz", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9" +} diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 24cf2cb837..96ad6a9d76 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -18,6 +18,86 @@ - **Running data pipelines (DuckLake) from source**: see the section below — a plain build advertises the `duckdb` tag but cannot execute DuckDB scripts and has no working S3 proxy. +## Cargo features & running the dev backend + +The dev backend runs under `cargo watch` and is launched by default with **only +`--features quickjs`** (see the tmux backend pane). That baseline compiles fast but +**deliberately omits most functionality** — notably S3/object storage, the S3 proxy, all +EE code, MCP, and every non-JS language runtime. A running server never gains a feature you +didn't compile in: feature-gated routes 404 or return a `"requires "` stub. So if +you touch code behind a feature gate, or need to *exercise* such a feature at runtime, you +MUST **restart the backend with the appropriate features** for what you're working on. + +### Restarting the dev backend with the right features + +The backend runs in tmux pane 1 as `cargo watch -x "run --features <…>"`. To restart it with a +different feature set — scope kills by pid/cwd, **never** `pkill -f target/debug/windmill` (it +kills every sibling worktree's backend): + +1. Stop the current run: `tmux send-keys -t C-c`, then kill *this worktree's* + `cargo-watch` pid (find it via `/proc//cwd`). +2. Relaunch in the same pane so it inherits the shell's `DATABASE_URL` etc.; the pane env's + `PORT` may be stale, so set it explicitly: + ```bash + export PORT=$BACKEND_PORT + cargo watch -x "run --features enterprise,private,parquet,quickjs" + ``` +3. Wait for `health check completed` in the pane before hitting the API. + +cargo-watch only re-runs on a file change, so after an idle/failed run `touch README.md` (from +`backend/`, where the watch runs) is a cheap retrigger (touching a `.rs` forces a full rebuild). + +### What each feature gate does (the ones you'll actually toggle) + +`backend/Cargo.toml` `[features]` is the source of truth; this is the practical dev map. Combine +only what you need — build time scales with the set. + +| Feature | Enables | Need it for | +|---|---|---| +| `quickjs` | Embedded JS engine for inline JS eval (the default dev baseline). | Keep in every dev set. | +| `private` | Compiles the `*_ee.rs` files (symlinked from `windmill-ee-private`). Gates **all** EE code, including the real S3 helpers, the S3 proxy, and advanced S3 permission checks. | Any EE code path, S3/object storage. | +| `enterprise` | EE business logic (autoscaling, SAML hooks, advanced S3 rule **enforcement**, WAP, forks, …). Pulls in `license`. | Running EE features. Advanced S3 permission rules only take effect with this. | +| `license` | License-key/plan plumbing (`LICENSE_KEY`). Pulled in by `enterprise`. Having the feature compiled does **not** require a license *key* at runtime — CE defaults to a free plan and most EE paths still run keyless. | License-gated behavior. | +| `parquet` | S3/object-storage support: the `job_helpers/*` and `apps_u/*` S3 endpoints, parquet/CSV preview, workspace large-file storage. Without it those routes return `"requires parquet"`. | Anything touching S3/object storage or datasets. | +| `duckdb` | DuckDB script executor (also needs the FFI dylib — see above). | DuckDB scripts, DuckLake. | +| `python` `rust` `php` `java` `ruby` `csharp` `nu` `deno_core` `mysql` `mssql` `bigquery` `oracledb` `rlang` | Each enables that language/DB runtime for job execution. | Running jobs in that language. | +| `mcp` | MCP gateway routes (baseline `quickjs` does NOT include it → MCP routes 404). | MCP work. | +| `websocket` `http_trigger` `kafka` `nats` `mqtt_trigger` `sqs_trigger` `gcp_trigger` `azure_trigger` `postgres_trigger` `native_trigger` | Each native trigger kind; none on by default (creating one 404s without its feature). | Working on / exercising that trigger. | +| `no_auth` | Treats every request as an admin superadmin (`CLOUD_HOSTED`-guarded). | Local auth-free experiments only. | + +Convenience bundles (`ce`, `ee`, `oss`, …) exist in `[features]` but are heavy — prefer the +minimal explicit set for dev. + +**Common combinations** (run from `backend/`): + +| Goal | `--features` | +|---|---| +| Plain dev baseline (JS eval only) | `quickjs` | +| S3 / object storage / datasets (CE) | `quickjs,private,parquet` | +| S3 + EE (advanced S3 rules, on-behalf app reads, WAP, forks) | `quickjs,enterprise,private,parquet` | +| DuckLake / DuckDB (CE) | `quickjs,duckdb,parquet,private` (+ build the FFI) | +| + Python jobs | append `,python` | + +## Workspace object storage in dev — use the local filesystem + +For a dev workspace you don't need MinIO/S3: use the built-in **`FilesystemStorage`** large-file +storage (a root path on local disk). It is intentionally hidden from the settings-UI storage +dropdown (dev-only), so set it via the API. Requires the backend built with `parquet` (+ `private` +for the real S3 helpers, + `enterprise` if you want advanced permission rules enforced): + +```bash +curl -X POST "$BASE/api/w//workspaces/edit_large_file_storage_config" \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"large_file_storage":{"type":"FilesystemStorage","root_path":"/abs/writable/dir", + "public_resource":false,"advanced_permissions":null,"secondary_storage":{}}}' +``` + +Optional `advanced_permissions` (EE) is a list of `{"pattern":"","allow":"read[,write,delete,list]"}` +rules: admins bypass them, non-admins are confined to matching grants. Uploads/reads then flow +through the normal `job_helpers/*` (viewer-scoped) and `apps_u/*` (app-author on-behalf) S3 +endpoints. Caveat: direct DuckDB access rejects filesystem stores (`"Filesystem is not supported +in DuckDB"`) — DuckLake/datatable go through the S3 proxy instead, which works. + ## Running data pipelines (DuckLake) from source DuckLake pipelines need **both** the right cargo features **and** the prebuilt DuckDB FFI. A diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 177509c02e..9ae2df8c80 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1890,18 +1890,18 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", @@ -1916,9 +1916,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -2057,9 +2057,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -6757,9 +6757,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ "hashbrown 0.17.1", ] @@ -7042,9 +7042,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -7243,7 +7243,7 @@ dependencies = [ "futures-sink", "futures-util", "keyed_priority_queue", - "lru 0.18.0", + "lru 0.18.1", "mysql_common", "native-tls", "pem 3.0.6", @@ -7628,11 +7628,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -9371,9 +9370,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -9383,9 +9382,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -9842,9 +9841,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" @@ -10149,9 +10148,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -10161,9 +10160,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "ryu-js" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" [[package]] name = "safetensors" @@ -10664,9 +10663,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -11191,9 +11190,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" dependencies = [ "bytes", "futures-util", @@ -12108,9 +12107,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -12249,9 +12248,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -13746,7 +13745,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-nats", @@ -13828,7 +13827,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.751.0" +version = "1.756.0" dependencies = [ "async-stream", "async-trait", @@ -13861,7 +13860,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13874,7 +13873,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "argon2", @@ -14012,7 +14011,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14035,7 +14034,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14050,7 +14049,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14076,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.751.0" +version = "1.756.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14086,7 +14085,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14103,7 +14102,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14125,7 +14124,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14148,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14164,7 +14163,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14185,7 +14184,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14206,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14220,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-nats", @@ -14255,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14280,7 +14279,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14298,7 +14297,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14319,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14340,7 +14339,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14377,7 +14376,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14405,7 +14404,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.751.0" +version = "1.756.0" dependencies = [ "lazy_static", "serde", @@ -14417,7 +14416,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.751.0" +version = "1.756.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14442,7 +14441,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14456,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.751.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14491,7 +14490,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.751.0" +version = "1.756.0" dependencies = [ "chrono", "lazy_static", @@ -14505,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14524,7 +14523,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.751.0" +version = "1.756.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14626,7 +14625,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.751.0" +version = "1.756.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14645,7 +14644,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.751.0" +version = "1.756.0" dependencies = [ "regex", "serde", @@ -14660,7 +14659,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14684,7 +14683,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "futures", @@ -14701,7 +14700,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.751.0" +version = "1.756.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14717,7 +14716,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -14738,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -14769,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "arc-swap", @@ -14794,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-stream", @@ -14828,7 +14827,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "futures", @@ -14846,7 +14845,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.751.0" +version = "1.756.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14855,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -14867,7 +14866,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -14879,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "gosyn", @@ -14891,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -14903,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -14915,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "nu-parser", @@ -14926,7 +14925,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14937,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14949,7 +14948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14960,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -14982,7 +14981,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -14994,7 +14993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -15008,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15025,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -15050,7 +15049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15084,7 +15083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15100,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -15111,7 +15110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -15150,7 +15149,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "const_format", @@ -15189,7 +15188,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.751.0" +version = "1.756.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15200,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -15234,7 +15233,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15258,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15291,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15324,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15344,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15378,7 +15377,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15414,7 +15413,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15461,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-nats", @@ -15485,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15520,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15548,7 +15547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15573,7 +15572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15592,7 +15591,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-once-cell", @@ -15702,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.751.0" +version = "1.756.0" dependencies = [ "bytes", "futures", @@ -16428,18 +16427,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -16520,9 +16519,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 922fb27c09..ab4d8bf345 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.751.0" +version = "1.756.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.751.0" +version = "1.756.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 1b702a3a54..589a683dba 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -27672e37df5d9dfde94f19963d5ffcdf8dd5448c +2ba6a2a75b6fc97858b306b2c98ada481e363c10 diff --git a/backend/migrations/20260710073406_index_v2_job_parent_job.down.sql b/backend/migrations/20260710073406_index_v2_job_parent_job.down.sql new file mode 100644 index 0000000000..86d52ecae7 --- /dev/null +++ b/backend/migrations/20260710073406_index_v2_job_parent_job.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS ix_v2_job_parent_job; diff --git a/backend/migrations/20260710073406_index_v2_job_parent_job.up.sql b/backend/migrations/20260710073406_index_v2_job_parent_job.up.sql new file mode 100644 index 0000000000..b4771e7aa5 --- /dev/null +++ b/backend/migrations/20260710073406_index_v2_job_parent_job.up.sql @@ -0,0 +1,11 @@ +-- Partial index for listing a run's child jobs (flow steps, loop iterations, +-- native-retry attempts, schedule handlers) via the `parent_job = ?` filter on +-- /jobs/list and /jobs/completed/list. Without it, Postgres walks the whole +-- workspace (workspace_id, created_at) timeline filtering row-by-row for the +-- parent. Children of one parent are few, so (parent_job, created_at DESC) +-- returns them directly and serves both ASC and DESC orderings. +-- Partial on parent_job IS NOT NULL keeps it small (root jobs are the majority). +-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs. +CREATE INDEX IF NOT EXISTS ix_v2_job_parent_job + ON v2_job (parent_job, created_at DESC) + WHERE parent_job IS NOT NULL; diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 9707e0b6d4..ae54e3f521 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.751.0" +version = "1.756.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.751.0" +version = "1.756.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.751.0" +version = "1.756.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.751.0" +version = "1.756.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index a0b5a09e27..edf76878e7 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.751.0" +version = "1.756.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index deda032058..d4dee19f82 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -3,9 +3,11 @@ use windmill_common::{ get_database_url, DatabaseUrl, }; -pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50; -pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5; -pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5; +// Single source of truth in windmill_common so the DB-health sizing guidance +// (windmill-api/src/db_health.rs) and the actual pool sizing here can't drift. +pub use windmill_common::{ + DEFAULT_MAX_CONNECTIONS_INDEXER, DEFAULT_MAX_CONNECTIONS_SERVER, DEFAULT_MAX_CONNECTIONS_WORKER, +}; #[cfg(feature = "operator")] pub const DEFAULT_MAX_CONNECTIONS_OPERATOR: u32 = 2; diff --git a/backend/src/main.rs b/backend/src/main.rs index 4115e2a12b..7080ae0c13 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -56,11 +56,11 @@ use windmill_common::{ PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, - RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, - SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, - SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING, - SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, - STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, + SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, + SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, @@ -124,7 +124,7 @@ use windmill_worker::{ use crate::monitor::{ initial_load, load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled, load_preview_tags_override, - load_require_preexisting_user, load_tag_per_workspace_enabled, + load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, load_workspace_fairness_enabled, load_workspace_fairness_max_percent, load_workspace_fairness_min_total, monitor_db, reload_app_workspaced_route_setting, @@ -1881,6 +1881,11 @@ async fn process_notify_event( } TIMEOUT_WAIT_RESULT_SETTING => reload_timeout_wait_result_setting(conn).await, RETENTION_PERIOD_SECS_SETTING => reload_retention_period_setting(conn).await, + RETENTION_PERIOD_SECS_OVERRIDES_SETTING => { + if let Err(e) = load_retention_period_overrides(db).await { + tracing::error!("Error loading per-workspace retention overrides: {e:#}"); + } + } AUDIT_LOG_RETENTION_DAYS_SETTING => { reload_audit_log_retention_days_setting(conn).await } diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 60b4024c0a..6632e3b3a8 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -94,7 +94,8 @@ use windmill_common::{ }, KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, - DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED, + DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS, JOB_RETENTION_SECS_OVERRIDES, + JOB_RETENTION_SECS_OVERRIDES_LOADED, METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, STORE_AUDIT_LOGS_S3, }; @@ -265,6 +266,12 @@ pub async fn initial_load( tracing::error!("Error loading preview tags override: {e:#}"); } + // Load per-workspace retention overrides before the first cleanup tick so a fresh server + // never sweeps globally without honoring configured longer-retention workspaces. + if let Err(e) = load_retention_period_overrides(db).await { + tracing::error!("Error loading per-workspace retention overrides: {e:#}"); + } + // Workspace fairness (cloud-only). Load the percentage/duration/min knobs // *before* the enabled flag so that `load_workspace_fairness_enabled` reads // current values when re-storing the pull queries. @@ -1339,68 +1346,73 @@ pub async fn delete_expired_items(db: &DB) -> () { ), } - let job_retention_secs = JOB_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed); - if job_retention_secs > 0 { - let batch_size = *JOB_CLEANUP_BATCH_SIZE; - let max_batches = *JOB_CLEANUP_MAX_BATCHES; - let cleanup_start = Instant::now(); - let mut total_deleted = 0u64; - let mut batch_num = 0i32; - // Watermark carried across batches so each one resumes after the rows the previous batch - // already processed instead of re-scanning the (potentially undeletable) oldest prefix. - let mut completed_at_floor: Option> = None; + // Per-workspace retention overrides (EE-only; the cache is always empty on CE). A workspace may + // keep jobs LONGER or SHORTER than the instance-wide window. Phase 1 sweeps globally on the + // instance window but excludes override workspaces; Phase 2 sweeps each override workspace on its + // own window (a sargable `workspace_id = $w` scan). The override count is capped small + // (`MAX_RETENTION_OVERRIDE_WORKSPACES`), so Phase 2's per-workspace fan-out stays bounded. + // + // Deliberate simplicity/scale trade-off: a LONGER or keep-forever override lets that workspace's + // old rows accumulate at the front of the completed_at index, and Phase 1's first batch each tick + // scans past that retained prefix (an index scan, thanks to the sargable floor — not a Seq Scan) + // before reaching a deletable row. This is only material at extreme scale (millions of retained + // rows on one busy keep-forever workspace); we accept it rather than carrying a cross-tick + // watermark, given overrides are a capped, targeted escape hatch. + // + // Gate the whole sweep on a confirmed-known override set: if the load never succeeded (e.g. a + // startup DB hiccup, or malformed data), the empty cache is "unknown", not "no overrides", and + // sweeping globally would delete jobs a longer-retention workspace configured. Retry the load + // once here (on CE the flag is already set at startup, so this is a no-op), and skip the whole + // job-cleanup phase this tick if still unknown — it runs again shortly. + if !JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed) { + if let Err(e) = load_retention_period_overrides(db).await { + tracing::error!("Error (re)loading per-workspace retention overrides: {e:#}"); + } + } + if !JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed) { + tracing::error!( + "Skipping job retention cleanup this cycle: per-workspace overrides not yet loaded" + ); + } else { + let job_retention_secs = JOB_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed); + // `load_full` (owned Arc) rather than `load` (Guard): the sweep below holds this across many + // `.await`s, and an arc_swap Guard is not meant to be held for long. + let retention_overrides = JOB_RETENTION_SECS_OVERRIDES.load_full(); + let override_workspace_ids: Vec = retention_overrides.keys().cloned().collect(); - // Process batches until no more expired jobs or max batches reached - loop { - if max_batches > 0 && batch_num >= max_batches { - tracing::debug!( - "Job cleanup: reached max batches limit ({}), will continue next iteration", - max_batches - ); - break; + // Phase 1: global sweep with the instance window, skipping override workspaces. + if job_retention_secs > 0 { + run_retention_cleanup( + db, + job_retention_secs, + RetentionScope::GlobalExcluding(&override_workspace_ids), + ) + .await; + + // Clean up concurrency keys separately (not tied to specific job IDs). Kept global on + // the instance window — concurrency keys are short-lived and not worth per-workspace + // scoping. + if let Err(e) = sqlx::query!( + "DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval", + job_retention_secs + ) + .execute(db) + .await + { + tracing::error!("Error deleting custom concurrency key: {:?}", e); } + } - // Each batch runs in its own transaction to avoid long-running locks - let batch_result = - delete_expired_jobs_batch(db, job_retention_secs, batch_size, completed_at_floor) + // Phase 2: each override workspace swept on its own window. A window of 0 means "keep + // forever" for that workspace, so it is excluded from Phase 1 above and skipped here. The + // override count is capped at MAX_RETENTION_OVERRIDE_WORKSPACES (enforced at write time), so + // this loop runs a bounded number of scoped sweeps per pass. + for (w_id, retention_secs) in retention_overrides.iter() { + if *retention_secs > 0 { + run_retention_cleanup(db, *retention_secs, RetentionScope::OnlyWorkspace(w_id)) .await; - - match batch_result { - Ok((deleted_count, max_completed_at)) => { - if deleted_count == 0 { - // No more expired jobs to delete - break; - } - completed_at_floor = max_completed_at.or(completed_at_floor); - total_deleted += deleted_count as u64; - batch_num += 1; - } - Err(e) => { - tracing::error!("Error in job cleanup batch {}: {:?}", batch_num, e); - break; - } } } - - if total_deleted > 0 { - tracing::info!( - "Job cleanup completed: deleted {} jobs in {} batches, took {:?}", - total_deleted, - batch_num, - cleanup_start.elapsed() - ); - } - - // Clean up concurrency keys separately (not tied to specific job IDs) - if let Err(e) = sqlx::query!( - "DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval", - job_retention_secs - ) - .execute(db) - .await - { - tracing::error!("Error deleting custom concurrency key: {:?}", e); - } } match windmill_common::trashbin::delete_expired_trash(db).await { @@ -1546,11 +1558,18 @@ pub async fn check_expiring_tokens(db: &DB) { /// /// Returns `(jobs deleted in this batch, max completed_at deleted)`. The caller feeds the /// returned watermark back in as `completed_at_floor` for the next batch. +/// +/// `only_workspace` and `exclude_workspaces` implement the per-workspace retention override and are +/// mutually exclusive: Phase 1 passes `exclude_workspaces` (skip override workspaces, sweep the +/// rest), Phase 2 passes `only_workspace` (sweep just that workspace on its own window). Both `None` +/// reproduces the plain global sweep exactly. See `run_retention_cleanup` / `delete_expired_items`. async fn delete_expired_jobs_batch( db: &DB, job_retention_secs: i64, batch_size: i64, completed_at_floor: Option>, + only_workspace: Option<&str>, + exclude_workspaces: Option<&[String]>, ) -> error::Result<(usize, Option>)> { let mut tx = db.begin().await?; @@ -1571,65 +1590,142 @@ async fn delete_expired_jobs_batch( // max(completed_at) deleted by the previous batch. Re-applying it as `completed_at >= floor` // lets each batch resume after the rows the previous batch already processed instead of // re-scanning them. This matters when the oldest rows are undeletable (children of a - // still-active root flow): without the floor the `ORDER BY completed_at ASC` scan walks that - // same protected prefix on every batch, turning a cleanup run quadratic in prefix size. + // still-active root flow, or override workspaces excluded from the global sweep): without the + // floor the `ORDER BY completed_at ASC` scan walks that same protected/retained prefix on every + // batch, turning a cleanup run quadratic in prefix size. // Floor only ever skips rows the current run already deleted, was protecting, or skip-locked — // all correctly deferred to the next run, identical to the unbounded scan's semantics. // + // It is applied as `completed_at >= COALESCE($floor, '-infinity')`, NOT `$floor IS NULL OR + // completed_at >= $floor`: the `OR ... IS NULL` disjunction is non-sargable, so the planner + // cannot use the floor as an index lower bound and falls back to a Seq Scan of the whole table — + // walking the entire prefix regardless of the floor. The COALESCE sentinel keeps a single cached + // query while making the bound a plain range predicate the completed_at / composite index drives. + // // Use FOR UPDATE SKIP LOCKED to avoid contention between replicas; ORDER BY completed_at // deletes oldest jobs first. - let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() { - // Common case: no old root flow is still running, so nothing is protected and the - // v2_job join (a PK lookup per candidate) is pure overhead — skip it entirely. - let rows = sqlx::query!( - "DELETE FROM v2_job_completed - WHERE id IN ( - SELECT id FROM v2_job_completed - WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval - AND ($3::timestamptz IS NULL OR completed_at >= $3) - ORDER BY completed_at ASC - LIMIT $2 - FOR UPDATE SKIP LOCKED - ) - RETURNING id, completed_at", - job_retention_secs, - batch_size, - completed_at_floor, - ) - .fetch_all(&mut *tx) - .await?; - let max = rows.iter().map(|r| r.completed_at).max(); - (rows.into_iter().map(|r| r.id).collect::>(), max) - } else { - // Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than `!= ALL($3)`: - // the subquery form lets the planner build a one-time hashed SubPlan and apply it as a - // filter on the ordered index scan, giving O(1) membership per candidate instead of a - // per-row linear array scan (which degrades sharply when many root jobs are active). The - // `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids). - let rows = sqlx::query!( - "DELETE FROM v2_job_completed - WHERE id IN ( - SELECT jc.id FROM v2_job_completed jc - LEFT JOIN v2_job j ON j.id = jc.id - WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval - AND ($4::timestamptz IS NULL OR jc.completed_at >= $4) - AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( - SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL - ) - ORDER BY jc.completed_at ASC - LIMIT $2 - FOR UPDATE OF jc SKIP LOCKED - ) - RETURNING id, completed_at", - job_retention_secs, - batch_size, - &active_root_job_ids, - completed_at_floor, - ) - .fetch_all(&mut *tx) - .await?; - let max = rows.iter().map(|r| r.completed_at).max(); - (rows.into_iter().map(|r| r.id).collect::>(), max) + // Two orthogonal choices drive which DELETE we run: + // - `only_workspace`: Some => a single-workspace (Phase 2) sweep. We bind `workspace_id = $n` + // directly (no `OR $n IS NULL` guard) so the composite `(workspace_id, completed_at)` index + // can drive the ordered scan — a sargable equality the OR-form would defeat. `None` => a + // global (Phase 1) sweep that instead excludes override workspaces via a hashed `NOT IN + // (SELECT ... unnest($exclude))` SubPlan (same one-time-hash trick as the active-root + // exclusion below): O(1) membership per candidate, vs `<> ALL($exclude)`'s per-row linear + // array scan which degrades sharply once many workspaces have overrides. + // - `active_root_job_ids.is_empty()`: skip the `v2_job` join entirely when nothing is + // protected (a PK lookup per candidate is pure overhead in the common case). + let (deleted_jobs, max_completed_at) = match only_workspace { + Some(w_id) if active_root_job_ids.is_empty() => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT id FROM v2_job_completed + WHERE workspace_id = $4 + AND completed_at <= now() - ($1::bigint::text || ' s')::interval + AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz) + ORDER BY completed_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + completed_at_floor, + w_id, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + Some(w_id) => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT jc.id FROM v2_job_completed jc + LEFT JOIN v2_job j ON j.id = jc.id + WHERE jc.workspace_id = $5 + AND jc.completed_at <= now() - ($1::bigint::text || ' s')::interval + AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz) + AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( + SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL + ) + ORDER BY jc.completed_at ASC + LIMIT $2 + FOR UPDATE OF jc SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + &active_root_job_ids, + completed_at_floor, + w_id, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + None if active_root_job_ids.is_empty() => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT id FROM v2_job_completed + WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval + AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz) + AND ($4::text[] IS NULL OR workspace_id NOT IN ( + SELECT u FROM unnest($4::text[]) AS u WHERE u IS NOT NULL + )) + ORDER BY completed_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + completed_at_floor, + exclude_workspaces, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + None => { + // Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than `!= ALL($3)`: + // the subquery form lets the planner build a one-time hashed SubPlan and apply it as a + // filter on the ordered index scan, giving O(1) membership per candidate instead of a + // per-row linear array scan (which degrades sharply when many root jobs are active). The + // `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids). + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT jc.id FROM v2_job_completed jc + LEFT JOIN v2_job j ON j.id = jc.id + WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval + AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz) + AND ($5::text[] IS NULL OR jc.workspace_id NOT IN ( + SELECT u FROM unnest($5::text[]) AS u WHERE u IS NOT NULL + )) + AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( + SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL + ) + ORDER BY jc.completed_at ASC + LIMIT $2 + FOR UPDATE OF jc SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + &active_root_job_ids, + completed_at_floor, + exclude_workspaces, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } }; let deleted_count = deleted_jobs.len(); @@ -1704,6 +1800,189 @@ async fn delete_expired_jobs_batch( Ok((deleted_count, max_completed_at)) } +/// Which workspaces a retention cleanup run targets. +#[derive(Debug)] +enum RetentionScope<'a> { + /// Sweep every workspace except the listed ones (they run in their own Phase-2 pass). + GlobalExcluding(&'a [String]), + /// Sweep only this single workspace, on its own retention window. + OnlyWorkspace(&'a str), +} + +/// Drives the batched job-retention delete for a given `retention_secs` window and `scope`. +/// Preserves the per-run `completed_at_floor` watermark across batches (see +/// `delete_expired_jobs_batch`). Returns the number of jobs deleted. +/// +/// `JOB_CLEANUP_MAX_BATCHES` bounds the batches per call, i.e. per scope. A full cleanup cycle can +/// therefore run up to `(1 + n_override_workspaces) * max_batches` batches; the override count is +/// capped at `MAX_RETENTION_OVERRIDE_WORKSPACES`, and any residue is picked up on the next tick. +async fn run_retention_cleanup(db: &DB, retention_secs: i64, scope: RetentionScope<'_>) -> u64 { + let (only_workspace, exclude_workspaces): (Option<&str>, Option<&[String]>) = match &scope { + // An empty exclusion list binds as NULL so the guard short-circuits to the plain sweep. + RetentionScope::GlobalExcluding(ids) => { + (None, if ids.is_empty() { None } else { Some(*ids) }) + } + RetentionScope::OnlyWorkspace(w_id) => (Some(*w_id), None), + }; + + let batch_size = *JOB_CLEANUP_BATCH_SIZE; + let max_batches = *JOB_CLEANUP_MAX_BATCHES; + let cleanup_start = Instant::now(); + let mut total_deleted = 0u64; + let mut batch_num = 0i32; + // Watermark carried across batches so each one resumes after the rows the previous batch + // already processed instead of re-scanning the (potentially undeletable) oldest prefix. + let mut completed_at_floor: Option> = None; + + // Process batches until no more expired jobs or max batches reached + loop { + if max_batches > 0 && batch_num >= max_batches { + tracing::debug!( + "Job cleanup ({scope:?}): reached max batches limit ({max_batches}), will continue next iteration" + ); + break; + } + + // Each batch runs in its own transaction to avoid long-running locks + let batch_result = delete_expired_jobs_batch( + db, + retention_secs, + batch_size, + completed_at_floor, + only_workspace, + exclude_workspaces, + ) + .await; + + match batch_result { + Ok((deleted_count, max_completed_at)) => { + if deleted_count == 0 { + // No more expired jobs to delete + break; + } + completed_at_floor = max_completed_at.or(completed_at_floor); + total_deleted += deleted_count as u64; + batch_num += 1; + } + Err(e) => { + tracing::error!("Error in job cleanup batch {batch_num} ({scope:?}): {e:?}"); + break; + } + } + } + + if total_deleted > 0 { + tracing::info!( + "Job cleanup completed ({scope:?}): deleted {total_deleted} jobs in {batch_num} batches, took {:?}", + cleanup_start.elapsed() + ); + } + + total_deleted +} + +/// Parses the raw `{workspace_id: seconds}` global-setting object into an override map. Returns +/// `Err` (with the offending workspace) if ANY value is not a non-negative integer, so the caller +/// can keep the last-good map instead of dropping just that entry — dropping a longer-retention +/// entry would let the Phase-1 global window delete its jobs, and a negative value would silently +/// become keep-forever (Phase 2 only sweeps `> 0`). +#[cfg(feature = "enterprise")] +fn parse_retention_overrides( + map: serde_json::Map, +) -> std::result::Result, String> { + use windmill_common::global_settings::MAX_RETENTION_OVERRIDE_WORKSPACES; + if map.len() > MAX_RETENTION_OVERRIDE_WORKSPACES { + return Err(format!( + "at most {MAX_RETENTION_OVERRIDE_WORKSPACES} per-workspace retention overrides are allowed, got {}", + map.len() + )); + } + let mut overrides = std::collections::HashMap::with_capacity(map.len()); + for (w_id, v) in map { + match v.as_i64() { + Some(secs) if secs >= 0 => { + overrides.insert(w_id, secs); + } + _ => { + return Err(format!( + "override for '{w_id}' must be a non-negative integer number of seconds, got {v}" + )); + } + } + } + Ok(overrides) +} + +/// Loads the per-workspace retention overrides from the `retention_period_secs_overrides` global +/// setting (a JSON `{workspace_id: secs}` object) into the in-memory `JOB_RETENTION_SECS_OVERRIDES` +/// cache, so the cleanup sweep reads them without a per-tick DB query. Enterprise-only — CE leaves +/// the cache empty so the sweep behaves exactly as before. +/// +/// On a load error, unexpected value shape, or malformed data the previous map is kept but +/// `JOB_RETENTION_SECS_OVERRIDES_LOADED` is set to FALSE, marking the cache unknown. Clobbering the +/// map to empty would let the global sweep delete jobs a workspace asked to keep longer; leaving the +/// flag TRUE would keep the stale (possibly shorter) policy in force after a lengthened/added +/// override fails to refresh, deleting those jobs prematurely. Marking it unknown makes the sweep +/// fail closed — it skips and the monitor retries the load next tick until a confirmed-current state +/// loads. `LOADED` is set true only on a valid map, explicit unset (`Ok(None)`), or CE's no-op. +pub async fn load_retention_period_overrides(db: &DB) -> error::Result<()> { + #[cfg(not(feature = "enterprise"))] + { + let _ = db; + // Overrides are EE-only; empty is the correct, fully-known state on CE. + JOB_RETENTION_SECS_OVERRIDES_LOADED.store(true, std::sync::atomic::Ordering::Relaxed); + } + #[cfg(feature = "enterprise")] + { + use windmill_common::global_settings::RETENTION_PERIOD_SECS_OVERRIDES_SETTING; + let value = + load_value_from_global_settings(db, RETENTION_PERIOD_SECS_OVERRIDES_SETTING).await; + match value { + Ok(Some(serde_json::Value::Object(map))) => match parse_retention_overrides(map) { + Ok(overrides) => { + JOB_RETENTION_SECS_OVERRIDES.store(std::sync::Arc::new(overrides)); + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(true, std::sync::atomic::Ordering::Relaxed); + } + // Malformed persisted value: we can't confirm the current override set. Keep the + // last-good map but mark the cache unknown so the sweep fails closed (skips) and + // retries, rather than deleting with a stale — possibly shorter — policy. + Err(reason) => { + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(false, std::sync::atomic::Ordering::Relaxed); + tracing::error!( + "Malformed per-workspace retention overrides, gating cleanup until it loads: {reason}" + ); + } + }, + Ok(None) => { + // Explicit unset is a known state: no overrides. + JOB_RETENTION_SECS_OVERRIDES + .store(std::sync::Arc::new(std::collections::HashMap::new())); + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(true, std::sync::atomic::Ordering::Relaxed); + } + // Unexpected shape / read failure: mark unknown so a lengthened or added override that + // failed to refresh can't be missed by a sweep still running the previous policy. + Ok(Some(other)) => { + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(false, std::sync::atomic::Ordering::Relaxed); + tracing::error!( + "Per-workspace retention overrides setting is not a JSON object (got {other}); gating cleanup until it loads" + ); + } + Err(e) => { + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(false, std::sync::atomic::Ordering::Relaxed); + tracing::error!( + "Error loading per-workspace retention overrides, gating cleanup until it loads: {e:#}" + ); + } + } + } + Ok(()) +} + async fn delete_log_files_from_disk_and_store( paths_to_delete: Vec, tmp_dir: &str, @@ -4734,3 +5013,54 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) { } } } + +#[cfg(all(test, feature = "enterprise"))] +mod retention_overrides_tests { + use super::parse_retention_overrides; + use serde_json::json; + + fn obj(v: serde_json::Value) -> serde_json::Map { + v.as_object().unwrap().clone() + } + + #[test] + fn parses_valid_map() { + let m = parse_retention_overrides(obj(json!({"a": 3600, "b": 0}))).unwrap(); + assert_eq!(m.get("a"), Some(&3600)); + assert_eq!(m.get("b"), Some(&0)); // 0 = keep forever, allowed + assert_eq!(m.len(), 2); + } + + #[test] + fn empty_map_is_ok() { + assert!(parse_retention_overrides(obj(json!({}))) + .unwrap() + .is_empty()); + } + + #[test] + fn rejects_negative() { + // A negative value must not silently become keep-forever; the whole map is rejected. + assert!(parse_retention_overrides(obj(json!({"a": 3600, "b": -1}))).is_err()); + } + + #[test] + fn rejects_non_integer() { + assert!(parse_retention_overrides(obj(json!({"a": "3600"}))).is_err()); + assert!(parse_retention_overrides(obj(json!({"a": 3600.5}))).is_err()); + assert!(parse_retention_overrides(obj(json!({"a": null}))).is_err()); + } + + #[test] + fn rejects_too_many_overrides() { + use windmill_common::global_settings::MAX_RETENTION_OVERRIDE_WORKSPACES; + let at_cap: serde_json::Map<_, _> = (0..MAX_RETENTION_OVERRIDE_WORKSPACES) + .map(|i| (format!("ws_{i}"), json!(3600))) + .collect(); + assert!(parse_retention_overrides(at_cap.clone()).is_ok()); + let over_cap: serde_json::Map<_, _> = (0..MAX_RETENTION_OVERRIDE_WORKSPACES + 1) + .map(|i| (format!("ws_{i}"), json!(3600))) + .collect(); + assert!(parse_retention_overrides(over_cap).is_err()); + } +} diff --git a/backend/tests/agent_workers.rs b/backend/tests/agent_workers.rs index 6ec3f9e410..791556b035 100644 --- a/backend/tests/agent_workers.rs +++ b/backend/tests/agent_workers.rs @@ -22,7 +22,7 @@ fn bun_code(code: &str) -> RawCode { .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, - tag: None, + tag: None, } } diff --git a/backend/tests/app_s3_onbehalf.rs b/backend/tests/app_s3_onbehalf.rs new file mode 100644 index 0000000000..80c8d5a5f7 --- /dev/null +++ b/backend/tests/app_s3_onbehalf.rs @@ -0,0 +1,145 @@ +//! Deployed-app S3 reads authorize on-behalf of the app author and are confined +//! to app provenance (declared keys or recent job outputs): a viewer cannot read +//! an arbitrary `file_key` as the author. Requires the `parquet` feature — the +//! real `apps_u/*` S3 handlers are gated on it. +//! +//! `base` fixture: test-user (admin, SECRET_TOKEN); test-user-2 (non-admin, +//! SECRET_TOKEN_2, no S3 folder permission). +#![cfg(feature = "parquet")] + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; +const USER_TOKEN: &str = "SECRET_TOKEN_2"; +const APP: &str = "u/test-user/s3onbehalf"; +const DECLARED: &str = "provenance/allowed.csv"; +const NON_PROVENANCE: &str = "evil/secret.csv"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +#[sqlx::test(fixtures("base"))] +async fn test_deployed_app_s3_onbehalf_provenance(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + // `on_behalf_of` is auto-set to the creator (admin) for an anonymous app, so + // the app reads S3 as that author; `DECLARED` is the only allowlisted key. + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP, + "summary": "s3 onbehalf test", + "value": {}, + "policy": { + "execution_mode": "anonymous", + "triggerables": {}, + "allowed_s3_keys": [{ "s3_path": DECLARED }] + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?); + + // GET an app-scoped S3 route as `token`. No workspace storage is configured, + // so a request that clears the provenance gate fails later at the storage + // lookup (or the CE OSS stub), never with "File restricted" — which is what + // lets these assertions distinguish "gate passed" from "gate denied". + let get = |route: &str, token: &'static str| { + let url = format!("{ws}/apps_u/{route}"); + authed(client().get(url), token).send() + }; + let denied = |body: &str| body.contains("File restricted"); + + // download_s3_file: author-on-behalf allowed for the declared key, denied for + // a key the app never declared (the confused-deputy guard). + let body = get(&format!("download_s3_file/{APP}?s3={DECLARED}"), USER_TOKEN) + .await? + .text() + .await?; + assert!(!denied(&body), "declared key must clear the gate: {body}"); + let body = get( + &format!("download_s3_file/{APP}?s3={NON_PROVENANCE}"), + USER_TOKEN, + ) + .await? + .text() + .await?; + assert!(denied(&body), "non-provenance key must be denied: {body}"); + + // load_table_count and load_csv_preview enforce the same gate. The preview's + // numeric `limit`/`offset` must deserialize (regression: a flattened query + // struct 400s on them under serde_urlencoded). + let body = get( + &format!("load_table_count/{APP}?file_key={DECLARED}"), + USER_TOKEN, + ) + .await? + .text() + .await?; + assert!( + !denied(&body), + "table_count declared key must clear the gate: {body}" + ); + let body = get( + &format!("load_table_count/{APP}?file_key={NON_PROVENANCE}"), + USER_TOKEN, + ) + .await? + .text() + .await?; + assert!( + denied(&body), + "table_count non-provenance key must be denied: {body}" + ); + + let resp = get( + &format!("load_csv_preview/{APP}?file_key={DECLARED}&limit=5&offset=0"), + USER_TOKEN, + ) + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_ne!(status, 400, "numeric limit/offset must deserialize: {body}"); + assert!( + !denied(&body), + "csv_preview declared key must clear the gate: {body}" + ); + + // load_file_preview: `read_bytes_from` / `read_bytes_length` are required. + let resp = get( + &format!("load_file_preview/{APP}?file_key={DECLARED}"), + USER_TOKEN, + ) + .await?; + assert_eq!( + resp.status(), + 400, + "file_preview without byte range must 400: {}", + resp.text().await? + ); + let body = get( + &format!( + "load_file_preview/{APP}?file_key={DECLARED}&read_bytes_from=0&read_bytes_length=4096" + ), + USER_TOKEN, + ) + .await? + .text() + .await?; + assert!( + !denied(&body), + "file_preview declared key must clear the gate: {body}" + ); + + Ok(()) +} diff --git a/backend/tests/script_rename_clears_assets.rs b/backend/tests/script_rename_clears_assets.rs new file mode 100644 index 0000000000..315deaf152 --- /dev/null +++ b/backend/tests/script_rename_clears_assets.rs @@ -0,0 +1,106 @@ +//! Regression test: renaming a script clears the OLD path's static asset +//! usage rows. +//! +//! A script deploy persists its producer/consumer asset lineage as `asset` +//! rows keyed by `usage_path = {#if helperScript} - +
{#if inputType === 'dynmultiselect'} diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index f5062bc008..1e6a29388f 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -117,6 +117,10 @@ right?: import('svelte').Snippet openAiChat?: boolean moduleId?: string + // Workspace to scope variable/resource/data-table lookups to. Defaults to + // the nav `$workspaceStore`; an AI-session live editor passes the session's + // acting workspace (a fork) so the helper pickers hit the right workspace. + workspace?: string } let { @@ -141,9 +145,12 @@ showHistoryDrawer = $bindable(false), right, openAiChat = false, - moduleId = undefined + moduleId = undefined, + workspace = undefined }: Props = $props() + let ws = $derived(workspace ?? $workspaceStore) + let contextualVariablePicker: ItemPicker | undefined = $state() let variablePicker: ItemPicker | undefined = $state() let resourcePicker: ItemPicker | undefined = $state() @@ -350,12 +357,12 @@ }) async function loadVariables() { - return await VariableService.listVariable({ workspace: $workspaceStore ?? '' }) + return await VariableService.listVariable({ workspace: ws ?? '' }) } async function loadContextualVariables() { return await VariableService.listContextualVariables({ - workspace: $workspaceStore ?? 'NO_W' + workspace: ws ?? 'NO_W' }) } @@ -366,7 +373,7 @@ async function onScriptPick(e: { detail: { path: string } }) { codeObj = undefined codeViewer?.openDrawer?.() - codeObj = await getScriptByPath(e.detail.path ?? '') + codeObj = await getScriptByPath(e.detail.path ?? '', ws) } const dispatch = createEventDispatcher() @@ -423,7 +430,7 @@ async function resourceTypePickCallback(name: string) { if (!editor) return const resourceType = await ResourceService.getResourceType({ - workspace: $workspaceStore ?? 'NO_W', + workspace: ws ?? 'NO_W', path: name }) @@ -785,8 +792,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS buttons={{ 'Edit/View': (x) => resourceEditor?.initEdit(x) }} extraField="description" extraField2="resource_type" - loadItems={async () => - await ResourceService.listResource({ workspace: $workspaceStore ?? 'NO_W' })} + loadItems={async () => await ResourceService.listResource({ workspace: ws ?? 'NO_W' })} > {#snippet submission()}
@@ -812,12 +818,15 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types" itemName="Resource Type" extraField="name" - loadItems={async () => - await ResourceService.listResourceType({ workspace: $workspaceStore ?? 'NO_W' })} + loadItems={async () => await ResourceService.listResourceType({ workspace: ws ?? 'NO_W' })} /> {/if} - - + + {#if showDucklakePicker} - (await WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? 'NO_W' })).map( - (path) => ({ path }) - )} + (await WorkspaceService.listDucklakes({ workspace: ws ?? 'NO_W' })).map((path) => ({ path }))} > {#snippet submission()}
@@ -885,9 +892,9 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS documentationLink="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables" itemName="data table" loadItems={async () => - (await WorkspaceService.listDataTables({ workspace: $workspaceStore ?? 'NO_W' })).map( - (d) => ({ path: d.name }) - )} + (await WorkspaceService.listDataTables({ workspace: ws ?? 'NO_W' })).map((d) => ({ + path: d.name + }))} > {#snippet submission()}
@@ -923,7 +930,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS extraField2="resource_type" loadItems={async () => await ResourceService.listResource({ - workspace: $workspaceStore ?? 'NO_W', + workspace: ws ?? 'NO_W', resourceType: 'postgresql,mysql,bigquery' })} > diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 87a0c6e1da..fdf18b4b50 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -43,6 +43,14 @@ * inline. Breadcrumb navigation still works — only the rename UI is * gated. */ pathEditable?: boolean + /** When true, the whole path/breadcrumb row (and its pen popover) is + * dropped, leaving only the summary. Used by the condensed session- + * preview top bar to save vertical room. */ + hidePath?: boolean + /** Workspace whose items the breadcrumb picker lists. Session live + * editors pass their acting workspace so the picker isn't scoped to the + * navigation workspace; falls back to $workspaceStore in the picker. */ + workspaceId?: string } let { @@ -55,7 +63,9 @@ onBehalfOfEmail, penVisibility = 'hover', summaryEditable = true, - pathEditable = true + pathEditable = true, + hidePath = false, + workspaceId }: Props = $props() let pathPopoverOpen = $state(false) @@ -130,106 +140,112 @@
-
-
+ {/snippet} + + {/if} +
+ {/if}
diff --git a/frontend/src/lib/components/ExploreAssetButton.svelte b/frontend/src/lib/components/ExploreAssetButton.svelte index 4b67fd3abc..45d378fd86 100644 --- a/frontend/src/lib/components/ExploreAssetButton.svelte +++ b/frontend/src/lib/components/ExploreAssetButton.svelte @@ -33,7 +33,8 @@ noText = false, buttonVariant = 'default', btnClasses = '', - disabled = false + disabled = false, + workspace = undefined }: { asset: Asset _resourceMetadata?: { resource_type?: string } @@ -44,9 +45,12 @@ buttonVariant?: ButtonType.Variant btnClasses?: string disabled?: boolean + /** Workspace the explored asset lives in; defaults to the nav workspace. */ + workspace?: string } = $props() let dbManagerDrawer = $derived(globalDbManagerDrawer.val) + let ws = $derived(workspace ?? $workspaceStore) const assetUri = $derived(formatAsset(asset)) @@ -60,18 +64,20 @@ on:click={async () => { if (asset.kind === 'resource' && isDbType(_resourceMetadata?.resource_type)) { let [resourcePath, specificTable] = asset.path.split('?table=') - dbManagerDrawer?.openDrawer({ - type: 'database', - resourceType: _resourceMetadata.resource_type, - resourcePath, - specificTable - }) + dbManagerDrawer?.openDrawer( + { + type: 'database', + resourceType: _resourceMetadata.resource_type, + resourcePath, + specificTable + }, + ws + ) } else if (asset.kind === 's3object' && isS3Uri(assetUri)) { s3FilePicker?.open(assetUri) } else if (asset.kind === 'volume') { - const storage = - (await VolumeService.getVolumeStorage({ workspace: $workspaceStore! })) ?? undefined - s3FilePicker?.open({ s3: `volumes/${$workspaceStore}/${asset.path}/`, storage }) + const storage = (await VolumeService.getVolumeStorage({ workspace: ws! })) ?? undefined + s3FilePicker?.open({ s3: `volumes/${ws}/${asset.path}/`, storage }) } else if (asset.kind === 'ducklake') { let ducklake = asset.path.split('/')[0] let specificTableSplit = asset.path.split('/')[1]?.split('.') as string[] | undefined @@ -79,7 +85,7 @@ specificTableSplit?.length === 2 ? [specificTableSplit[0], specificTableSplit[1]] : [undefined, specificTableSplit?.[0]] - dbManagerDrawer?.openDrawer({ type: 'ducklake', ducklake, specificSchema, specificTable }) + dbManagerDrawer?.openDrawer({ type: 'ducklake', ducklake, specificSchema, specificTable }, ws) } else if (asset.kind === 'datatable') { let datatable = asset.path.split('/')[0] let specificTableSplit = asset.path.split('/')[1]?.split('.') as string[] | undefined @@ -87,13 +93,16 @@ specificTableSplit?.length === 2 ? [specificTableSplit[0], specificTableSplit[1]] : [undefined, specificTableSplit?.[0]] - dbManagerDrawer?.openDrawer({ - type: 'database', - resourceType: 'postgresql', - resourcePath: `datatable://${datatable}`, - specificTable, - specificSchema - }) + dbManagerDrawer?.openDrawer( + { + type: 'database', + resourceType: 'postgresql', + resourcePath: `datatable://${datatable}`, + specificTable, + specificSchema + }, + ws + ) } onClick?.() }} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index d4be7a3b1b..f6a10a48d1 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -136,13 +136,21 @@ loadedFromDraft = false, othersDraftsCount = 0, onOpenOthersDrafts, - onTestJob + onTestJob, + condensedHeader = false }: FlowBuilderProps = $props() - // Key the AutosaveIndicator watches. Falls back to this component's own - // draft key, so the full-page editor is unchanged; the sessions preview - // overrides both to the (forked) workspace + path its autosave saves under. - const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) + // Top-bar button size + bar height. Condensed (session preview) uses the + // smallest well-supported unified size (`sm`) so the bar is thinner. + const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') + + // The workspace this editor operates on: deploy, save-draft, trigger loading + // and the AutosaveIndicator all target it. Falls back to the global store, so + // the full-page editor is unchanged; the sessions preview overrides it to the + // session's (forked) workspace, so an embedded editor acts on the session's + // fork rather than the navigation workspace ($workspaceStore, which stays put). + // indicatorPath is the matching draft path. + const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath) let initialPathStore = writable(initialPath) @@ -237,7 +245,7 @@ try { if (initialPath && initialPath != '') { const flowVersion = await FlowService.getFlowLatestVersion({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath }) @@ -289,14 +297,25 @@ // failure: `flush` never rejects (postSave catches and routes errors // to the failures map), so the success branch fired regardless. export async function saveDraft(): Promise { - if (!$workspaceStore || !liveEditorDraftStoragePath) return + if (!opWorkspace || !liveEditorDraftStoragePath) return await UserDraftDbSyncer.flush({ - workspace: $workspaceStore, + workspace: opWorkspace, itemKind: 'flow', path: liveEditorDraftStoragePath }) } + // Materialize a brand-new flow's draft before the session preview loads it by + // path — an untouched new flow never autosaved, so forcePersist is the only + // thing that creates the row. Gated to never-deployed: forcePersist skips the + // discardIf baseline, safe only when there is none. + async function persistDraftForSession(): Promise { + await saveDraft() + if (opWorkspace && liveEditorDraftStoragePath && newFlow) { + await UserDraft.forcePersist('flow', liveEditorDraftStoragePath, { workspace: opWorkspace }) + } + } + export function computeUnlockedSteps(flow: Flow) { return Object.fromEntries( getAllModules(flow.value.modules, flow.value.failure_module) @@ -339,7 +358,7 @@ } async function syncWithDeployed() { const flow = await FlowService.getFlowByPath({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath, withStarredInfo: true }) @@ -396,7 +415,7 @@ if (newFlow) { await FlowService.createFlow({ - workspace: $workspaceStore!, + workspace: opWorkspace!, requestBody: { path: $pathStore, summary: flow.summary ?? '', @@ -414,7 +433,7 @@ } }) await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: fakeInitialPath, requestBody: { new_path: $pathStore @@ -424,7 +443,7 @@ if (triggersToDeploy) { await deployTriggers( triggersToDeploy, - $workspaceStore, + opWorkspace, !!$userStore?.is_admin || !!$userStore?.is_super_admin, usedTriggerKinds, $pathStore, @@ -435,7 +454,7 @@ if (triggersToDeploy) { await deployTriggers( triggersToDeploy, - $workspaceStore, + opWorkspace, !!$userStore?.is_admin || !!$userStore?.is_super_admin, usedTriggerKinds, initialPath @@ -443,7 +462,7 @@ } await FlowService.updateFlow({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath, requestBody: { path: $pathStore, @@ -465,7 +484,7 @@ // New/updated path now exists server-side — drop the autocomplete // cache so it shows up immediately instead of after the 60s TTL. - invalidateWorkspacePaths($workspaceStore!) + invalidateWorkspacePaths(opWorkspace!) const { draft_triggers: _, ...newSavedFlow } = flowStore.val as OpenFlow & { draft_triggers: Trigger[] @@ -504,9 +523,14 @@ const history = initHistory(untrack(() => flowStore).val) const pathStore = writable(untrack(() => pathStoreInit) ?? initialPath) + // "Open in AI session" target: the URL draft path the editor loads/saves by + // (which for a new flow differs from the live-edited friendly `$pathStore`), + // falling back to `$pathStore` in drawer mounts that carry no storage path. + const sessionTargetPath = $derived(liveEditorDraftStoragePath || $pathStore) + $effect(() => { - if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return - const workspace = $workspaceStore + if (liveEditorDraftStoragePath === undefined || !opWorkspace) return + const workspace = opWorkspace UserDraft.setLiveEditorDraft({ workspace, itemKind: 'flow', @@ -561,7 +585,8 @@ modulesTestStates, outputPickerOpenFns, preserveOnBehalfOf, - savedOnBehalfOfEmail + savedOnBehalfOfEmail, + opWorkspace: () => opWorkspace }) // Set up NoteEditor context for note editing capabilities @@ -606,14 +631,14 @@ export async function loadTriggers() { if (initialPath == '') return $triggersCount = await FlowService.getTriggersCountOfFlow({ - workspace: $workspaceStore!, + workspace: opWorkspace!, path: initialPath }) // Initialize triggers using utility function await triggersState.fetchTriggers( triggersCount, - $workspaceStore, + opWorkspace, initialPath, true, $primaryScheduleStore, @@ -631,7 +656,9 @@ for (const mod of restoredModules) { if (mod) { try { - loadFlowModuleState(mod).then((state) => (flowStateStore.val[mod.id] = state)) + loadFlowModuleState(mod, opWorkspace).then( + (state) => (flowStateStore.val[mod.id] = state) + ) } catch (e) { console.error('Error loading state for restored node', e) } @@ -740,10 +767,10 @@ if ( !untrack(() => newFlow) && !isCloudHosted() && - editInForkAllowed($workspaceStore, $userWorkspaces) + editInForkAllowed(opWorkspace, $userWorkspaces) ) { dropdownItems.push({ - label: editInForkLabel($workspaceStore, $userWorkspaces), + label: editInForkLabel(opWorkspace, $userWorkspaces), onClick: () => window.open(buildForkEditUrl('flow', initialPath)) }) } @@ -980,7 +1007,7 @@ selectedId && untrack(() => select(selectedId)) }) $effect.pre(() => { - initialPath && initialPath != '' && $workspaceStore && untrack(() => loadTriggers()) + initialPath && initialPath != '' && opWorkspace && untrack(() => loadTriggers()) }) $effect.pre(() => { const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false @@ -991,7 +1018,7 @@ await stepHistoryLoader.loadIndividualStepsStates( flowStore.val as Flow, flowStateStore, - $workspaceStore!, + opWorkspace!, $initialPathStore, $pathStore ) @@ -1093,23 +1120,29 @@ -
+
-
- onNavigate?.(item)} - /> - {#if indicatorWorkspace && indicatorPath !== undefined} +
+
+ onNavigate?.(item)} + /> +
+ {#if opWorkspace && indicatorPath !== undefined} {/if}
-
+
{#if $enterpriseLicense && !newFlow} {/if}
- + {#if $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) || $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))}
@@ -1175,6 +1209,7 @@ {#snippet previewButtons()} { select('Trigger') handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind) @@ -1240,6 +1275,13 @@ aiChatOpen={aiChatManager.open} showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false} toggleAiChat={() => aiChatManager.toggleOpen()} + sessionOpen={sessionTargetPath + ? { + target: { kind: 'flow', path: sessionTargetPath }, + workspaceId: opWorkspace ?? undefined, + beforeOpen: persistDraftForSession + } + : undefined} onOpenPreview={flowPreviewButtons?.openPreview} localModuleStates={showJobStatus ? localModuleStates : {}} {showJobStatus} diff --git a/frontend/src/lib/components/FlowDiffViewer.svelte b/frontend/src/lib/components/FlowDiffViewer.svelte index 3539cbc5fe..56a15687a8 100644 --- a/frontend/src/lib/components/FlowDiffViewer.svelte +++ b/frontend/src/lib/components/FlowDiffViewer.svelte @@ -10,6 +10,8 @@ * FlowGraphDiffViewer show its own user-facing toggle (matches the * pre-fork-diff-drawer behavior). */ inlineDiff?: boolean + /** Forward Monaco's auto-inline opt-out to the YAML-mode DiffEditor. */ + disableAutoInline?: boolean /** Forwarded to FlowGraphDiffViewer — render an empty surface * placeholder for the "before" / "after" pane when the item is * added / removed. */ @@ -21,6 +23,7 @@ beforeYaml, afterYaml, inlineDiff = undefined, + disableAutoInline = false, beforeMissing = false, afterMissing = false }: Props = $props() @@ -46,6 +49,7 @@ defaultOriginal={beforeYaml} defaultModified={afterYaml} {inlineDiff} + {disableAutoInline} readOnly /> {/await} diff --git a/frontend/src/lib/components/FlowLoopIterationPreview.svelte b/frontend/src/lib/components/FlowLoopIterationPreview.svelte index f92072b5fd..b4dde43289 100644 --- a/frontend/src/lib/components/FlowLoopIterationPreview.svelte +++ b/frontend/src/lib/components/FlowLoopIterationPreview.svelte @@ -73,7 +73,8 @@ runPreview(previewArgs, undefined) } - const { flowStateStore, pathStore } = getContext('FlowEditorContext') + const { flowStateStore, pathStore, opWorkspace } = + getContext('FlowEditorContext') const dispatch = createEventDispatcher() export async function runPreview( @@ -82,7 +83,15 @@ ) { progressBar?.reset() const newFlow = { value: { modules }, summary: '' } - jobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom) + jobId = await runFlowPreview( + args, + newFlow, + $pathStore, + restartedFrom, + undefined, + undefined, + opWorkspace?.() + ) isRunning = true } @@ -130,7 +139,7 @@ try { jobId && (await JobService.cancelQueuedJob({ - workspace: $workspaceStore ?? '', + workspace: opWorkspace?.() ?? $workspaceStore ?? '', id: jobId, requestBody: {} })) @@ -177,6 +186,7 @@ {#if jobId} { job = newJob diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index bf06628cc0..dd359fb006 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -133,8 +133,11 @@ fakeInitialPath, customUi, executionCount, - devTempScriptRefs + devTempScriptRefs, + opWorkspace } = $state(getContext('FlowEditorContext')) + // Acting workspace when previewing inside an AI session; else the nav workspace. + let opWs = $derived(opWorkspace?.() ?? $workspaceStore) const dispatch = createEventDispatcher() let renderCount: number = $state(0) @@ -193,14 +196,15 @@ lastPreviewFlow = JSON.stringify(flowStore.val) flowProgressBar?.reset() const newFlow = extractFlow(previewMode) - args = await processSecretArgs(args, flowStore.val.schema as any) + args = await processSecretArgs(args, flowStore.val.schema as any, opWs) newJobId = await runFlowPreview( args, newFlow, $pathStore, restartedFrom, conversationId, - devTempScriptRefs?.() + devTempScriptRefs?.(), + opWorkspace?.() ) jobId = newJobId isRunning = true @@ -286,7 +290,7 @@ subJobIds.map(async (subId) => { try { const subJob = await JobService.getJob({ - workspace: $workspaceStore!, + workspace: opWs!, id: subId }) flowRecording.addCompletedJob(subId, subJob) @@ -332,11 +336,11 @@ untrack(() => { for (const mod of modules) { if (mod.job) { - flowRecording.watchSubJob(mod.job, $workspaceStore!) + flowRecording.watchSubJob(mod.job, opWs!) } } if (job?.flow_status?.failure_module?.job) { - flowRecording.watchSubJob(job.flow_status.failure_module.job, $workspaceStore!) + flowRecording.watchSubJob(job.flow_status.failure_module.job, opWs!) } }) } @@ -347,7 +351,7 @@ try { jobId && (await JobService.cancelQueuedJob({ - workspace: $workspaceStore ?? '', + workspace: opWs ?? '', id: jobId, requestBody: {} })) @@ -514,6 +518,7 @@ runnableId={$initialPathStore} stablePathForCaptures={$initialPathStore || fakeInitialPath} runnableType={'FlowPath'} + workspace={opWs} previewArgs={previewArgs.val} on:openTriggers on:select={(e) => { @@ -562,6 +567,7 @@ { @@ -632,7 +638,7 @@
{ isRunning = false diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index 1b219153da..0b311ebe60 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -16,9 +16,12 @@ workspaceId: string | undefined job: Job light?: boolean + /** Fired after a successful resume/reject, before the next poll observes it — + * lets a host (e.g. the AI chat jobs tray) close its modal optimistically. */ + onAction?: (approved: boolean) => void } - let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props() + let { isOwner: _isOwner, workspaceId, job, light = false, onAction }: Props = $props() let default_payload: object = $state({}) let description: any = $state(undefined) @@ -71,6 +74,7 @@ } }) actionTaken = true + onAction?.(approve) } catch (e: any) { sendUserToast(e?.body ?? e?.message ?? 'Failed', true) } finally { diff --git a/frontend/src/lib/components/FlowWrapper.svelte b/frontend/src/lib/components/FlowWrapper.svelte index 6eaf199ea7..2bd24f151a 100644 --- a/frontend/src/lib/components/FlowWrapper.svelte +++ b/frontend/src/lib/components/FlowWrapper.svelte @@ -5,6 +5,7 @@ import FlowBuilder from './FlowBuilder.svelte' import { usePageDraftSync } from './usePageDraftSync.svelte' import { workspaceStore } from '$lib/stores' + import { selectDraftStoragePath } from '$lib/mintDraftPath' import type { OpenFlow } from '$lib/gen' let { @@ -28,13 +29,19 @@ // Stable per-user draft storage key. Captured once so editing the flow's path // (which lives in `draft_path`, not the storage key) can't re-key the autosave // handle and orphan the draft. Mirrors the full-page editor keying on the URL - // path; falls back through the SDK's path inputs. - const draftStoragePath = untrack( - () => - props.initialPath || - props.pathStoreInit || - (oldFlowStore.val as { path?: string } | undefined)?.path || - '' + // path; falls back through the SDK's path inputs. For a brand-new flow with no + // caller path this mints a `u//draft_` key — the SDK equivalent of + // the `/flows/add` redirect — so autosave attaches instead of the handle + // detaching (local-only, never POSTs). + const draftStoragePath = untrack(() => + selectDraftStoragePath({ + providedPaths: [ + props.initialPath, + props.pathStoreInit, + (oldFlowStore.val as { path?: string } | undefined)?.path + ], + isNewItem: !!props.newFlow + }) ) // Reuse the full-page flow editor's draft orchestration so the SDK gets diff --git a/frontend/src/lib/components/GitRepoResourcePicker.svelte b/frontend/src/lib/components/GitRepoResourcePicker.svelte index c1db328acc..8e49190526 100644 --- a/frontend/src/lib/components/GitRepoResourcePicker.svelte +++ b/frontend/src/lib/components/GitRepoResourcePicker.svelte @@ -13,6 +13,8 @@ currentInventories?: string currentPlaybook?: string gitSshIdentity?: string[] + /** Acting workspace (fork/session); falls back to the nav workspace. */ + workspace?: string } let { @@ -21,9 +23,12 @@ currentCommit = undefined, currentInventories = undefined, currentPlaybook = undefined, - gitSshIdentity = undefined + gitSshIdentity = undefined, + workspace: workspaceProp = undefined }: Props = $props() + let ws = $derived(workspaceProp ?? $workspaceStore) + const dispatch = createEventDispatcher<{ selected: { resourcePath: string @@ -44,12 +49,12 @@ let loadingInventories = $state(false) async function loadGitRepoResources() { - if (!$workspaceStore) return + if (!ws) return loading = true try { const resources = await ResourceService.listResource({ - workspace: $workspaceStore, + workspace: ws, resourceType: 'git_repository' }) @@ -66,7 +71,7 @@ } $effect(() => { - if (open && $workspaceStore) { + if (open && ws) { loadGitRepoResources() // Set current resource as selected when opening selectedResource = currentResource @@ -95,12 +100,12 @@ inventoriesPath: string, commitHash: string ): Promise { - const rootPath = `gitrepos/${$workspaceStore}/${resourcePath}/${commitHash}/` + const rootPath = `gitrepos/${ws}/${resourcePath}/${commitHash}/` if (inventoriesPath.startsWith('./')) inventoriesPath = inventoriesPath.slice(2) let files = await HelpersService.listGitRepoFiles({ - workspace: $workspaceStore!, + workspace: ws!, maxKeys: 100, marker: undefined, prefix: `${rootPath}/${inventoriesPath}` @@ -121,7 +126,7 @@ if (!commitHash) { try { const result = await ResourceService.getGitCommitHash({ - workspace: $workspaceStore!, + workspace: ws!, path: selectedResource, gitSshIdentity: gitSshIdentity?.join(',') }) diff --git a/frontend/src/lib/components/GitRepoViewer.svelte b/frontend/src/lib/components/GitRepoViewer.svelte index b5f8e42c26..69fd651c3b 100644 --- a/frontend/src/lib/components/GitRepoViewer.svelte +++ b/frontend/src/lib/components/GitRepoViewer.svelte @@ -31,14 +31,23 @@ gitRepoResourcePath: string gitSshIdentity?: string[] commitHashInput?: string + /** Acting workspace (fork/session); falls back to the nav workspace. */ + workspace?: string } - let { gitRepoResourcePath, gitSshIdentity, commitHashInput = $bindable() }: Props = $props() + let { + gitRepoResourcePath, + gitSshIdentity, + commitHashInput = $bindable(), + workspace: workspaceProp = undefined + }: Props = $props() + + let ws = $derived(workspaceProp ?? $workspaceStore) let commitHash = $derived(commitHashInput) async function populateS3WithGitRepo() { - const workspace = $workspaceStore + const workspace = ws if (!workspace) return const payload = { @@ -172,7 +181,7 @@ error = null isLoadingCommitHash = true const result = await ResourceService.getGitCommitHash({ - workspace: $workspaceStore!, + workspace: ws!, path: gitRepoResourcePath, gitSshIdentity: gitSshIdentity?.join(',') }) @@ -189,9 +198,9 @@ try { error = null isCheckingPathExists = true - const s3Path = `gitrepos/${$workspaceStore}/${gitRepoResourcePath}/${commitHash}/` + const s3Path = `gitrepos/${ws}/${gitRepoResourcePath}/${commitHash}/` const pathCheck = await HelpersService.checkS3FolderExists({ - workspace: $workspaceStore!, + workspace: ws!, fileKey: s3Path, markerFile: CLONE_MARKER_FILE }) @@ -226,7 +235,7 @@ {#if runningJobId} @@ -261,7 +270,7 @@ {#if runningJobId} @@ -306,9 +315,10 @@ {#key `${gitRepoResourcePath}-${commitHash}`} { diff --git a/frontend/src/lib/components/HighlightCode.svelte b/frontend/src/lib/components/HighlightCode.svelte index 909b8daca2..73c9a08c4a 100644 --- a/frontend/src/lib/components/HighlightCode.svelte +++ b/frontend/src/lib/components/HighlightCode.svelte @@ -17,6 +17,8 @@ import r from 'svelte-highlight/languages/r' import type { Script } from '$lib/gen' import { Button } from './common' + import CopyButton from './common/button/CopyButton.svelte' + import ScrollableX from './common/ScrollableX.svelte' import { copyToClipboard } from '$lib/utils' import { ClipboardCopy } from 'lucide-svelte' import HighlightTheme from './HighlightTheme.svelte' @@ -31,6 +33,10 @@ onApplyCode?: () => void showApplyButton?: boolean applyButtonIcon?: typeof ClipboardCopy + /** Keep the copy/apply buttons hidden until the block is hovered, and render them subtly. */ + buttonsOnHover?: boolean + /** Wrap the code in ScrollableX (native scroll, subtle hover-revealed scrollbar) for horizontal overflow. */ + customScrollbarX?: boolean } let { @@ -41,9 +47,15 @@ className = '', onApplyCode = undefined, showApplyButton = false, - applyButtonIcon = undefined + applyButtonIcon = undefined, + buttonsOnHover = false, + customScrollbarX = false }: Props = $props() + const hoverButtonClasses = buttonsOnHover + ? 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150' + : '' + function getLang(lang: Script['language'] | 'bunnative' | 'frontend' | 'json' | undefined) { switch (lang) { case 'python3': @@ -107,21 +119,27 @@ -
-
+ +{#snippet codeBody()} + {#if code?.length < 10000} + {#if !lines} + + {:else} + + + + {/if} + {:else} +
{code}
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/HistoricInputs.svelte b/frontend/src/lib/components/HistoricInputs.svelte index 3c784f354f..85f2603005 100644 --- a/frontend/src/lib/components/HistoricInputs.svelte +++ b/frontend/src/lib/components/HistoricInputs.svelte @@ -17,6 +17,8 @@ placement?: 'bottom-start' | 'top-start' | 'bottom-end' | 'top-end' limitPayloadSize?: boolean searchArgs?: Record | undefined + /** Workspace to read run history from; defaults to the nav workspace. */ + workspace?: string } let { @@ -26,9 +28,12 @@ showAuthor = false, placement = 'top-end', limitPayloadSize = false, - searchArgs = undefined + searchArgs = undefined, + workspace = undefined }: Props = $props() + let ws = $derived(workspace ?? $workspaceStore) + let historicList: HistoricList | undefined = $state(undefined) const dispatch = createEventDispatcher() @@ -111,7 +116,7 @@ jobKinds: getJobKinds(runnableType), syncQueuedRunsCount: false, refreshRate: 10000, - currentWorkspace: $workspaceStore ?? '', + currentWorkspace: ws ?? '', skip: !runnableId, excludesEntrypointOverride: true }) satisfies UseJobLoaderArgs diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 834ef0b96c..10e117bb42 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -20,6 +20,7 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import SimpleEditor from './SimpleEditor.svelte' import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' + import RetentionPeriodOverrides from './instanceSettings/RetentionPeriodOverrides.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' @@ -326,6 +327,12 @@
{/if} + {:else if setting.fieldType == 'retention_overrides'} + + {:else} JobService.getCompletedJobLogsTail({ - workspace: $workspaceStore!, + workspace: workspace!, id: lastJobId }) ).then((res) => { @@ -222,7 +222,7 @@ return abstractRun( () => JobService.runScriptByPath({ - workspace: $workspaceStore!, + workspace: workspace!, path: path ?? '', requestBody: args, skipPreprocessor: true @@ -239,7 +239,7 @@ return abstractRun( () => JobService.runScriptByHash({ - workspace: $workspaceStore!, + workspace: workspace!, hash: hash ?? '', requestBody: args, skipPreprocessor: true @@ -256,7 +256,7 @@ return abstractRun( () => JobService.runFlowByPath({ - workspace: $workspaceStore!, + workspace: workspace!, path: path ?? '', requestBody: args, skipPreprocessor: true @@ -274,7 +274,7 @@ return abstractRun( () => JobService.runFlowPreview({ - workspace: $workspaceStore!, + workspace: workspace!, requestBody: { args, value: flow.value, @@ -318,7 +318,7 @@ return abstractRun( () => JobService.runDynamicSelect({ - workspace: $workspaceStore!, + workspace: workspace!, requestBody: { entrypoint_function, args, runnable_ref } }), callbacks @@ -342,7 +342,7 @@ return abstractRun( () => JobService.runScriptPreview({ - workspace: $workspaceStore!, + workspace: workspace!, timeout, requestBody: { path, @@ -371,7 +371,7 @@ currentEventSource = undefined try { await JobService.cancelQueuedJob({ - workspace: $workspaceStore ?? '', + workspace: workspace ?? '', id, requestBody: {} }) diff --git a/frontend/src/lib/components/MarkdownCodeBlock.svelte b/frontend/src/lib/components/MarkdownCodeBlock.svelte new file mode 100644 index 0000000000..26022e21cf --- /dev/null +++ b/frontend/src/lib/components/MarkdownCodeBlock.svelte @@ -0,0 +1,149 @@ + + +
+
+ {#if renderMermaid && language === 'mermaid'} + + {:else} + + {/if} +
+
diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 785e111527..14e85ddad3 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -39,9 +39,13 @@ stepsInputArgs, previewArgs, modulesTestStates, - devTempScriptRefs + devTempScriptRefs, + opWorkspace } = getContext('FlowEditorContext') + // Acting workspace when the flow editor runs in an AI session; else the nav workspace. + let opWs = $derived(opWorkspace?.() ?? $workspaceStore) + let jobLoader: JobLoader | undefined = $state(undefined) let jobProgressReset: () => void = () => {} let stepHistoryLoader = getStepHistoryLoaderContext() @@ -102,8 +106,8 @@ ) } else if (val.type == 'script') { const script = val.hash - ? await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash: val.hash }) - : await getScriptByPath(val.path) + ? await ScriptService.getScriptByHash({ workspace: opWs!, hash: val.hash }) + : await getScriptByPath(val.path, opWs) await jobLoader?.runPreview( val.path, script.content, @@ -121,7 +125,7 @@ } else if (val.type == 'flow') { await jobLoader?.runFlowByPath(val.path, args, callbacks) } else if (val.type == 'aiagent') { - const { schema } = await loadSchemaFromModule(mod) + const { schema } = await loadSchemaFromModule(mod, opWs) const inputTransforms: { [key: string]: JavascriptTransform } = Object.fromEntries( Object.keys(args).map((key) => [ @@ -202,6 +206,7 @@ void + /** Workspace the resource picker lists from; defaults to the nav workspace. */ + workspace?: string } let { @@ -32,7 +34,8 @@ editor = $bindable(undefined), disabled = false, datatableAsPgResource = false, - onClear = undefined + onClear = undefined, + workspace = undefined }: Props = $props() function isResource() { @@ -55,7 +58,7 @@
{#if format === 'resource-s3_object'} - + {:else if value == undefined || typeof value === 'string'} valueToPath(), (v) => { diff --git a/frontend/src/lib/components/PageHeader.svelte b/frontend/src/lib/components/PageHeader.svelte index 556ac1b0cc..9a927c52fa 100644 --- a/frontend/src/lib/components/PageHeader.svelte +++ b/frontend/src/lib/components/PageHeader.svelte @@ -2,12 +2,15 @@ import Tooltip from './Tooltip.svelte' interface Props { - title: string; - tooltip?: string; - documentationLink?: string | undefined; - primary?: boolean; - childrenWrapperDivClasses?: string; - children?: import('svelte').Snippet; + title: string + tooltip?: string + documentationLink?: string | undefined + primary?: boolean + childrenWrapperDivClasses?: string + // Inline actions rendered right after the title (e.g. a copy-id button), + // as opposed to `children` which lands on the far right of the header row. + titleActions?: import('svelte').Snippet + children?: import('svelte').Snippet } let { @@ -16,8 +19,9 @@ documentationLink = undefined, primary = true, childrenWrapperDivClasses = '', + titleActions, children - }: Props = $props(); + }: Props = $props()
@@ -31,6 +35,7 @@ {tooltip} {/if} + {@render titleActions?.()} {:else} @@ -40,6 +45,7 @@ {tooltip} {/if} + {@render titleActions?.()} {/if} diff --git a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte index fd08eb64be..e7651e5703 100644 --- a/frontend/src/lib/components/ParqetCsvTableRenderer.svelte +++ b/frontend/src/lib/components/ParqetCsvTableRenderer.svelte @@ -7,7 +7,7 @@ import 'ag-grid-community/styles/ag-theme-alpine.css' import { twMerge } from 'tailwind-merge' import DarkModeObserver from './DarkModeObserver.svelte' - import { HelpersService } from '$lib/gen' + import { AppService, HelpersService } from '$lib/gen' import { base } from '$lib/base' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { enterpriseLicense, workspaceStore } from '$lib/stores' @@ -22,9 +22,61 @@ storage: string | undefined workspaceId: string | undefined disable_download?: boolean + // When set (deployed app view), read the file on-behalf of the app author + // through the app-scoped, provenance-gated `apps_u/*` endpoints instead of + // the viewer-scoped `job_helpers/*` API. Undefined in the editor/preview. + appPath?: string | undefined } - let { s3resource, storage, workspaceId, disable_download = false }: Props = $props() + let { + s3resource, + storage, + workspaceId, + disable_download = false, + appPath = undefined + }: Props = $props() + + // Route the parquet/csv read through the app-scoped endpoints when `appPath` + // is set, else the viewer-scoped helpers. Same request/response shape either + // way — the only difference is which identity authorizes the S3 read. + function loadRowCount(searchCol: string | undefined, searchTerm: string | undefined) { + const workspace = workspaceId ?? $workspaceStore! + return appPath + ? AppService.appLoadTableCount({ + workspace, + path: appPath, + fileKey: s3resource, + searchCol, + searchTerm, + storage + }) + : HelpersService.loadTableRowCount({ + workspace, + path: s3resource, + searchCol, + searchTerm, + storage + }) + } + + function loadChunk(args: { + offset?: number + limit?: number + sortCol?: string + sortDesc?: boolean + searchCol?: string + searchTerm?: string + csvSeparator?: string + }) { + const workspace = workspaceId ?? $workspaceStore! + const csv = s3resource.endsWith('.csv') + if (appPath) { + const data = { workspace, path: appPath, fileKey: s3resource, storage, ...args } + return csv ? AppService.appLoadCsvPreview(data) : AppService.appLoadParquetPreview(data) + } + const data = { workspace, path: s3resource, storage, ...args } + return csv ? HelpersService.loadCsvPreview(data) : HelpersService.loadParquetPreview(data) + } let lastSearch: string | undefined = undefined @@ -40,34 +92,20 @@ const newSearch = searchCol ? searchCol + searchTerm : undefined if (!nbRows || lastSearch != newSearch) { nbRows = undefined - const res = await HelpersService.loadTableRowCount({ - workspace: workspaceId ?? $workspaceStore!, - path: s3resource, - searchCol: searchCol, - storage, - searchTerm - }) + const res = await loadRowCount(searchCol, searchTerm) nbRows = res.count lastSearch = newSearch } - const requestBody = { - workspace: workspaceId ?? $workspaceStore!, - path: s3resource, + const res = (await loadChunk({ offset: params.startRow, limit: params.endRow - params.startRow, sortCol: params.sortModel?.[0]?.colId, sortDesc: params.sortModel?.[0]?.sort == 'desc', searchCol, searchTerm, - storage: storage, csvSeparator: csv ? csvSeparatorChar : undefined - } - const res = ( - csv - ? await HelpersService.loadCsvPreview(requestBody) - : await HelpersService.loadParquetPreview(requestBody) - ) as any + })) as any for (let i = 0; i < res.rows.length; i++) { res.rows[i]['__index'] = i + params.startRow if (!$enterpriseLicense) { @@ -110,20 +148,10 @@ try { const csv = s3resource.endsWith('.csv') - const res = csv - ? await HelpersService.loadCsvPreview({ - workspace: $workspaceStore!, - path: s3resource, - limit: 0, - storage: storage, - csvSeparator: csvSeparatorChar - }) - : await HelpersService.loadParquetPreview({ - workspace: $workspaceStore!, - path: s3resource, - limit: 0, - storage: storage - }) + const res = (await loadChunk({ + limit: 0, + csvSeparator: csv ? csvSeparatorChar : undefined + })) as any createGrid( eGui, @@ -201,14 +229,15 @@
{/if} {#if !disable_download && !s3resource.endsWith('.csv')} - {@const csvApiPath = `/w/${workspaceId}/job_helpers/download_s3_parquet_file_as_csv?file_key=${encodeURIComponent(s3resource)}${storage ? `&storage=${storage}` : ''}`} + {@const csvApiPath = appPath + ? `/w/${workspaceId}/apps_u/download_s3_parquet_file_as_csv/${appPath}?file_key=${encodeURIComponent(s3resource)}${storage ? `&storage=${storage}` : ''}` + : `/w/${workspaceId}/job_helpers/download_s3_parquet_file_as_csv?file_key=${encodeURIComponent(s3resource)}${storage ? `&storage=${storage}` : ''}`} {@const csvName = (s3resource.split('/').pop() ?? 'download') + '.csv'} {#if shouldDownloadViaClient()} {:else} @@ -216,9 +245,7 @@ target="_blank" href="{base}/api{csvApiPath}" class="text-secondary w-full text-right underline text-2xs whitespace-nowrap" - >
CSV
CSV
{/if} {/if} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index b8c37011bd..27d6b45e6c 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -78,6 +78,11 @@ disableEditing?: boolean size?: 'sm' | 'md' drawerOffset?: number + /** Workspace the folder list and path-existence checks run against. + * Defaults to the navigation `$workspaceStore`; pass the session's acting + * workspace when the editor operates on a workspace other than the one the + * top nav points at (see the sessions preview / dev-workspace flows). */ + workspaceOverride?: string } let { @@ -94,9 +99,12 @@ hideUser = false, disableEditing = false, size = 'md', - drawerOffset = 0 + drawerOffset = 0, + workspaceOverride = undefined }: Props = $props() + let ws = $derived(workspaceOverride ?? $workspaceStore) + $effect.pre(() => { if (path == undefined) { path = '' @@ -203,7 +211,7 @@ folders = initialFolders.concat( ( await FolderService.listFolderNames({ - workspace: $workspaceStore! + workspace: ws! }) ) .filter((x) => !excludedFolders.includes(x)) @@ -244,74 +252,74 @@ async function pathExists(path: string, kind: PathKind): Promise { if (!path.length) return false if (kind == 'flow') { - return await FlowService.existsFlowByPath({ workspace: $workspaceStore!, path: path }) + return await FlowService.existsFlowByPath({ workspace: ws!, path: path }) } else if (kind == 'script') { return await ScriptService.existsScriptByPath({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'resource') { return await ResourceService.existsResource({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'variable') { return await VariableService.existsVariable({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'schedule') { - return await ScheduleService.existsSchedule({ workspace: $workspaceStore!, path: path }) + return await ScheduleService.existsSchedule({ workspace: ws!, path: path }) } else if (kind == 'app') { - return await AppService.existsApp({ workspace: $workspaceStore!, path: path }) + return await AppService.existsApp({ workspace: ws!, path: path }) } else if (kind == 'http_trigger') { return await HttpTriggerService.existsHttpTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'websocket_trigger') { return await WebsocketTriggerService.existsWebsocketTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'kafka_trigger') { return await KafkaTriggerService.existsKafkaTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'postgres_trigger') { return await PostgresTriggerService.existsPostgresTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'nats_trigger') { return await NatsTriggerService.existsNatsTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind === 'mqtt_trigger') { return await MqttTriggerService.existsMqttTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind == 'sqs_trigger') { return await SqsTriggerService.existsSqsTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind === 'gcp_trigger') { return await GcpTriggerService.existsGcpTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind === 'azure_trigger') { return await AzureTriggerService.existsAzureTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else if (kind === 'email_trigger') { return await EmailTriggerService.existsEmailTrigger({ - workspace: $workspaceStore!, + workspace: ws!, path: path }) } else { @@ -398,7 +406,7 @@ }) }) $effect.pre(() => { - if ($workspaceStore && $userStore) { + if (ws && $userStore) { untrack(() => { loadFolders() initPath() @@ -412,30 +420,30 @@ ) let pathUsageInFlowsPromise = $derived( (kind == 'script' || kind == 'flow') && - $workspaceStore && + ws && initialPath && FlowService.listFlowPathsFromWorkspaceRunnable({ - workspace: $workspaceStore, + workspace: ws, path: initialPath, runnableKind: kind }) ) let pathUsageInAppsPromise = $derived( (kind == 'script' || kind == 'flow') && - $workspaceStore && + ws && initialPath && AppService.listAppPathsFromWorkspaceRunnable({ - workspace: $workspaceStore, + workspace: ws, path: initialPath, runnableKind: kind }) ) let pathUsageInScriptsPromise = $derived( kind == 'script' && - $workspaceStore && + ws && initialPath && ScriptService.listScriptPathsFromWorkspaceRunnable({ - workspace: $workspaceStore, + workspace: ws, path: initialPath }) ) @@ -525,6 +533,7 @@ bind:this={inputP} bind:value={meta.name} prefix={`${meta.ownerKind?.charAt(0) ?? ''}/${meta.owner ?? ''}/`} + workspace={ws} {size} {error} {autofocus} diff --git a/frontend/src/lib/components/PathNameAutocomplete.svelte b/frontend/src/lib/components/PathNameAutocomplete.svelte index 9c31ef980a..10d5c10640 100644 --- a/frontend/src/lib/components/PathNameAutocomplete.svelte +++ b/frontend/src/lib/components/PathNameAutocomplete.svelte @@ -101,6 +101,10 @@ error?: string | boolean textInputClass?: string onkeyup?: (e: KeyboardEvent) => void + /** Workspace whose paths feed the autocomplete. Defaults to the navigation + * `$workspaceStore`; pass the acting workspace when the editor operates on + * a workspace other than the one the top nav points at. */ + workspace?: string } let { @@ -113,9 +117,12 @@ size = 'md', error, textInputClass, - onkeyup + onkeyup, + workspace = undefined }: Props = $props() + let ws = $derived(workspace ?? $workspaceStore) + let inputEl: TextInput | undefined = $state(undefined) export function focus() { inputEl?.focus() @@ -258,12 +265,12 @@ async function loadPaths(workspace: string) { const paths = await fetchWorkspacePaths(workspace) // Guard against workspace changing during the in-flight fetch. - if ($workspaceStore === workspace) allPaths = paths + if (ws === workspace) allPaths = paths } $effect(() => { - const ws = $workspaceStore - if (ws) void loadPaths(ws) + const w = ws + if (w) void loadPaths(w) }) $effect(() => { @@ -300,7 +307,7 @@ function onInputFocus() { hasFocus = true // Opportunistic refresh if the cache is stale. - if ($workspaceStore) void loadPaths($workspaceStore) + if (ws) void loadPaths(ws) } function onInputBlur() { setTimeout(() => { diff --git a/frontend/src/lib/components/PrefixedInput.svelte b/frontend/src/lib/components/PrefixedInput.svelte index 2a1dccf386..adc578b545 100644 --- a/frontend/src/lib/components/PrefixedInput.svelte +++ b/frontend/src/lib/components/PrefixedInput.svelte @@ -1,139 +1,68 @@ - - - + +
+ + +
diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 8804ad8ac5..7acc4e485b 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -402,6 +402,7 @@ {loadingSchema} {resourceToEdit} onLoadResourceType={() => resourceTypeResource.refetch()} + workspace={selected} /> {/key} {/if} diff --git a/frontend/src/lib/components/ResourceForm.svelte b/frontend/src/lib/components/ResourceForm.svelte index ced60bf4dc..ef226501b7 100644 --- a/frontend/src/lib/components/ResourceForm.svelte +++ b/frontend/src/lib/components/ResourceForm.svelte @@ -42,6 +42,9 @@ loadingSchema: boolean resourceToEdit: Resource | undefined onLoadResourceType?: () => void + /** Workspace the path is validated against and the connection is tested in; + * defaults to the nav workspace. */ + workspace?: string | undefined } let { @@ -62,9 +65,12 @@ resourceSchema, loadingSchema, resourceToEdit, - onLoadResourceType + onLoadResourceType, + workspace = undefined }: Props = $props() + let ws = $derived(workspace ?? $workspaceStore) + let editDescription = $state(false) let rawCode: string | undefined = $state(undefined) let textFileContent: string = $state('') @@ -131,11 +137,12 @@ {/if}
@@ -218,7 +225,11 @@ {#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'} {:else} - + {/if} {#if resource_type === 'git_repository' && $workspaceStore && ($userStore?.is_admin || $userStore?.is_super_admin)} {/if} {:else if !can_write} diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index b1b562b04d..89eaf8f7ab 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -114,6 +114,7 @@ scheduledForStr: string | undefined invisible_to_owner: boolean | undefined overrideTag: string | undefined + overrideTagNote?: string args?: Record jsonView?: boolean isValid?: boolean @@ -132,6 +133,7 @@ scheduledForStr = $bindable(), invisible_to_owner = $bindable(), overrideTag = $bindable(), + overrideTagNote = undefined, args = $bindable(), jsonView = false, isValid = $bindable(true) @@ -160,7 +162,7 @@ debounced && clearTimeout(debounced) debounced = setTimeout(() => { const nurl = new URL(window.location.href) - nurl.hash = computeSharableHash(args) + nurl.hash = computeSharableHash(args, overrideTag) try { replaceState(nurl.toString(), page.state) @@ -201,6 +203,7 @@ jsonEditor?.setCode(code) } $effect(() => { + overrideTag Object.keys(args ?? {}).forEach((key) => { args?.[key] }) @@ -387,6 +390,10 @@
tag override: {overrideTag}
+ {:else if overrideTagNote} +
+ {overrideTagNote} +
{/if} {#if invisible_to_owner}
diff --git a/frontend/src/lib/components/S3FilePicker.svelte b/frontend/src/lib/components/S3FilePicker.svelte index 55bb25c5ef..8829660ea3 100644 --- a/frontend/src/lib/components/S3FilePicker.svelte +++ b/frontend/src/lib/components/S3FilePicker.svelte @@ -18,6 +18,9 @@ selectedFileKey?: { s3: string; storage?: string } | undefined folderOnly?: boolean regexFilter?: RegExp | undefined + /** Workspace to browse S3 storage in — the acting workspace of the editor that + * opened the picker, else the nav workspace. */ + workspace?: string | undefined onClose?: () => void onSelectAndClose?: (selected: { s3: string; storage: string | undefined }) => void } @@ -30,10 +33,13 @@ selectedFileKey = $bindable(undefined), folderOnly = false, regexFilter = undefined, + workspace = undefined, onClose, onSelectAndClose }: Props = $props() + let ws = $derived(workspace ?? $workspaceStore) + let drawer: Drawer | undefined = $state() let s3FilePickerInner: S3FilePickerInner | undefined = $state() @@ -55,8 +61,8 @@ > = $state({}) let secondaryStorageNames = resource( - () => $workspaceStore, - () => SettingService.getSecondaryStorageNames({ workspace: $workspaceStore! }), + () => ws, + () => SettingService.getSecondaryStorageNames({ workspace: ws! }), { lazy: true } ) @@ -105,6 +111,7 @@ bind:uploadModalOpen {folderOnly} {regexFilter} + {workspace} /> {#snippet actions()}
diff --git a/frontend/src/lib/components/S3FilePickerInner.svelte b/frontend/src/lib/components/S3FilePickerInner.svelte index 333857b7eb..9475c0041a 100644 --- a/frontend/src/lib/components/S3FilePickerInner.svelte +++ b/frontend/src/lib/components/S3FilePickerInner.svelte @@ -65,6 +65,8 @@ regexFilter?: RegExp | undefined hideS3SpecificDetails?: boolean rootPath?: string + /** Workspace to browse S3 storage in — defaults to the nav workspace. */ + workspace?: string | undefined workspaceSettingsInitialized?: boolean storage?: string | undefined uploadModalOpen?: boolean @@ -103,6 +105,7 @@ regexFilter = undefined, hideS3SpecificDetails = false, rootPath: initialRootPath = '', + workspace = undefined, workspaceSettingsInitialized = $bindable(true), storage = $bindable(undefined), uploadModalOpen = $bindable(false), @@ -117,6 +120,8 @@ testConnectionRequest = HelpersService.datasetStorageTestConnection }: Props = $props() + let ws = $derived(workspace ?? $workspaceStore) + let rootPath = $state(initialRootPath) let rootPathNestingLevel = $derived(1 * (rootPath.split('/').length - 1)) @@ -183,7 +188,7 @@ async function loadFiles() { fileListLoading = true let availableFiles = await listStoredFilesRequest({ - workspace: $workspaceStore!, + workspace: ws!, maxKeys: maxKeys, // fixed pages of 1000 files for now marker: page == 0 ? undefined : listMarkers[page - 1], prefix: rootPath ?? (filter.trim() != '' ? filter : undefined), @@ -280,7 +285,7 @@ } fileInfoLoading = true let fileMetadataRaw = await loadFileMetadataRequest({ - workspace: $workspaceStore!, + workspace: ws!, fileKey: fileKey, storage: storage }) @@ -300,7 +305,7 @@ async function loadFilePreview(fileKey: string, fileSizeInBytes?: number, fileMimeType?: string) { let filePreviewRaw = await loadFilePreviewRequest({ - workspace: $workspaceStore!, + workspace: ws!, fileKey: fileKey, fileSizeInBytes: fileSizeInBytes, fileMimeType: fileMimeType, @@ -349,7 +354,7 @@ } try { await deleteS3FileRequest({ - workspace: $workspaceStore!, + workspace: ws!, fileKey: fileKey, storage: storage }) @@ -409,7 +414,7 @@ } try { await moveS3FileRequest({ - workspace: $workspaceStore!, + workspace: ws!, srcFileKey: srcFileKey, destFileKey: destFileKey!, storage: storage @@ -457,7 +462,7 @@ fileListLoading = true try { await testConnectionRequest({ - workspace: $workspaceStore!, + workspace: ws!, storage: storage }) workspaceSettingsInitialized = true @@ -716,7 +721,7 @@ {#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)}
{#if !hideS3SpecificDetails} - {@const downloadApiPath = `/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}`} + {@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}`} {@const downloadName = fileMetadata?.fileKey.split('/').pop() ?? 'unnamed_download.file'} {#if shouldDownloadViaClient()} diff --git a/frontend/src/lib/components/S3ObjectPicker.svelte b/frontend/src/lib/components/S3ObjectPicker.svelte index 1c461708cd..bb3e19599e 100644 --- a/frontend/src/lib/components/S3ObjectPicker.svelte +++ b/frontend/src/lib/components/S3ObjectPicker.svelte @@ -14,9 +14,15 @@ interface Props { value: any editor?: SimpleEditor | undefined + /** Workspace to browse/upload S3 objects in; defaults to the nav workspace. */ + workspace?: string | undefined } - let { value = $bindable(), editor = $bindable(undefined) }: Props = $props() + let { + value = $bindable(), + editor = $bindable(undefined), + workspace = undefined + }: Props = $props() const dispatch = createEventDispatcher() @@ -48,6 +54,7 @@ editor?.setCode(rawValue) }} readOnlyMode={false} + {workspace} />
@@ -85,6 +92,7 @@ } }} defaultValue={value?.s3} + {workspace} /> {/if} {#if customUi?.topBar?.path != false}
@@ -1906,13 +1953,15 @@ kind="script" summaryEditable={customUi?.topBar?.editableSummary != false} pathEditable={customUi?.topBar?.editablePath != false} + hidePath={condensedHeader} + workspaceId={autosaveWorkspace} onNavigate={(item) => onNavigate?.(item)} />
{/if} - {#if indicatorWorkspace} + {#if opWorkspace} (metadataOpen = true)} startIcon={{ icon: Settings }} iconOnly={compactTopbar} @@ -1963,7 +2012,7 @@
- {#if testJob?.id && testJob.type === 'CompletedJob' && $workspaceStore} + {#if testJob?.id && testJob.type === 'CompletedJob' && opWs} + + + + {shownParent} + + + + {name} + + + {:else} + + + {shownParent} + + + {name} + + {/if} + {:else} + + {showFork ? name : (rootLabel ?? name)} + + {/if} + {#if pendingFork} + (new) + {:else if currentWs?.is_dev_workspace} + {devBadgeText(currentWs.dev_workspace_label)} + {/if} + {/if} + +{/snippet} + +{#if menuItems?.length && !isCollapsed} +
+ {@render chipButton(true)} + + {#snippet buttonReplacement()} + + + + {/snippet} + +
+{:else} + {@render chipButton(false)} +{/if} diff --git a/frontend/src/lib/components/apps/components/display/AppDisplayComponent.svelte b/frontend/src/lib/components/apps/components/display/AppDisplayComponent.svelte index af83951314..c2ac250948 100644 --- a/frontend/src/lib/components/apps/components/display/AppDisplayComponent.svelte +++ b/frontend/src/lib/components/apps/components/display/AppDisplayComponent.svelte @@ -15,7 +15,6 @@ import ResolveStyle from '../helpers/ResolveStyle.svelte' import { components } from '../../editor/component' import ResolveConfig from '../helpers/ResolveConfig.svelte' - import { userStore } from '$lib/stores' interface Props { id: string @@ -37,13 +36,16 @@ }: Props = $props() const requireHtmlApproval = getContext(IS_APP_PUBLIC_CONTEXT_KEY) - const { app, worldStore, componentControl, workspace, appPath } = + const { app, worldStore, componentControl, workspace, appPath, isEditor } = getContext('AppViewerContext') let result: any = $state(undefined) const resolvedConfig = $state( - initConfig(components['displaycomponent'].initialData.configuration, untrack(() => configuration)) + initConfig( + components['displaycomponent'].initialData.configuration, + untrack(() => configuration) + ) ) $componentControl[untrack(() => id)] = { @@ -52,12 +54,21 @@ } } - const outputs = initOutput($worldStore, untrack(() => id), { - result: undefined, - loading: false - }) + const outputs = initOutput( + $worldStore, + untrack(() => id), + { + result: undefined, + loading: false + } + ) - let css = $state(initCss($app.css?.displaycomponent, untrack(() => customCss))) + let css = $state( + initCss( + $app.css?.displaycomponent, + untrack(() => customCss) + ) + ) let loading = $state(false) @@ -119,7 +130,7 @@ {result_stream} {requireHtmlApproval} disableExpand={resolvedConfig?.hideDetails} - appPath={$userStore ? undefined : $appPath} + appPath={isEditor ? undefined : $appPath} forceJson={resolvedConfig?.forceJson} />
diff --git a/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte b/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte index d2831f76a3..750aff3df7 100644 --- a/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte +++ b/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte @@ -16,7 +16,6 @@ import ResolveStyle from '../helpers/ResolveStyle.svelte' import InitializeComponent from '../helpers/InitializeComponent.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' - import { userStore } from '$lib/stores' interface Props { id: string @@ -34,22 +33,35 @@ render }: Props = $props() - const { app, worldStore, workspace, appPath } = getContext('AppViewerContext') + const { app, worldStore, workspace, appPath, isEditor } = + getContext('AppViewerContext') const requireHtmlApproval = getContext(IS_APP_PUBLIC_CONTEXT_KEY) let resolvedConfig = $state( - initConfig(components['jobiddisplaycomponent'].initialData.configuration, untrack(() => configuration)) + initConfig( + components['jobiddisplaycomponent'].initialData.configuration, + untrack(() => configuration) + ) ) - const outputs = initOutput($worldStore, untrack(() => id), { - result: undefined, - loading: false, - jobId: undefined as string | undefined - }) + const outputs = initOutput( + $worldStore, + untrack(() => id), + { + result: undefined, + loading: false, + jobId: undefined as string | undefined + } + ) initializing = false - let css = $state(initCss($app.css?.jobiddisplaycomponent, untrack(() => customCss))) + let css = $state( + initCss( + $app.css?.jobiddisplaycomponent, + untrack(() => customCss) + ) + ) let jobLoader: JobLoader | undefined = $state(undefined) let testIsLoading: boolean = $state(false) @@ -137,7 +149,7 @@ {result} {requireHtmlApproval} disableExpand={resolvedConfig?.hideDetails} - appPath={$userStore ? undefined : $appPath} + appPath={isEditor ? undefined : $appPath} forceJson={resolvedConfig?.forceJson} />
diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index ed471e8262..84261bcf80 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -908,15 +908,22 @@ bind:clientWidth={topbarWidth} class="flex flex-row justify-between gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto max-h-12 h-12 shrink-0" > -
- (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} - /> -
+ +
+
+ (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} + /> +
+
{#if $app} {#if $mode !== 'preview'} -
+