diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index ef52d6e110..73551436f4 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -55,18 +55,55 @@ The body MUST be explicit about what changed. Structure: The harness/tooling that invoked the skill may add its own attribution trailer; the skill itself does not prescribe one. +## Screenshots (required for frontend changes) + +If `git diff main...HEAD --name-only` matches `^frontend/`, the PR body **must** include +screenshots of the affected UI. Skip only when there is no visible UI effect (types, +tests, build config) — and say so in the body. + +1. Verify the change in the browser (AGENTS.md → "Verifying Frontend Changes"). +2. Screenshot each affected page with `mcp__playwright__browser_take_screenshot` (save to a file). +3. Host each image and get its Markdown embed by pushing to the public + `windmill-labs/agent-screenshots-internal` repo. **Pipe base64 through stdin** — + passing it as `-f content=…` fails with `argument list too long` on real images: + + ```bash + REPO=windmill-labs/agent-screenshots-internal + IMG=screenshot.png # repeat per page + DEST="shots/$(git branch --show-current)/$(date +%s)-$(basename "$IMG")" + base64 -w0 "$IMG" | jq -Rs --arg m "add $DEST" '{message:$m, content:.}' \ + | gh api -X PUT "repos/$REPO/contents/$DEST" --input - >/dev/null + echo "![$(basename "$IMG" .png)](https://raw.githubusercontent.com/$REPO/main/$DEST)" + ``` + Derive `$DEST` from the file name (as above) so distinct pages never collide — a + fixed name would make same-second uploads reuse one path, and the second `PUT` + then 422s (the Contents API needs the existing file's `sha` to overwrite). +4. Put the printed `![…](…)` lines under a `## Screenshots` heading in the PR body. + +Requires `gh` (`repo` scope), `jq`, `base64` — all in the devShell. The host repo is +public (so the raw URLs render for reviewers without a token) and its history is +permanent — **never screenshot pages that show secrets or sensitive values** (workspace +variables, resource values, instance settings, OAuth/SMTP config); deleting the file +can't undo an accidental capture. (GitHub's drag-and-drop uploader needs a browser +session and can't be driven from a token.) + +If `gh` can't push to the host repo (e.g. a CI token scoped only to `windmill`), do +**not** fail the PR or skip silently — hand the upload to the user, who has push access, +and continue once they confirm it's done. + ## Execution Steps 1. Run `git status` to check for uncommitted changes 2. Run `git log main..HEAD --oneline` to see all commits in this branch 3. Run `git diff main...HEAD` to see the full diff against main 4. **Invoke the `local-review` skill** before creating the PR (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi). If issues are found, fix them and commit before proceeding. Do not skip this step. -5. Check if remote branch exists and is up to date: +5. **Screenshots for frontend changes**: if `git diff main...HEAD --name-only` matches `^frontend/`, capture and embed screenshots of the affected UI per "Screenshots" above before writing the PR body (skip only if there is no visible UI effect). +6. Check if remote branch exists and is up to date: ```bash git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream" ``` -6. Push to remote if needed: `git push -u origin HEAD` -7. Create draft PR using gh CLI: +7. Push to remote if needed: `git push -u origin HEAD` +8. Create draft PR using gh CLI: ```bash gh pr create --draft --title ": " --body "$(cat <<'EOF' ## Summary @@ -82,7 +119,7 @@ The harness/tooling that invoked the skill may add its own attribution trailer; EOF )" ``` -8. Return the PR URL to the user +9. Return the PR URL to the user ## EE Companion PR (when `*_ee.rs` files were modified) diff --git a/.github/workflows/claude-plan.yml b/.github/workflows/claude-plan.yml index c0457dabed..c63e3fe1aa 100644 --- a/.github/workflows/claude-plan.yml +++ b/.github/workflows/claude-plan.yml @@ -45,7 +45,7 @@ jobs: allowed_bots: 'windmill-internal-app[bot]' trigger_phrase: '/plan' claude_args: | - --model opus + --model claude-opus-4-8 --system-prompt "# Claude Planning Mode You are operating in PLANNING MODE ONLY. Your role is to create detailed, structured plans without making any code changes. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 8052bb6b4c..0d9df9f0ac 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -51,4 +51,4 @@ jobs: } claude_args: | --allowedTools "Bash,WebFetch,WebSearch" - --model opus + --model claude-opus-4-8 diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 6414cf944c..4d722df4af 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -160,4 +160,4 @@ jobs: ${{ env.REVIEW_PROMPT }} claude_args: | --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" - --model opus + --model claude-opus-4-8 diff --git a/AGENTS.md b/AGENTS.md index 6cf4e3d7f3..aead237e59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **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. - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` @@ -47,6 +48,8 @@ Typical flow: 5. `mcp__playwright__browser_take_screenshot` for visual confirmation 6. `mcp__playwright__browser_console_messages` / `browser_network_requests` to surface errors +**Attach the screenshots to the PR.** For any change under `frontend/`, embed screenshots of the affected UI in the PR body — the `pr` skill requires this and carries the upload recipe. + If you cannot exercise a UI change (no dev server, etc.), say so explicitly rather than claiming success. ## Banned Patterns @@ -106,4 +109,5 @@ $NAV --root backend callees "X" # what does X call? - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked +- **Comments record constraints, not narration.** Write a comment only for what the code can't show: why a non-obvious approach is required, what breaks if it's "simplified" away. State each invariant once, at the place where someone would break it, in ≤4 lines. Don't describe what the next line does, don't repeat the same rationale at multiple sites, and don't address the PR reviewer (justifying a change belongs in the PR description, not the code). Describe the code as it is, never its drafting history: "we no longer do X", "unchanged behavior", "instead of the previous approach" are meaningless to a reader who never saw the earlier iteration — before finishing, reread your comments as if the current state is the only state that ever existed. - **Never attribute work to a specific customer, account, or "requested by a customer" in repo-tracked content** (PR descriptions, commit messages, code comments, docs). Describe changes by their technical motivation instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 639d1ad7ac..119e051c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,157 @@ # Changelog +## [1.726.1](https://github.com/windmill-labs/windmill/compare/v1.726.0...v1.726.1) (2026-06-15) + + +### Bug Fixes + +* **apps:** prevent decision tree graph editor crash on missing graph context ([#9602](https://github.com/windmill-labs/windmill/issues/9602)) ([24f3259](https://github.com/windmill-labs/windmill/commit/24f32596e9ca39c963c19af4a8b14fbcd04e3a78)) +* db-backed draft fixes — review-page UX, legacy drafts, session restore ([#9600](https://github.com/windmill-labs/windmill/issues/9600)) ([4e4b224](https://github.com/windmill-labs/windmill/commit/4e4b2247ef471dada1b8c894974fe921d14b3947)) + +## [1.726.0](https://github.com/windmill-labs/windmill/compare/v1.725.1...v1.726.0) (2026-06-15) + + +### Features + +* **audit:** record workspace archive/unarchive/delete in instance audit log ([#9596](https://github.com/windmill-labs/windmill/issues/9596)) ([9de5708](https://github.com/windmill-labs/windmill/commit/9de57086086bb5626d175c7f926915d1d6ac67ca)) +* **frontend:** add user-level toggle to disable Windmill AI ([#9585](https://github.com/windmill-labs/windmill/issues/9585)) ([5709a56](https://github.com/windmill-labs/windmill/commit/5709a564fbafd9aa91943572ecd8c3e0c45c20b1)) + + +### Bug Fixes + +* **embeddings:** retry HuggingFace model downloads with backoff ([#9597](https://github.com/windmill-labs/windmill/issues/9597)) ([6a62959](https://github.com/windmill-labs/windmill/commit/6a6295921d681359155d814507908792be405679)) +* resolve release CI failures (pypi bundle, flow serde test, cli windows) ([#9595](https://github.com/windmill-labs/windmill/issues/9595)) ([5ccaae8](https://github.com/windmill-labs/windmill/commit/5ccaae8ab36f2be18b67863ea069763455908029)) + +## [1.725.1](https://github.com/windmill-labs/windmill/compare/v1.725.0...v1.725.1) (2026-06-15) + + +### Bug Fixes + +* **apps:** apply scope-path predicate to app list/search endpoints ([#9581](https://github.com/windmill-labs/windmill/issues/9581)) ([3bf6e10](https://github.com/windmill-labs/windmill/commit/3bf6e102afbdad41e558617bc812012eaaaecd9b)) +* **auth:** add scope checks to scripts/flows list_tokens endpoints ([#9582](https://github.com/windmill-labs/windmill/issues/9582)) ([36c9f86](https://github.com/windmill-labs/windmill/commit/36c9f8612b5778aa2c981454729590b71671ce8d)) +* **cli:** preserve committed script.lock on transient NULL lock during git-sync deploy ([#9593](https://github.com/windmill-labs/windmill/issues/9593)) ([6b916ac](https://github.com/windmill-labs/windmill/commit/6b916ac688e0305284e6cf819bf28803bcca0118)) +* expose parent_hash in MCP createScript tool for updates ([#9586](https://github.com/windmill-labs/windmill/issues/9586)) ([a69505d](https://github.com/windmill-labs/windmill/commit/a69505df9bf25d7c4f11d0528a7450c08dbb422c)) +* **flows:** stop serializing default retry/stop_after_if fields ([#9583](https://github.com/windmill-labs/windmill/issues/9583)) ([e1e2a24](https://github.com/windmill-labs/windmill/commit/e1e2a24b6a6752b3ac779cb0db38061cbc54425e)) +* **security:** sanitize dependency names & connection strings against command/SQL injection ([#9590](https://github.com/windmill-labs/windmill/issues/9590)) ([aff0a4e](https://github.com/windmill-labs/windmill/commit/aff0a4ec189cd8e315282e878bb858ef00635b90)) + +## [1.725.0](https://github.com/windmill-labs/windmill/compare/v1.724.0...v1.725.0) (2026-06-15) + + +### Features + +* Db-backed user drafts ([#9351](https://github.com/windmill-labs/windmill/issues/9351)) ([1fc3557](https://github.com/windmill-labs/windmill/commit/1fc355709c025fd256c5a4035356e15a5a05b23d)) +* scope AI session storage per user, session list in IndexedDB ([#9518](https://github.com/windmill-labs/windmill/issues/9518)) ([aa26c4d](https://github.com/windmill-labs/windmill/commit/aa26c4d9b22b3a353a6c0605eb9a4193e34aa18c)) + + +### Bug Fixes + +* **powershell:** sanitize module names to prevent command injection (CWE-78) ([#9587](https://github.com/windmill-labs/windmill/issues/9587)) ([6acce7a](https://github.com/windmill-labs/windmill/commit/6acce7a88733683153db534cb18752b31d93af82)) + +## [1.724.0](https://github.com/windmill-labs/windmill/compare/v1.723.0...v1.724.0) (2026-06-15) + + +### Features + +* **cli:** add --yes, --secret/--no-secret and --description to variable add ([#9548](https://github.com/windmill-labs/windmill/issues/9548)) ([4e9e0c0](https://github.com/windmill-labs/windmill/commit/4e9e0c024b4b95f9676b1646591d8f0c662e84ab)) +* **frontend:** improve AI chat cancel and interrupted-turn handling ([#9539](https://github.com/windmill-labs/windmill/issues/9539)) ([114c412](https://github.com/windmill-labs/windmill/commit/114c41251a8c738b58a1a3dd9434d09d33feb6f1)) +* **frontend:** precise AI chat context usage tracking + indicator ([#9551](https://github.com/windmill-labs/windmill/issues/9551)) ([2b47180](https://github.com/windmill-labs/windmill/commit/2b471805bf1c92bb210cfacda217a4341e1f989c)) +* wire chat reasoning effort through gemini and bedrock proxies ([#9545](https://github.com/windmill-labs/windmill/issues/9545)) ([aaf0563](https://github.com/windmill-labs/windmill/commit/aaf05635cedadc73455dc522474b673719f9fd5c)) + + +### Bug Fixes + +* actually isolate windows job children from CTRL_BREAK_EVENT + reap on worker death ([#9563](https://github.com/windmill-labs/windmill/issues/9563)) ([61f3291](https://github.com/windmill-labs/windmill/commit/61f3291b240bdb5c26bee8947351a9590bc3bd45)) +* **ai:** enforce resource authz when loading MCP tools in agent worker ([#9571](https://github.com/windmill-labs/windmill/issues/9571)) ([317a862](https://github.com/windmill-labs/windmill/commit/317a8629d1c8436d2a6f3443bd25b81d606ce283)) +* append system CA bundle to tracing proxy cert file ([#9549](https://github.com/windmill-labs/windmill/issues/9549)) ([3cf4083](https://github.com/windmill-labs/windmill/commit/3cf40839602e5c3d1df51f0a29b01736bade09da)) +* **cli:** consistent flow inline lock filenames for compound extensions ([#9555](https://github.com/windmill-labs/windmill/issues/9555)) ([f0659a7](https://github.com/windmill-labs/windmill/commit/f0659a755a161420833e3bfdbe04befc6ebeb977)) +* **flows:** skip_if evaluates wrong previous_result during retry ([#9547](https://github.com/windmill-labs/windmill/issues/9547)) ([2aab352](https://github.com/windmill-labs/windmill/commit/2aab35245c362c2f911c60ea435f29bfb1369ebf)) +* **folders:** allow hyphens in folder names ([#9566](https://github.com/windmill-labs/windmill/issues/9566)) ([84df111](https://github.com/windmill-labs/windmill/commit/84df11177f2009bff007e9b722b55a9a5a63c06a)), closes [#8474](https://github.com/windmill-labs/windmill/issues/8474) +* **frontend:** load resource value in JSON editor when resource type is missing ([#9574](https://github.com/windmill-labs/windmill/issues/9574)) ([251266c](https://github.com/windmill-labs/windmill/commit/251266cd8119dbef314314daed43aa43ab92f1c9)) +* isolate windows job children from worker CTRL_BREAK_EVENT ([#9562](https://github.com/windmill-labs/windmill/issues/9562)) ([1d6191e](https://github.com/windmill-labs/windmill/commit/1d6191ebb75843917eec6c76a4be347f9ac4cb72)) +* stop sending temperature for AI chat across all providers ([#9553](https://github.com/windmill-labs/windmill/issues/9553)) ([3585716](https://github.com/windmill-labs/windmill/commit/358571687296fbe5c378533b3c1662707955c64a)) + +## [1.723.0](https://github.com/windmill-labs/windmill/compare/v1.722.0...v1.723.0) (2026-06-11) + + +### Features + +* add get_app_runtime_logs tool to global chat ([#9502](https://github.com/windmill-labs/windmill/issues/9502)) ([f86d0d7](https://github.com/windmill-labs/windmill/commit/f86d0d79fc6aa23119fd59761330ab79592d0a2a)) +* **cli:** improve agent prompts/skills and workspace fork workflow ([#9531](https://github.com/windmill-labs/windmill/issues/9531)) ([5bdc4f8](https://github.com/windmill-labs/windmill/commit/5bdc4f83ce37302a2c375d0ff73763acfee2aadb)) +* enable native web search in copilot ([#9522](https://github.com/windmill-labs/windmill/issues/9522)) ([d3f5fe1](https://github.com/windmill-labs/windmill/commit/d3f5fe1c8c39ff07d05f0922b8f40aa95756a707)) + + +### Bug Fixes + +* **frontend:** stop live activity flickering when user has multiple tabs ([#9543](https://github.com/windmill-labs/windmill/issues/9543)) ([57e627e](https://github.com/windmill-labs/windmill/commit/57e627eabf7c4144ce1c07214ad44d026b82f0b4)) +* omit temperature for claude fable 5 ([#9540](https://github.com/windmill-labs/windmill/issues/9540)) ([bd00bee](https://github.com/windmill-labs/windmill/commit/bd00beeac54dbcfa9ab86fd336fca1a8fa289341)) +* refetch license key from settings when in-memory key is invalid ([#9534](https://github.com/windmill-labs/windmill/issues/9534)) ([38c0ccd](https://github.com/windmill-labs/windmill/commit/38c0ccdf563d3655a4cba390ce9372bc9f9c4a9b)) + +## [1.722.0](https://github.com/windmill-labs/windmill/compare/v1.721.0...v1.722.0) (2026-06-11) + + +### Features + +* add reasoning effort control and thinking display to AI chat ([#9511](https://github.com/windmill-labs/windmill/issues/9511)) ([7f987e8](https://github.com/windmill-labs/windmill/commit/7f987e8c9807b72d9cc3901b6e4d02a24c423f50)) +* **ai-chat:** collapse big pastes, cap input height, escape HTML ([#9487](https://github.com/windmill-labs/windmill/issues/9487)) ([365e204](https://github.com/windmill-labs/windmill/commit/365e20410ed528d5b4e967b64fb282bcc1e03ccd)) +* **ai-chat:** quick access to AI prompt settings from chat ([#9508](https://github.com/windmill-labs/windmill/issues/9508)) ([b894f78](https://github.com/windmill-labs/windmill/commit/b894f783f183ab795eab6f57da8654275d9ad82d)) +* **ai:** add list_runs and get_job_logs tools to global chat mode ([#9488](https://github.com/windmill-labs/windmill/issues/9488)) ([cfe5119](https://github.com/windmill-labs/windmill/commit/cfe51190356a9e922f6398dd875abc368accbd15)) +* clear conflict error + force delete when reusing a fork workspace id ([#9499](https://github.com/windmill-labs/windmill/issues/9499)) ([fddabe9](https://github.com/windmill-labs/windmill/commit/fddabe9c5c6f178b4b09854dc11e50adafd44c87)) +* **flow:** support worker tag override on AI agent steps ([#9513](https://github.com/windmill-labs/windmill/issues/9513)) ([a6a5600](https://github.com/windmill-labs/windmill/commit/a6a5600833063e36b35aac7f90dcbdcd3db7c627)) +* folder-level label inheritance for scripts, flows and jobs ([#9524](https://github.com/windmill-labs/windmill/issues/9524)) ([765f50c](https://github.com/windmill-labs/windmill/commit/765f50c474f8abf76550664ed15418ddb3c0b221)) +* **frontend:** show AI sessions in narrow-screen burger menu ([#9523](https://github.com/windmill-labs/windmill/issues/9523)) ([a8f1062](https://github.com/windmill-labs/windmill/commit/a8f1062f37228e7a8257aac1cd3295bd5084ff90)) +* prefer idle worker pods on k8s autoscaling scale-in via pod-deletion-cost ([#9515](https://github.com/windmill-labs/windmill/issues/9515)) ([3c3f157](https://github.com/windmill-labs/windmill/commit/3c3f15722fd22713cc8baa744d9a81207943a33c)) +* prompt browser confirmation on page exit with unsaved changes ([#9503](https://github.com/windmill-labs/windmill/issues/9503)) ([3119e16](https://github.com/windmill-labs/windmill/commit/3119e16ed8df0daec0e019ae2efb2176d7e9e953)) +* **worker:** #ssh directive to run a bash script on a remote SSH host ([#9479](https://github.com/windmill-labs/windmill/issues/9479)) ([afddfe8](https://github.com/windmill-labs/windmill/commit/afddfe84452357b06f4fb815566d4c8269ceafbf)) +* workspace protection rule to restrict anonymous app deployment ([#9509](https://github.com/windmill-labs/windmill/issues/9509)) ([cf9ad54](https://github.com/windmill-labs/windmill/commit/cf9ad54181d38c5d3c3aa05208e17d8d97eae4ef)) + + +### Bug Fixes + +* **cli:** include lock-relevant script content in lock cache key ([#9528](https://github.com/windmill-labs/windmill/issues/9528)) ([dc60e1a](https://github.com/windmill-labs/windmill/commit/dc60e1aa174f2a7616d2d4a37117a463ef25a9d6)) +* **frontend:** allow copy/paste shortcuts inside ConfirmationModal ([#9505](https://github.com/windmill-labs/windmill/issues/9505)) ([7fc5340](https://github.com/windmill-labs/windmill/commit/7fc5340da39c049d059aa0ae15ceb6a98ca58053)) +* **frontend:** clarify trigger filters match the message parsed as JSON ([#9516](https://github.com/windmill-labs/windmill/issues/9516)) ([0b17843](https://github.com/windmill-labs/windmill/commit/0b178437ce8cd8295be21c0c6077c39e957a3337)) +* **frontend:** enable Apply button when env vars change in worker group config ([#9501](https://github.com/windmill-labs/windmill/issues/9501)) ([c80c6d8](https://github.com/windmill-labs/windmill/commit/c80c6d8fcdfc1de8fe841e9699452f67a25cad23)) +* **frontend:** improve AI chat markdown and typing dots in dark mode ([#9497](https://github.com/windmill-labs/windmill/issues/9497)) ([4e86806](https://github.com/windmill-labs/windmill/commit/4e868062d4c22c5574149d35235b7ad1abe805f3)) +* **frontend:** stop echoing draft values in global chat write tool results ([#9530](https://github.com/windmill-labs/windmill/issues/9530)) ([ce6e2f7](https://github.com/windmill-labs/windmill/commit/ce6e2f7ade25ca91c375f0de5f1be3f1c82ccb47)) +* inherit container NO_PROXY into MITM tracing proxy job exclusions ([#9492](https://github.com/windmill-labs/windmill/issues/9492)) ([4c22e3b](https://github.com/windmill-labs/windmill/commit/4c22e3b712a74828cf654ea7d89aeab5b50cfbd7)) +* make default chat model optional in AI settings ([#9514](https://github.com/windmill-labs/windmill/issues/9514)) ([1d43288](https://github.com/windmill-labs/windmill/commit/1d4328877fcc87352fb56de63f0570739d5a9dc4)) +* **nsjail:** make ansible collections mount non-mandatory ([#9510](https://github.com/windmill-labs/windmill/issues/9510)) ([08da7a1](https://github.com/windmill-labs/windmill/commit/08da7a121b4b835500dfc2bd943c4bdce912c63e)) +* **nsjail:** make ansible uv tools mount non-mandatory ([#9507](https://github.com/windmill-labs/windmill/issues/9507)) ([dc368a9](https://github.com/windmill-labs/windmill/commit/dc368a9669e812c593ad848266f645b6223501e6)) + +## [1.721.0](https://github.com/windmill-labs/windmill/compare/v1.720.0...v1.721.0) (2026-06-09) + + +### Features + +* deployed↔draft compare + AI-session draft bar ([#9435](https://github.com/windmill-labs/windmill/issues/9435)) ([b0b330c](https://github.com/windmill-labs/windmill/commit/b0b330c7864d0159af4b0f17dbb3c09bd015145b)) + + +### Bug Fixes + +* **cli:** reconcile case-only path drift during sync on case-insensitive filesystems (WIN-2020) ([#9485](https://github.com/windmill-labs/windmill/issues/9485)) ([c258928](https://github.com/windmill-labs/windmill/commit/c258928ab62adc1327913c21556520dfd1e5c24c)) +* drop archived items from fork compare (spurious 'not visible' warning) ([#9481](https://github.com/windmill-labs/windmill/issues/9481)) ([92c21bb](https://github.com/windmill-labs/windmill/commit/92c21bbe6586f3c285796a98692e976515a629d5)) +* require auth to view approval details when user_auth_required ([#9482](https://github.com/windmill-labs/windmill/issues/9482)) ([5f41ddd](https://github.com/windmill-labs/windmill/commit/5f41ddd3a592bcd504f94fc99060ca5d79c36190)) + +## [1.720.0](https://github.com/windmill-labs/windmill/compare/v1.719.0...v1.720.0) (2026-06-08) + + +### Features + +* allow private MCP server URLs ([#9470](https://github.com/windmill-labs/windmill/issues/9470)) ([3bc5800](https://github.com/windmill-labs/windmill/commit/3bc5800197db383ae6f708415701a4bdbe2e3345)) +* **api:** add endpoint to update token label ([#9474](https://github.com/windmill-labs/windmill/issues/9474)) ([e8e0701](https://github.com/windmill-labs/windmill/commit/e8e0701a360d0614c4c5a74f6410ba6ac0638caa)) +* **frontend:** use unified drill picker for AI chat @-mention dropdown ([#9159](https://github.com/windmill-labs/windmill/issues/9159)) ([64b089c](https://github.com/windmill-labs/windmill/commit/64b089cd23cca4601abb09f092a32becb80d9394)) + + +### Bug Fixes + +* center auth0/okta icons and respect currentColor ([#9457](https://github.com/windmill-labs/windmill/issues/9457)) ([5d0ef7d](https://github.com/windmill-labs/windmill/commit/5d0ef7dfd91b3021d125a1b34f81f0788f173786)) +* **forks:** keep trigger/schedule operational state owned by the parent - WIN-2019 ([#9476](https://github.com/windmill-labs/windmill/issues/9476)) ([192574a](https://github.com/windmill-labs/windmill/commit/192574ab8f98d9521a232fc8a4935d407b00cb3a)) +* **frontend:** respect forced column order for numeric column names ([#9463](https://github.com/windmill-labs/windmill/issues/9463)) ([44f5dd6](https://github.com/windmill-labs/windmill/commit/44f5dd6636d4b23aa55383b8b8abe4c2f73bc88d)) +* **frontend:** use ban icon for canceled jobs instead of hourglass ([#9478](https://github.com/windmill-labs/windmill/issues/9478)) ([fa86c62](https://github.com/windmill-labs/windmill/commit/fa86c62b6600e7d47dadf4706d7002706333d919)) +* gate native integration pickers behind non-operator check ([#9465](https://github.com/windmill-labs/windmill/issues/9465)) ([6156e23](https://github.com/windmill-labs/windmill/commit/6156e2372a785ccd0c6f29cb74e90bee69e76483)) +* **oauth:** persist refreshed token through configured secret backend ([#9471](https://github.com/windmill-labs/windmill/issues/9471)) ([76c0d97](https://github.com/windmill-labs/windmill/commit/76c0d970a18bf28ddc48dd746570e73486542606)) +* refresh session editor preview on breadcrumb target switch ([#9475](https://github.com/windmill-labs/windmill/issues/9475)) ([6d522b3](https://github.com/windmill-labs/windmill/commit/6d522b3989ace1f214bd049d910bc0d2a2a6893e)) + ## [1.719.0](https://github.com/windmill-labs/windmill/compare/v1.718.0...v1.719.0) (2026-06-06) diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 2750058a68..622c79bf46 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { CompletedJob, Flow, Script } from '../../../frontend/src/lib/gen' +import type { CompletedJob, Flow, Job, Script } from '../../../frontend/src/lib/gen' import type { DataTableTables, DataTableTableSchema, @@ -29,18 +29,36 @@ export interface BenchmarkWorkspaceFlow { value: Flow['value'] } +export interface BenchmarkWorkspaceJob { + /** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */ + id?: string + jobKind?: CompletedJob['job_kind'] + scriptPath?: string + createdBy?: string + label?: string + success?: boolean + logs?: string +} + export interface BenchmarkWorkspaceRunnables { scripts?: BenchmarkWorkspaceScript[] flows?: BenchmarkWorkspaceFlow[] datatables?: BenchmarkDatatableSeed[] + jobs?: BenchmarkWorkspaceJob[] } type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' } const benchmarkWorkspaces = new Set() const benchmarkWorkspaceRunnables = new Map() +// Keyed by `${workspace}::${jobId}` so concurrent attempts (or distinct cases) +// can seed the same fixed job id without clobbering each other's entry. const benchmarkJobs = new Map() +function benchmarkJobKey(workspace: string, jobId: string): string { + return `${workspace}::${jobId}` +} + export function resetBenchmarkMockBackend(): void { benchmarkWorkspaces.clear() benchmarkWorkspaceRunnables.clear() @@ -62,6 +80,19 @@ export function registerBenchmarkWorkspaceRunnables( ...runnables, datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined }) + // Seed any fixture jobs so list_runs / get_job_logs have data to return. + for (const seed of runnables.jobs ?? []) { + createBenchmarkCompletedJob({ + workspace, + id: seed.id, + jobKind: seed.jobKind ?? 'script', + success: seed.success, + scriptPath: seed.scriptPath, + createdBy: seed.createdBy, + label: seed.label, + logs: seed.logs + }) + } } export function unregisterBenchmarkWorkspace(workspace: string): void { @@ -131,14 +162,17 @@ export function createBenchmarkCompletedJob(input: { scriptPath?: string scriptHash?: string args?: Record + id?: string + createdBy?: string + label?: string }): string { - const jobId = `benchmark-job-${randomUUID()}` + const jobId = input.id ?? `benchmark-job-${randomUUID()}` const now = new Date().toISOString() const job: BenchmarkCompletedJob = { type: 'CompletedJob', id: jobId, workspace_id: input.workspace, - created_by: 'ai-evals', + created_by: input.createdBy ?? 'ai-evals', created_at: now, started_at: now, completed_at: now, @@ -156,10 +190,11 @@ export function createBenchmarkCompletedJob(input: { is_skipped: false, email: 'ai-evals@local', visible_to_owner: true, - tag: 'benchmark' + tag: 'benchmark', + labels: input.label ? [input.label] : undefined } - benchmarkJobs.set(jobId, { workspace: input.workspace, job }) + benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { workspace: input.workspace, job }) return jobId } @@ -167,13 +202,42 @@ export function getBenchmarkCompletedJob( workspace: string, jobId: string ): BenchmarkCompletedJob | null { - const entry = benchmarkJobs.get(jobId) - if (!entry || entry.workspace !== workspace) { + const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId)) + if (!entry) { return null } return structuredClone(entry.job) } +/** + * List seeded/recorded jobs for a benchmark workspace, most recent first — + * the shape `JobService.listJobs` returns. Returns `null` for a non-benchmark + * workspace so the caller can fall through to the real backend. Server-side + * filters (path/creator/status/limit) are intentionally not applied: global + * eval cases assert on the recorded `list_runs` tool call, not on filtering. + */ +export function listBenchmarkJobs(workspace: string): Job[] | null { + if (!hasBenchmarkWorkspace(workspace)) { + return null + } + return [...benchmarkJobs.values()] + .filter((entry) => entry.workspace === workspace) + .map((entry) => structuredClone(entry.job) as Job) + .sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '')) +} + +/** + * Mirror `JobService.getJobLogs` (response is the raw log string). Throws a + * "not found" error for an unknown id, matching the backend 404. + */ +export function getBenchmarkJobLogs(workspace: string, jobId: string): string { + const job = getBenchmarkCompletedJob(workspace, jobId) + if (!job) { + throw new Error(`Job Logs not found for "${jobId}"`) + } + return job.logs ?? '' +} + // ============= Datatables (best-effort in-memory SQL) ============= /** diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index a84f990483..ebbbac8d11 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -37,11 +37,13 @@ vi.mock('$lib/gen', async () => { getBenchmarkCompletedJobResultMaybe, getBenchmarkDatatableSchema, getBenchmarkFlowByPath, + getBenchmarkJobLogs, getBenchmarkScriptByHash, getBenchmarkScriptByPath, hasBenchmarkWorkspace, listBenchmarkDatatables, listBenchmarkFlows, + listBenchmarkJobs, listBenchmarkScripts, createBenchmarkHttpTrigger, createBenchmarkSchedule, @@ -199,7 +201,15 @@ vi.mock('$lib/gen', async () => { getCompletedJobResultMaybe: async (data: { workspace: string; id: string }) => hasBenchmarkWorkspace(data.workspace) ? getBenchmarkCompletedJobResultMaybe({ workspace: data.workspace, id: data.id }) - : actual.JobService.getCompletedJobResultMaybe(data) + : actual.JobService.getCompletedJobResultMaybe(data), + listJobs: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? (listBenchmarkJobs(data.workspace) ?? []) + : actual.JobService.listJobs(data), + getJobLogs: async (data: { workspace: string; id: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkJobLogs(data.workspace, data.id) + : actual.JobService.getJobLogs(data) }), WorkspaceService: wrapService(actual.WorkspaceService, { listDataTableTables: async (data: { workspace: string }) => diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 8d58248d62..28839b0594 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -821,3 +821,52 @@ - uses the wmill.datatable() SDK to query the orders table and sum the order totals - returns the total revenue from the script - leaves the result as an AI draft and does not deploy or save it + +- id: global-test27-list-recent-runs + prompt: |- + What are the most recent runs in this workspace? Give me a quick rundown of what ran and whether it succeeded. + initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + - write_script + # Read-only job inspection produces no draft, so the global judge (which only + # sees the drafts artifact) would score it empty — validate via tool use. + skipJudge: true + judgeChecklist: + - lists the recent runs from the workspace rather than fabricating them + - summarizes each run's path and success/failure from the returned data + +- id: global-test28-fetch-failed-run-logs + prompt: |- + The run with id 01920000-0000-7000-8000-0000000000f1 failed. Pull its logs and tell me what went wrong. + initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - get_job_logs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + - write_script + toolCallArgs: + - tool: get_job_logs + field: id + stringIncludesAnyOf: + - 01920000-0000-7000-8000-0000000000f1 + # Same as above — no draft is produced, so rely on the deterministic tool-use + # and argument checks rather than the judge. + skipJudge: true + judgeChecklist: + - fetches the logs for the requested job id + - explains the failure from the returned logs (connection refused to the upstream API) diff --git a/ai_evals/fixtures/frontend/global/initial/jobs_seed.json b/ai_evals/fixtures/frontend/global/initial/jobs_seed.json new file mode 100644 index 0000000000..b075d6df26 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/jobs_seed.json @@ -0,0 +1,30 @@ +{ + "workspace": { + "jobs": [ + { + "id": "01920000-0000-7000-8000-0000000000f1", + "scriptPath": "f/etl/sync_customers", + "jobKind": "script", + "createdBy": "alice", + "success": false, + "logs": "Starting customer sync...\nFetched 0 records\nERROR: connection refused to https://api.upstream.example.com\n at fetchCustomers (sync_customers.ts:42)\nJob failed with exit code 1" + }, + { + "id": "01920000-0000-7000-8000-0000000000f2", + "scriptPath": "f/reports/daily_digest", + "jobKind": "script", + "createdBy": "bob", + "success": true, + "logs": "Generating daily digest...\nDigest emailed to 12 recipients\nDone in 1.2s" + }, + { + "id": "01920000-0000-7000-8000-0000000000f3", + "scriptPath": "f/billing/charge_invoices", + "jobKind": "flow", + "createdBy": "alice", + "success": true, + "logs": "Processing invoices...\nCharged 8 invoices totalling $1,240.00\nDone" + } + ] + } +} diff --git a/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json b/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json deleted file mode 100644 index 461d1afb14..0000000000 --- a/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "workspace_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - null, - false, - false, - false, - true, - true, - true, - null, - false, - false, - false, - null - ] - }, - "hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328" -} diff --git a/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json new file mode 100644 index 0000000000..71f0c1e2d8 --- /dev/null +++ b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72" +} diff --git a/backend/.sqlx/query-0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034.json b/backend/.sqlx/query-0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034.json deleted file mode 100644 index 2dcdba586b..0000000000 --- a/backend/.sqlx/query-0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034" -} diff --git a/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json b/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json deleted file mode 100644 index d2c85b0e53..0000000000 --- a/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - false, - null, - false, - false, - false, - true, - true, - true, - null, - false, - false, - false - ] - }, - "hash": "115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc" -} diff --git a/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json b/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json deleted file mode 100644 index 023eaaa010..0000000000 --- a/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "verified!", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "super_admin!", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "devops!", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user!", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source!", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled!", - "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "workspace_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37" -} diff --git a/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json new file mode 100644 index 0000000000..d67ddf711b --- /dev/null +++ b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, (email IS NULL), created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "typ!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f" +} diff --git a/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json new file mode 100644 index 0000000000..900cbdae4d --- /dev/null +++ b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ, value, created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2" +} diff --git a/backend/.sqlx/query-825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3.json b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json similarity index 79% rename from backend/.sqlx/query-825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3.json rename to backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json index 876bc39b48..2d22e40cc7 100644 --- a/backend/.sqlx/query-825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3.json +++ b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path\n FROM app\n WHERE workspace_id = $1", + "query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path\n FROM app\n WHERE workspace_id = $1", "describe": { "columns": [ { @@ -40,11 +40,6 @@ }, { "ordinal": 7, - "name": "draft_only", - "type_info": "Bool" - }, - { - "ordinal": 8, "name": "custom_path", "type_info": "Text" } @@ -62,9 +57,8 @@ false, false, false, - true, true ] }, - "hash": "825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3" + "hash": "26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5" } diff --git a/backend/.sqlx/query-285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6.json b/backend/.sqlx/query-285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6.json deleted file mode 100644 index 8b353e43e1..0000000000 --- a/backend/.sqlx/query-285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n path\n FROM\n flow_version\n WHERE\n id = $1 AND\n workspace_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6" -} diff --git a/backend/.sqlx/query-28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60.json b/backend/.sqlx/query-28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60.json deleted file mode 100644 index a5c5e9427e..0000000000 --- a/backend/.sqlx/query-28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM flow WHERE path = $1 AND workspace_id = $2 AND archived = false", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "draft_only", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60" -} diff --git a/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json b/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json deleted file mode 100644 index fd205d6750..0000000000 --- a/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "label", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "token_prefix", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "expiration", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_used_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 6, - "name": "workspace_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8" - ] - }, - "nullable": [ - true, - false, - true, - false, - false, - true, - true - ] - }, - "hash": "2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d" -} diff --git a/backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json b/backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json new file mode 100644 index 0000000000..7e13b3aa44 --- /dev/null +++ b/backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END)\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Timestamptz", + "Bool", + "Bool" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e" +} diff --git a/backend/.sqlx/query-311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894.json b/backend/.sqlx/query-311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894.json deleted file mode 100644 index 2b5e787553..0000000000 --- a/backend/.sqlx/query-311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n path\n FROM\n flow_version\n WHERE\n id = $1 AND\n workspace_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894" -} diff --git a/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json new file mode 100644 index 0000000000..66809be416 --- /dev/null +++ b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs\n FROM flow WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe" +} diff --git a/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json new file mode 100644 index 0000000000..f1fab7b130 --- /dev/null +++ b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6" +} diff --git a/backend/.sqlx/query-756f82b72af07fd690f37b2e16ed2d390604f4fc4cb330842a88d5764cbcf0c6.json b/backend/.sqlx/query-3c84781704b84b8a927ecce5a3fcb3adcf0175d0a71013f2497397c1c8ccc619.json similarity index 74% rename from backend/.sqlx/query-756f82b72af07fd690f37b2e16ed2d390604f4fc4cb330842a88d5764cbcf0c6.json rename to backend/.sqlx/query-3c84781704b84b8a927ecce5a3fcb3adcf0175d0a71013f2497397c1c8ccc619.json index df721b4cde..3568d1723e 100644 --- a/backend/.sqlx/query-756f82b72af07fd690f37b2e16ed2d390604f4fc4cb330842a88d5764cbcf0c6.json +++ b/backend/.sqlx/query-3c84781704b84b8a927ecce5a3fcb3adcf0175d0a71013f2497397c1c8ccc619.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27, $44)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)", + "query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27,\n -- $44 (payload labels) merged with the labels of the runnable's folder, if any\n -- ($45/$46 are runnable_path/workspace_id again, kept separate to avoid parameter type conflicts)\n (SELECT CASE WHEN fl.labels IS NULL THEN $44\n ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl)\n END\n FROM folder_labels($46, $45) AS fl(labels)))\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)", "describe": { "columns": [], "parameters": { @@ -137,10 +137,12 @@ "Varchar", "Bool", "Int8", - "TextArray" + "TextArray", + "Text", + "Text" ] }, "nullable": [] }, - "hash": "756f82b72af07fd690f37b2e16ed2d390604f4fc4cb330842a88d5764cbcf0c6" + "hash": "3c84781704b84b8a927ecce5a3fcb3adcf0175d0a71013f2497397c1c8ccc619" } diff --git a/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json new file mode 100644 index 0000000000..335561cfef --- /dev/null +++ b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81" +} diff --git a/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json similarity index 80% rename from backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json rename to backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json index e9919c7da4..ebd8594a06 100644 --- a/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json +++ b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + "query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2", "describe": { "columns": [ { @@ -31,5 +31,5 @@ false ] }, - "hash": "66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e" + "hash": "3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5" } diff --git a/backend/.sqlx/query-71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345.json b/backend/.sqlx/query-3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd.json similarity index 51% rename from backend/.sqlx/query-71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345.json rename to backend/.sqlx/query-3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd.json index da3b4cc1b9..c4d6928bc5 100644 --- a/backend/.sqlx/query-71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345.json +++ b/backend/.sqlx/query-3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false", + "query": "SELECT policy->>'execution_mode' = 'anonymous' FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE", "describe": { "columns": [ { "ordinal": 0, - "name": "draft_only", + "name": "?column?", "type_info": "Bool" } ], @@ -16,8 +16,8 @@ ] }, "nullable": [ - true + null ] }, - "hash": "71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345" + "hash": "3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd" } diff --git a/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json b/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json deleted file mode 100644 index a977f306ab..0000000000 --- a/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "label", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "token_prefix", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "expiration", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_used_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 6, - "name": "workspace_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8" - ] - }, - "nullable": [ - true, - false, - true, - false, - false, - true, - true - ] - }, - "hash": "40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8" -} diff --git a/backend/.sqlx/query-269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed.json b/backend/.sqlx/query-42600bdeb6b86ac306ab276cb49241508776f5f998821e04bc92df917176cea6.json similarity index 83% rename from backend/.sqlx/query-269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed.json rename to backend/.sqlx/query-42600bdeb6b86ac306ab276cb49241508776f5f998821e04bc92df917176cea6.json index 07d46964f9..617930de62 100644 --- a/backend/.sqlx/query-269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed.json +++ b/backend/.sqlx/query-42600bdeb6b86ac306ab276cb49241508776f5f998821e04bc92df917176cea6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE name = $1 AND workspace_id = $2", + "query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels FROM folder WHERE name = $1 AND workspace_id = $2", "describe": { "columns": [ { @@ -47,6 +47,11 @@ "ordinal": 8, "name": "default_permissioned_as", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -64,8 +69,9 @@ true, true, true, - false + false, + true ] }, - "hash": "269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed" + "hash": "42600bdeb6b86ac306ab276cb49241508776f5f998821e04bc92df917176cea6" } diff --git a/backend/.sqlx/query-464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53.json b/backend/.sqlx/query-464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53.json deleted file mode 100644 index df664b5b8b..0000000000 --- a/backend/.sqlx/query-464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_runtime SET ping = now() WHERE id = $1 AND ping < now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53" -} diff --git a/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json b/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json deleted file mode 100644 index 4621e0cc11..0000000000 --- a/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Bool", - "Varchar", - "Int4", - "Bool", - "Timestamptz", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26" -} diff --git a/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json b/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json deleted file mode 100644 index f1de82e5e6..0000000000 --- a/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "TextArray", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0" -} diff --git a/backend/.sqlx/query-ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb.json b/backend/.sqlx/query-4ca364e493dd51d5f83489dc4674eeadcf092b37a20d03d52f2160ca21f79fb0.json similarity index 51% rename from backend/.sqlx/query-ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb.json rename to backend/.sqlx/query-4ca364e493dd51d5f83489dc4674eeadcf092b37a20d03d52f2160ca21f79fb0.json index ff06d78624..f486fdd689 100644 --- a/backend/.sqlx/query-ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb.json +++ b/backend/.sqlx/query-4ca364e493dd51d5f83489dc4674eeadcf092b37a20d03d52f2160ca21f79fb0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as)\n SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as\n FROM folder\n WHERE workspace_id = $1", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels)\n SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels\n FROM folder\n WHERE workspace_id = $1", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb" + "hash": "4ca364e493dd51d5f83489dc4674eeadcf092b37a20d03d52f2160ca21f79fb0" } diff --git a/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json b/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json deleted file mode 100644 index 46025de086..0000000000 --- a/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ)\n DO UPDATE SET value = EXCLUDED.value, created_at = now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - { - "Custom": { - "name": "draft_type", - "kind": { - "Enum": [ - "script", - "flow", - "app" - ] - } - } - } - ] - }, - "nullable": [] - }, - "hash": "5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8" -} diff --git a/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json new file mode 100644 index 0000000000..be5e26ef9b --- /dev/null +++ b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a" +} diff --git a/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json deleted file mode 100644 index de1b71a1e6..0000000000 --- a/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Text", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2" -} diff --git a/backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json b/backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json new file mode 100644 index 0000000000..0191cf8bfd --- /dev/null +++ b/backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END)\n AND path = $3 AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc" +} diff --git a/backend/.sqlx/query-567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139.json b/backend/.sqlx/query-567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139.json deleted file mode 100644 index 4bebcfa038..0000000000 --- a/backend/.sqlx/query-567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139" -} diff --git a/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json new file mode 100644 index 0000000000..bb326fab87 --- /dev/null +++ b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND (email = $4 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4" +} diff --git a/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json new file mode 100644 index 0000000000..ca75762cd6 --- /dev/null +++ b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7.json b/backend/.sqlx/query-5b4214deb7677ae16df0bea7c6553461dee4bcc8d7180a9a517d5809c42e11d7.json similarity index 54% rename from backend/.sqlx/query-86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7.json rename to backend/.sqlx/query-5b4214deb7677ae16df0bea7c6553461dee4bcc8d7180a9a517d5809c42e11d7.json index 82f4b5f8e6..a5e2b45b75 100644 --- a/backend/.sqlx/query-86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7.json +++ b/backend/.sqlx/query-5b4214deb7677ae16df0bea7c6553461dee4bcc8d7180a9a517d5809c42e11d7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as) SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as FROM folder WHERE workspace_id = $2", + "query": "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels) SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels FROM folder WHERE workspace_id = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7" + "hash": "5b4214deb7677ae16df0bea7c6553461dee4bcc8d7180a9a517d5809c42e11d7" } diff --git a/backend/.sqlx/query-b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400.json b/backend/.sqlx/query-636dd6906a20b608eea34e04a81bddb79398f57a34d180d08aa00aaaaa1050c8.json similarity index 82% rename from backend/.sqlx/query-b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400.json rename to backend/.sqlx/query-636dd6906a20b608eea34e04a81bddb79398f57a34d180d08aa00aaaaa1050c8.json index 593d49d450..9028b69027 100644 --- a/backend/.sqlx/query-b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400.json +++ b/backend/.sqlx/query-636dd6906a20b608eea34e04a81bddb79398f57a34d180d08aa00aaaaa1050c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", + "query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -47,6 +47,11 @@ "ordinal": 8, "name": "default_permissioned_as", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -65,8 +70,9 @@ true, true, true, - false + false, + true ] }, - "hash": "b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400" + "hash": "636dd6906a20b608eea34e04a81bddb79398f57a34d180d08aa00aaaaa1050c8" } diff --git a/backend/.sqlx/query-65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86.json b/backend/.sqlx/query-65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86.json deleted file mode 100644 index 2492cbc015..0000000000 --- a/backend/.sqlx/query-65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs\n FROM flow WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86" -} diff --git a/backend/.sqlx/query-dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e.json b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json similarity index 72% rename from backend/.sqlx/query-dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e.json rename to backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json index e327857d3f..80f172aa40 100644 --- a/backend/.sqlx/query-dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e.json +++ b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", "describe": { "columns": [], "parameters": { @@ -66,7 +66,6 @@ } }, "Varchar", - "Bool", "VarcharArray", "Int4", "Int4", @@ -96,5 +95,5 @@ }, "nullable": [] }, - "hash": "dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e" + "hash": "66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd" } diff --git a/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json new file mode 100644 index 0000000000..dcff085b5e --- /dev/null +++ b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e" +} diff --git a/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json new file mode 100644 index 0000000000..02e17b82cb --- /dev/null +++ b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d" +} diff --git a/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json new file mode 100644 index 0000000000..f4839e8e38 --- /dev/null +++ b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)\n VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e" +} diff --git a/backend/.sqlx/query-755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32.json b/backend/.sqlx/query-755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32.json deleted file mode 100644 index 112b8fe253..0000000000 --- a/backend/.sqlx/query-755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, draft_only, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, draft_only, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow\n WHERE workspace_id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32" -} diff --git a/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json new file mode 100644 index 0000000000..9dc1d0f296 --- /dev/null +++ b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d" +} diff --git a/backend/.sqlx/query-b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800.json b/backend/.sqlx/query-7fc4dcc5f46da2a1ac74bf80237a6ae28f1b817b2e1ff275a3f470bb88fc500c.json similarity index 82% rename from backend/.sqlx/query-b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800.json rename to backend/.sqlx/query-7fc4dcc5f46da2a1ac74bf80237a6ae28f1b817b2e1ff275a3f470bb88fc500c.json index cdf942ca54..fe7587f243 100644 --- a/backend/.sqlx/query-b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800.json +++ b/backend/.sqlx/query-7fc4dcc5f46da2a1ac74bf80237a6ae28f1b817b2e1ff275a3f470bb88fc500c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT DISTINCT unnest(labels) as \"label!\" FROM (\n SELECT labels FROM script WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM flow WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM resource WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL\n ) t ORDER BY 1", + "query": "SELECT DISTINCT unnest(labels) as \"label!\" FROM (\n SELECT labels FROM script WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM flow WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM resource WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM folder WHERE workspace_id = $1 AND labels IS NOT NULL\n ) t ORDER BY 1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800" + "hash": "7fc4dcc5f46da2a1ac74bf80237a6ae28f1b817b2e1ff275a3f470bb88fc500c" } diff --git a/backend/.sqlx/query-977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c.json b/backend/.sqlx/query-813b91a338f2fe46a7d27a155c435f33faa61710388f71f04da2d4569bc3dd71.json similarity index 67% rename from backend/.sqlx/query-977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c.json rename to backend/.sqlx/query-813b91a338f2fe46a7d27a155c435f33faa61710388f71f04da2d4569bc3dd71.json index f6512e6817..025a2c8ff3 100644 --- a/backend/.sqlx/query-977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c.json +++ b/backend/.sqlx/query-813b91a338f2fe46a7d27a155c435f33faa61710388f71f04da2d4569bc3dd71.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9)", "describe": { "columns": [], "parameters": { @@ -12,10 +12,11 @@ "Jsonb", "Text", "Varchar", - "Jsonb" + "Jsonb", + "TextArray" ] }, "nullable": [] }, - "hash": "977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c" + "hash": "813b91a338f2fe46a7d27a155c435f33faa61710388f71f04da2d4569bc3dd71" } diff --git a/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json new file mode 100644 index 0000000000..33e7bfe8ec --- /dev/null +++ b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5" +} diff --git a/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json new file mode 100644 index 0000000000..13402c8387 --- /dev/null +++ b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422" +} diff --git a/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json new file mode 100644 index 0000000000..681b9d7d7d --- /dev/null +++ b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df" +} diff --git a/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json deleted file mode 100644 index c9faa982ba..0000000000 --- a/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "owner", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 4, - "name": "label", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true, - true, - false, - true, - true - ] - }, - "hash": "93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9" -} diff --git a/backend/.sqlx/query-97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f.json b/backend/.sqlx/query-97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f.json deleted file mode 100644 index f14f3e5c61..0000000000 --- a/backend/.sqlx/query-97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "draft_only", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f" -} diff --git a/backend/.sqlx/query-9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771.json b/backend/.sqlx/query-9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771.json deleted file mode 100644 index ed3cf85466..0000000000 --- a/backend/.sqlx/query-9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only)\n VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}', false)", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771" -} diff --git a/backend/.sqlx/query-9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8.json b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json similarity index 62% rename from backend/.sqlx/query-9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8.json rename to backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json index 7959ccdbcb..08dfcfd6bd 100644 --- a/backend/.sqlx/query-9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8.json +++ b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", + "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", "describe": { "columns": [ { @@ -50,38 +50,53 @@ }, { "ordinal": 9, + "name": "inherited_labels?", + "type_info": "TextArray" + }, + { + "ordinal": 10, "name": "is_expired", "type_info": "Bool" }, { - "ordinal": 10, + "ordinal": 11, "name": "is_refreshed", "type_info": "Bool" }, { - "ordinal": 11, + "ordinal": 12, "name": "refresh_error", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 13, "name": "is_linked", "type_info": "Bool" }, { - "ordinal": 13, + "ordinal": 14, "name": "is_oauth?", "type_info": "Bool" }, { - "ordinal": 14, + "ordinal": 15, "name": "account", "type_info": "Int4" }, { - "ordinal": 15, + "ordinal": 16, "name": "ws_specific", "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 18, + "name": "is_draft", + "type_info": "Bool" } ], "parameters": { @@ -102,12 +117,15 @@ true, null, null, + null, true, null, false, true, + null, + null, null ] }, - "hash": "9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8" + "hash": "9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1" } diff --git a/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json deleted file mode 100644 index 163dc2285b..0000000000 --- a/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73" -} diff --git a/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json b/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json deleted file mode 100644 index 83efc5d35c..0000000000 --- a/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8" -} diff --git a/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json b/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json deleted file mode 100644 index dab14d9f1d..0000000000 --- a/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - false, - null, - null, - false, - false, - false, - true, - true, - true, - false, - false, - false - ] - }, - "hash": "a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10" -} diff --git a/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json deleted file mode 100644 index 4bb689c519..0000000000 --- a/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "installation_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "account_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "github_base_url", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null, - null, - null - ] - }, - "hash": "ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761" -} diff --git a/backend/.sqlx/query-b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8.json b/backend/.sqlx/query-b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8.json deleted file mode 100644 index 1341337cc5..0000000000 --- a/backend/.sqlx/query-b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ) DO UPDATE SET value = EXCLUDED.value", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - { - "Custom": { - "name": "draft_type", - "kind": { - "Enum": [ - "script", - "flow", - "app" - ] - } - } - } - ] - }, - "nullable": [] - }, - "hash": "b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8" -} diff --git a/backend/.sqlx/query-b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac.json b/backend/.sqlx/query-b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac.json deleted file mode 100644 index a08c31e743..0000000000 --- a/backend/.sqlx/query-b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "content", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac" -} diff --git a/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json new file mode 100644 index 0000000000..52e1ced19a --- /dev/null +++ b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND email IS NOT DISTINCT FROM $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d" +} diff --git a/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json b/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json deleted file mode 100644 index 3c45fe92ba..0000000000 --- a/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10,\n $11, $12::text::json, $13, now(), $14\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - "Text", - "Bool", - "Varchar", - "Bool", - "Bool", - "Text", - "Bool", - "Jsonb", - "Text", - "Varchar", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe" -} diff --git a/backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json similarity index 51% rename from backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json rename to backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json index 1b1a8da18c..78014926fa 100644 --- a/backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json +++ b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13", + "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13", "describe": { "columns": [], "parameters": { @@ -23,5 +23,5 @@ }, "nullable": [] }, - "hash": "c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02" + "hash": "c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2" } diff --git a/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json b/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json deleted file mode 100644 index 36be033396..0000000000 --- a/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", true as operator_only, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "verified!", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "super_admin!", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "devops!", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "first_time_user!", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source!", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled!", - "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "workspace_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef" -} diff --git a/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json new file mode 100644 index 0000000000..65003984ac --- /dev/null +++ b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453" +} diff --git a/backend/.sqlx/query-c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae.json b/backend/.sqlx/query-c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae.json deleted file mode 100644 index e566e02ac4..0000000000 --- a/backend/.sqlx/query-c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae" -} diff --git a/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json similarity index 66% rename from backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json rename to backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json index 425b4d8502..61c60afdc4 100644 --- a/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json +++ b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id", "describe": { "columns": [ { @@ -17,7 +17,6 @@ "Jsonb", "Int8Array", "Jsonb", - "Bool", "Text" ] }, @@ -25,5 +24,5 @@ false ] }, - "hash": "cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5" + "hash": "c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a" } diff --git a/backend/.sqlx/query-39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096.json b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json similarity index 67% rename from backend/.sqlx/query-39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096.json rename to backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json index 60bb866c1c..6d682c7bb7 100644 --- a/backend/.sqlx/query-39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096.json +++ b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096" + "hash": "cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6" } diff --git a/backend/.sqlx/query-cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354.json b/backend/.sqlx/query-cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354.json deleted file mode 100644 index f11e9daf54..0000000000 --- a/backend/.sqlx/query-cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['modules', flow_status->>'step', 'progress'], $1)\n WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354" -} diff --git a/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json new file mode 100644 index 0000000000..0eed069163 --- /dev/null +++ b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596" +} diff --git a/backend/.sqlx/query-d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73.json b/backend/.sqlx/query-d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73.json deleted file mode 100644 index 7447facfe7..0000000000 --- a/backend/.sqlx/query-d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "TextArray", - "Text" - ] - }, - "nullable": [] - }, - "hash": "d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73" -} diff --git a/backend/.sqlx/query-fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca.json b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json similarity index 55% rename from backend/.sqlx/query-fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca.json rename to backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json index b699cc582a..6ae3f60e49 100644 --- a/backend/.sqlx/query-fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca.json +++ b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json @@ -1,17 +1,17 @@ { "db_name": "PostgreSQL", - "query": "SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2", + "query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "path", + "name": "email", "type_info": "Varchar" } ], "parameters": { "Left": [ - "Int8", + "Text", "Text" ] }, @@ -19,5 +19,5 @@ false ] }, - "hash": "fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca" + "hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9" } diff --git a/backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json similarity index 65% rename from backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json rename to backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json index b101845b0e..35e384b496 100644 --- a/backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json +++ b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", + "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", "describe": { "columns": [ { @@ -15,7 +15,6 @@ "Varchar", "Varchar", "Jsonb", - "Bool", "Text", "TextArray" ] @@ -24,5 +23,5 @@ false ] }, - "hash": "e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d" + "hash": "ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d" } diff --git a/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json b/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json deleted file mode 100644 index be8864a85d..0000000000 --- a/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e" -} diff --git a/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json new file mode 100644 index 0000000000..c077375ef4 --- /dev/null +++ b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text", + "Timestamptz", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545" +} diff --git a/backend/.sqlx/query-ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628.json b/backend/.sqlx/query-ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628.json deleted file mode 100644 index 6dd90f0961..0000000000 --- a/backend/.sqlx/query-ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - { - "Custom": { - "name": "draft_type", - "kind": { - "Enum": [ - "script", - "flow", - "app" - ] - } - } - }, - "Text" - ] - }, - "nullable": [] - }, - "hash": "ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628" -} diff --git a/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json new file mode 100644 index 0000000000..879288cc04 --- /dev/null +++ b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7, $8,\n $9,\n $10, $11::text::json, $12, now(), $13\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Text", + "Bool", + "Jsonb", + "Text", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5" +} diff --git a/backend/.sqlx/query-27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8.json b/backend/.sqlx/query-ebc8f4840335dc3f4c8274fcf106825ecfdf7fbca59ea26541c5b4c0350b5502.json similarity index 52% rename from backend/.sqlx/query-27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8.json rename to backend/.sqlx/query-ebc8f4840335dc3f4c8274fcf106825ecfdf7fbca59ea26541c5b4c0350b5502.json index 6d127f4c83..044699ed7b 100644 --- a/backend/.sqlx/query-27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8.json +++ b/backend/.sqlx/query-ebc8f4840335dc3f4c8274fcf106825ecfdf7fbca59ea26541c5b4c0350b5502.json @@ -1,23 +1,22 @@ { "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM app WHERE path = $1 AND workspace_id = $2", + "query": "SELECT deleted FROM workspace WHERE id = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "draft_only", + "name": "deleted", "type_info": "Bool" } ], "parameters": { "Left": [ - "Text", "Text" ] }, "nullable": [ - true + false ] }, - "hash": "27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8" + "hash": "ebc8f4840335dc3f4c8274fcf106825ecfdf7fbca59ea26541c5b4c0350b5502" } diff --git a/backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json similarity index 73% rename from backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json rename to backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json index cc551bed42..7ac1fb17da 100644 --- a/backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json +++ b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE\n ORDER BY path, created_at DESC\n )", + "query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false\n ORDER BY path, created_at DESC\n )", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8" + "hash": "ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71" } diff --git a/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json b/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json deleted file mode 100644 index c1d113ee27..0000000000 --- a/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - null, - false, - false, - false, - true, - true, - true, - null, - false, - false, - false - ] - }, - "hash": "f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493" -} diff --git a/backend/.sqlx/query-f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030.json b/backend/.sqlx/query-f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030.json deleted file mode 100644 index bddbe43ad8..0000000000 --- a/backend/.sqlx/query-f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030" -} diff --git a/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json b/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json deleted file mode 100644 index 2393837f07..0000000000 --- a/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path!", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad" -} diff --git a/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json new file mode 100644 index 0000000000..394d36b747 --- /dev/null +++ b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745" +} diff --git a/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json deleted file mode 100644 index 117a8bdc1b..0000000000 --- a/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, false, true, true)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853" -} diff --git a/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json new file mode 100644 index 0000000000..c624143ea4 --- /dev/null +++ b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8" +} diff --git a/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json new file mode 100644 index 0000000000..e8dedf7ab9 --- /dev/null +++ b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1bd290e171..95efa7d40e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -106,9 +106,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -186,9 +186,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object", ] @@ -448,7 +448,7 @@ dependencies = [ "memchr", "num", "regex", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -620,7 +620,7 @@ checksum = "850b60ddcc664dcd848f8a2fa8436ab9336e051d6dd2b3f21f897dd8e9c24703" dependencies = [ "base64 0.22.1", "bytes", - "http 1.4.1", + "http 1.4.2", "rand 0.8.5", "reqwest 0.12.28", "serde", @@ -752,7 +752,7 @@ dependencies = [ "bytes", "fastrand", "hex", - "http 1.4.1", + "http 1.4.2", "ring 0.17.14", "time", "tokio", @@ -813,7 +813,7 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -840,7 +840,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] @@ -915,7 +915,7 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", "url", @@ -940,7 +940,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] @@ -986,7 +986,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] @@ -1010,7 +1010,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] @@ -1054,7 +1054,7 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "percent-encoding", "sha2 0.10.9", "time", @@ -1097,7 +1097,7 @@ dependencies = [ "futures-core", "futures-util", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "percent-encoding", "pin-project-lite", @@ -1117,7 +1117,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -1136,9 +1136,9 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "h2 0.3.27", - "h2 0.4.14", + "h2 0.4.15", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "hyper 0.14.32", "hyper 1.10.1", @@ -1208,7 +1208,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1228,7 +1228,7 @@ dependencies = [ "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -1246,7 +1246,7 @@ dependencies = [ "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1304,7 +1304,7 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "itoa", @@ -1332,7 +1332,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.10.1", @@ -1365,7 +1365,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -1384,7 +1384,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -1682,7 +1682,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "http 1.4.1", + "http 1.4.2", "http-body-util", "hyper 1.10.1", "hyper-named-pipe", @@ -1716,9 +1716,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ "bon-macros", "rustversion", @@ -1726,9 +1726,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ "darling 0.23.0", "ident_case", @@ -1786,13 +1786,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.1", + "brotli-decompressor 5.0.3", ] [[package]] @@ -1807,9 +1807,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -2056,9 +2056,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -2601,9 +2601,9 @@ dependencies = [ [[package]] name = "curl-sys" -version = "0.4.88+curl-8.20.0" +version = "0.4.89+curl-8.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644816de6547255eff4e491a1dda1c19b7237f00b62a61e6e64859ce4f2906d0" +checksum = "6d680779285438f2d0927485973ab45b212ea990bddb80de8a55a1e3c1d9ba22" dependencies = [ "cc", "libc", @@ -3275,7 +3275,7 @@ dependencies = [ "log", "recursive", "regex", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -3595,9 +3595,9 @@ dependencies = [ "deno_tls", "dyn-clone", "error_reporter", - "h2 0.4.14", + "h2 0.4.15", "hickory-resolver", - "http 1.4.1", + "http 1.4.2", "http-body-util", "hyper 1.10.1", "hyper-rustls 0.27.9", @@ -3930,7 +3930,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -4515,7 +4514,7 @@ checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" dependencies = [ "bit-set", "regex-automata", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -4526,7 +4525,7 @@ checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" dependencies = [ "bit-set", "regex-automata", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -5131,7 +5130,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -5175,7 +5174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de13e62d7e0ffc3eb40a0113ddf753cf6ec741be739164442b08893db4f9bfca" dependencies = [ "google-cloud-token", - "http 1.4.1", + "http 1.4.2", "thiserror 1.0.69", "tokio", "tokio-retry2", @@ -5287,16 +5286,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -5403,7 +5402,7 @@ dependencies = [ "base64 0.22.1", "bytes", "headers-core", - "http 1.4.1", + "http 1.4.2", "httpdate", "mime", "sha1", @@ -5415,7 +5414,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -5444,7 +5443,7 @@ checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ "dirs 6.0.0", "futures", - "http 1.4.1", + "http 1.4.2", "indicatif", "libc", "log", @@ -5578,9 +5577,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -5604,7 +5603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -5615,7 +5614,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] @@ -5641,7 +5640,7 @@ dependencies = [ "async-compression", "bstr", "futures", - "http 1.4.1", + "http 1.4.2", "http-body-util", "hyper 1.10.1", "hyper-rustls 0.26.0", @@ -5701,8 +5700,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.14", - "http 1.4.1", + "h2 0.4.15", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", @@ -5722,7 +5721,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-tls", @@ -5774,7 +5773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "hyper-util", "log", @@ -5792,7 +5791,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "hyper-util", "log", @@ -5858,7 +5857,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "hyper 1.10.1", "ipnet", @@ -6407,7 +6406,7 @@ dependencies = [ "either", "futures", "home", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.10.1", @@ -6441,7 +6440,7 @@ dependencies = [ "chrono", "derive_more 2.1.1", "form_urlencoded", - "http 1.4.1", + "http 1.4.2", "json-patch", "k8s-openapi", "schemars 0.8.22", @@ -6961,6 +6960,29 @@ dependencies = [ "malachite-nz", ] +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "mappable-rc" version = "0.1.1" @@ -7032,9 +7054,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" @@ -7187,7 +7209,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.1", + "http 1.4.2", "httparse", "memchr", "mime", @@ -7203,15 +7225,15 @@ checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" [[package]] name = "mysql-common-derive" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" +checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "heck", + "manyhow", "num-bigint", "proc-macro-crate", - "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", @@ -7695,7 +7717,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.1", + "http 1.4.2", "rand 0.8.5", "reqwest 0.12.28", "serde", @@ -7726,7 +7748,7 @@ dependencies = [ "chrono", "form_urlencoded", "futures", - "http 1.4.1", + "http 1.4.2", "http-body-util", "httparse", "humantime", @@ -7833,7 +7855,7 @@ dependencies = [ "dyn-clone", "ed25519-dalek", "hmac", - "http 1.4.1", + "http 1.4.2", "itertools 0.10.5", "log", "oauth2", @@ -7855,9 +7877,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.13.0", "cfg-if", @@ -7892,18 +7914,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -7960,7 +7982,7 @@ checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.27.1", ] @@ -7972,7 +7994,7 @@ checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.30.0", "reqwest 0.12.28", ] @@ -7985,7 +8007,7 @@ checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" dependencies = [ "async-trait", "futures-core", - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.27.1", "opentelemetry-http 0.27.0", "opentelemetry-proto 0.27.0", @@ -8004,7 +8026,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" dependencies = [ - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.30.0", "opentelemetry-http 0.30.0", "opentelemetry-proto 0.30.0", @@ -8255,7 +8277,7 @@ dependencies = [ "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli 8.0.3", + "brotli 8.0.4", "bytes", "chrono", "flate2", @@ -8734,28 +8756,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "proc-macro-rules" version = "0.4.0" @@ -8779,6 +8779,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -9363,14 +9374,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -9381,7 +9392,7 @@ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -9398,9 +9409,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -9432,8 +9443,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.14", - "http 1.4.1", + "h2 0.4.15", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.10.1", @@ -9480,8 +9491,8 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.14", - "http 1.4.1", + "h2 0.4.15", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.10.1", @@ -9522,7 +9533,7 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http 1.4.1", + "http 1.4.2", "reqwest 0.13.1", "serde", "thiserror 2.0.18", @@ -9539,7 +9550,7 @@ dependencies = [ "async-trait", "futures", "getrandom 0.2.17", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "reqwest 0.13.1", "reqwest-middleware", @@ -9645,7 +9656,7 @@ dependencies = [ "bytes", "chrono", "futures", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "oauth2", @@ -9816,9 +9827,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", @@ -10820,9 +10831,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -11935,7 +11946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ "byteorder", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", "utf8-ranges", ] @@ -12192,12 +12203,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -12207,15 +12217,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -12518,7 +12528,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "http 1.4.1", + "http 1.4.2", "httparse", "rand 0.8.5", "ring 0.17.14", @@ -12605,8 +12615,8 @@ dependencies = [ "base64 0.22.1", "bytes", "flate2", - "h2 0.4.14", - "http 1.4.1", + "h2 0.4.15", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.10.1", @@ -12637,8 +12647,8 @@ dependencies = [ "axum 0.8.9", "base64 0.22.1", "bytes", - "h2 0.4.14", - "http 1.4.1", + "h2 0.4.15", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.10.1", @@ -12706,7 +12716,7 @@ dependencies = [ "axum-core 0.5.6", "cookie", "futures-util", - "http 1.4.1", + "http 1.4.2", "parking_lot", "pin-project-lite", "tower-layer", @@ -12725,7 +12735,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -12875,7 +12885,7 @@ checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" dependencies = [ "cc", "regex", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", "tree-sitter-language", ] @@ -12960,7 +12970,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.2", "httparse", "log", "native-tls", @@ -12982,7 +12992,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.2", "httparse", "log", "native-tls", @@ -13344,9 +13354,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -13446,9 +13456,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -13663,7 +13673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "974fa1e325e6cc5327de8887f189a441fcff4f8eedcd31ec87f0ef0cc5283fbc" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.2", "thiserror 2.0.18", "url", ] @@ -13782,7 +13792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-nats", @@ -13864,7 +13874,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.719.0" +version = "1.726.1" dependencies = [ "async-stream", "async-trait", @@ -13877,7 +13887,7 @@ dependencies = [ "bytes", "eventsource-stream", "futures", - "http 1.4.1", + "http 1.4.2", "lazy_static", "mime_guess", "reqwest 0.13.1", @@ -13897,7 +13907,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -13910,7 +13920,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "argon2", @@ -13939,7 +13949,7 @@ dependencies = [ "git-version", "hex", "hmac", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "indexmap 2.14.0", "itertools 0.14.0", @@ -14048,11 +14058,11 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "lazy_static", "quick_cache", @@ -14071,7 +14081,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14084,12 +14094,12 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "axum 0.8.9", "chrono", - "http 1.4.1", + "http 1.4.2", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -14110,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.719.0" +version = "1.726.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14120,7 +14130,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14137,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14159,7 +14169,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14182,7 +14192,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14198,7 +14208,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14229,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14240,7 +14250,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14254,7 +14264,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-nats", @@ -14289,13 +14299,13 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "lazy_static", "serde", @@ -14314,7 +14324,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "flate2", @@ -14332,11 +14342,11 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "axum 0.8.9", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "itertools 0.14.0", "lazy_static", @@ -14354,7 +14364,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14374,12 +14384,12 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", "futures", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "itertools 0.14.0", "lazy_static", @@ -14410,7 +14420,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14448,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.719.0" +version = "1.726.1" dependencies = [ "lazy_static", "serde", @@ -14450,13 +14460,13 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.719.0" +version = "1.726.1" dependencies = [ "argon2", "axum 0.8.9", "chrono", "dashmap", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "lazy_static", "serde", @@ -14475,7 +14485,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,12 +14499,12 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.719.0" +version = "1.726.1" dependencies = [ "axum 0.8.9", "chrono", "hex", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "lazy_static", "magic-crypt", @@ -14522,7 +14532,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.719.0" +version = "1.726.1" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14546,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14565,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.719.0" +version = "1.726.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14584,6 +14594,7 @@ dependencies = [ "dashmap", "datafusion", "equivalent", + "erased-serde", "futures", "futures-core", "gethostname", @@ -14656,7 +14667,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.719.0" +version = "1.726.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14686,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.719.0" +version = "1.726.1" dependencies = [ "regex", "serde", @@ -14690,7 +14701,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14725,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "futures", @@ -14731,7 +14742,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.719.0" +version = "1.726.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,13 +14758,13 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", "chrono", "futures", - "http 1.4.1", + "http 1.4.2", "oauth2", "reqwest 0.12.28", "rmcp", @@ -14768,7 +14779,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -14777,7 +14788,7 @@ dependencies = [ "base64 0.22.1", "chrono", "hmac", - "http 1.4.1", + "http 1.4.2", "itertools 0.14.0", "lazy_static", "reqwest 0.13.1", @@ -14799,7 +14810,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14835,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14869,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "futures", @@ -14876,7 +14887,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.719.0" +version = "1.726.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14896,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14908,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14920,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14932,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14944,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14978,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +15001,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15023,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15035,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15049,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15079,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde", @@ -15080,7 +15091,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15109,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15125,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15141,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde", @@ -15141,7 +15152,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-recursion", @@ -15179,7 +15190,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "const_format", @@ -15217,7 +15228,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.719.0" +version = "1.726.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15228,7 +15239,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-recursion", @@ -15236,7 +15247,7 @@ dependencies = [ "chrono", "futures", "hex", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "lazy_static", "quick_cache", @@ -15260,7 +15271,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15284,13 +15295,13 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "itertools 0.14.0", "lazy_static", @@ -15317,7 +15328,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15327,7 +15338,7 @@ dependencies = [ "chrono", "constant_time_eq 0.3.1", "hex", - "http 1.4.1", + "http 1.4.2", "itertools 0.14.0", "lazy_static", "quick_cache", @@ -15350,7 +15361,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15370,7 +15381,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15380,7 +15391,7 @@ dependencies = [ "chrono", "google-cloud-googleapis", "google-cloud-pubsub", - "http 1.4.1", + "http 1.4.2", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -15404,7 +15415,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15415,7 +15426,7 @@ dependencies = [ "futures", "hex", "hmac", - "http 1.4.1", + "http 1.4.2", "hyper 1.10.1", "itertools 0.14.0", "lazy_static", @@ -15440,7 +15451,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15463,7 +15474,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15487,7 +15498,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-nats", @@ -15511,7 +15522,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15546,7 +15557,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", @@ -15574,14 +15585,14 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "base64 0.22.1", "futures", - "http 1.4.1", + "http 1.4.2", "itertools 0.14.0", "serde", "serde_json", @@ -15599,7 +15610,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15618,7 +15629,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-once-cell", @@ -15728,7 +15739,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.719.0" +version = "1.726.1" dependencies = [ "bytes", "futures", @@ -16542,18 +16553,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -16583,9 +16594,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index df637c3f75..44bdedcd11 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.719.0" +version = "1.726.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.719.0" +version = "1.726.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 84d5414fa2..fa76836c89 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -481ea7f28dc5af6b72390c82f494f34cb9809546 +97b5cb2096d3a9b4818943c5abf181d914cb4e99 diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.down.sql b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql new file mode 100644 index 0000000000..bbd036ad37 --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql @@ -0,0 +1,25 @@ +ALTER TABLE draft DROP COLUMN id; + +-- The .up.sql secret-draft scrub is irreversible — the blanked plaintext +-- values were deliberately destroyed and cannot be recovered here. + +DROP INDEX IF EXISTS draft_workspace_path_typ_idx; +DROP INDEX IF EXISTS draft_user_listing_idx; +DROP INDEX IF EXISTS draft_pkey_legacy; +DROP INDEX IF EXISTS draft_pkey_with_user; + +-- Per-user rows can't exist under the composite PK (one row per +-- (workspace_id, path, typ)); drop them before restoring it. +DELETE FROM draft WHERE email IS NOT NULL; + +ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ); + +ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey; +ALTER TABLE draft DROP COLUMN email; + +-- Restore the narrower DRAFT_TYPE enum; drop rows outside that set so the +-- cast doesn't fail. +CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app'); +DELETE FROM draft WHERE typ::text NOT IN ('script', 'flow', 'app'); +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::text::DRAFT_TYPE; +DROP TYPE DRAFT_KIND; diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.up.sql b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql new file mode 100644 index 0000000000..07343c6b2c --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql @@ -0,0 +1,82 @@ +-- Reshape `draft` for per-user bidirectional sync: add the owner `email` +-- (FK to password.email, NULL on legacy rows); replace the composite PK with +-- two partial unique indexes so per-user rows and the single legacy +-- workspace-level row coexist at the same (workspace_id, path, typ); widen +-- the DRAFT_TYPE enum to DRAFT_KIND (every UserDraftItemKind); and add a +-- synthetic BIGSERIAL `id` PK (tools like pg_dump/replication break on the +-- partial-index-only layout). + +CREATE TYPE DRAFT_KIND AS ENUM ( + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +); + +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_KIND USING typ::text::DRAFT_KIND; +DROP TYPE DRAFT_TYPE; + +ALTER TABLE draft ADD COLUMN email VARCHAR(255); + +ALTER TABLE draft + ADD CONSTRAINT draft_password_fkey + FOREIGN KEY (email) + REFERENCES password(email) + ON DELETE CASCADE + ON UPDATE CASCADE; + +ALTER TABLE draft DROP CONSTRAINT draft_pkey; + +CREATE UNIQUE INDEX draft_pkey_with_user + ON draft (workspace_id, path, typ, email) + WHERE email IS NOT NULL; + +CREATE UNIQUE INDEX draft_pkey_legacy + ON draft (workspace_id, path, typ) + WHERE email IS NULL; + +-- Serves the per-user draft listing (`WHERE workspace_id = ? AND email = ? +-- ORDER BY path`); neither partial unique index helps (both lead with +-- `path, typ`), and the trailing `path` keeps rows in output order. +CREATE INDEX draft_user_listing_idx + ON draft (workspace_id, email, path) + WHERE email IS NOT NULL; + +ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY; + +-- Hot path: `fetch_other_drafts_users` runs on every get-by-path request +-- with `WHERE workspace_id = ? AND path = ? AND typ = ?` and no email +-- predicate. The partial unique/listing indexes can't serve it (their +-- `email IS [NOT] NULL` predicates aren't implied by the query), so a plain +-- btree is needed. Also covers `get_draft_for_user` (same three columns + +-- `email IS NOT DISTINCT FROM ?` as a filter). +CREATE INDEX draft_workspace_path_typ_idx ON draft (workspace_id, path, typ); + +-- Secret variable values must never sit in `draft.value` in plaintext. +-- `save_draft` now encrypts them at write time; this scrubs any rows +-- persisted before that guard (irreversible — see the .down.sql note). +UPDATE draft +SET value = jsonb_set(value::jsonb, '{variable,value}', '""'::jsonb)::json +WHERE typ = 'variable' + AND (value::jsonb -> 'variable' ->> 'is_secret')::boolean IS TRUE + AND value::jsonb -> 'variable' ? 'value'; diff --git a/backend/migrations/20260609165313_remove_draft_only.down.sql b/backend/migrations/20260609165313_remove_draft_only.down.sql new file mode 100644 index 0000000000..9594c93957 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.down.sql @@ -0,0 +1,5 @@ +-- Restore the `draft_only` column. Irreversible data-wise: stubs deleted in +-- the up migration are gone and the column comes back NULL everywhere. +ALTER TABLE script ADD COLUMN draft_only BOOLEAN; +ALTER TABLE flow ADD COLUMN draft_only BOOLEAN; +ALTER TABLE app ADD COLUMN draft_only BOOLEAN; diff --git a/backend/migrations/20260609165313_remove_draft_only.up.sql b/backend/migrations/20260609165313_remove_draft_only.up.sql new file mode 100644 index 0000000000..9949de5540 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.up.sql @@ -0,0 +1,99 @@ +-- `draft_only` items (scripts/flows/apps saved as a draft but never +-- deployed) lived as a stub row in their own table plus a `draft` row; the +-- stub is now redundant. For each stub: ensure a draft row exists at its +-- path (ON CONFLICT DO NOTHING preserves the real per-user draft most stubs +-- already have, synthesising an `email = NULL` legacy stand-in only for the +-- rare stub that lost its draft), drop the stub (version FKs cascade), then +-- drop the `draft_only` column. +-- ON CONFLICT targets `draft_pkey_legacy` — (workspace_id, path, typ) WHERE +-- email IS NULL. + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + s.workspace_id, + s.path, + 'script'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', s.path, + 'summary', s.summary, + 'description', s.description, + 'content', s.content, + 'language', s.language, + 'kind', s.kind, + 'tag', s.tag, + 'schema', s.schema, + 'envs', to_jsonb(s.envs), + 'concurrent_limit', s.concurrent_limit, + 'concurrency_time_window_s', s.concurrency_time_window_s, + 'concurrency_key', s.concurrency_key, + 'cache_ttl', s.cache_ttl, + 'cache_ignore_s3_path', s.cache_ignore_s3_path, + 'dedicated_worker', s.dedicated_worker, + 'ws_error_handler_muted', s.ws_error_handler_muted, + 'priority', s.priority, + 'timeout', s.timeout, + 'delete_after_use', s.delete_after_use, + 'restart_unless_cancelled', s.restart_unless_cancelled, + 'visible_to_runner_only', s.visible_to_runner_only, + 'auto_kind', s.auto_kind, + 'has_preprocessor', s.has_preprocessor, + 'on_behalf_of_email', s.on_behalf_of_email, + 'assets', s.assets, + 'debounce_key', s.debounce_key, + 'debounce_delay_s', s.debounce_delay_s, + 'labels', to_jsonb(s.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM script s +WHERE s.draft_only IS TRUE AND s.deleted IS FALSE AND s.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + f.workspace_id, + f.path, + 'flow'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', f.path, + 'summary', f.summary, + 'description', f.description, + 'value', f.value, + 'schema', f.schema, + 'tag', f.tag, + 'dedicated_worker', f.dedicated_worker, + 'timeout', f.timeout, + 'visible_to_runner_only', f.visible_to_runner_only, + 'on_behalf_of_email', f.on_behalf_of_email, + 'ws_error_handler_muted', f.ws_error_handler_muted, + 'labels', to_jsonb(f.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM flow f +WHERE f.draft_only IS TRUE AND f.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +-- App drafts store the editor's working value directly in `draft.value` (an +-- `App` object for `app`, a `{files, runnables, data}` object for `raw_app`). +-- The deployed wrapper's summary/policy/custom_path aren't part of the App +-- type and aren't restored from a draft on reload, so they're dropped here. +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + a.workspace_id, + a.path, + CASE WHEN av.raw_app THEN 'raw_app'::DRAFT_KIND ELSE 'app'::DRAFT_KIND END, + NULL, + av.value +FROM app a +JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)] +WHERE a.draft_only IS TRUE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +DELETE FROM script WHERE draft_only IS TRUE; +DELETE FROM flow WHERE draft_only IS TRUE; +DELETE FROM app WHERE draft_only IS TRUE; + +ALTER TABLE script DROP COLUMN draft_only; +ALTER TABLE flow DROP COLUMN draft_only; +ALTER TABLE app DROP COLUMN draft_only; diff --git a/backend/migrations/20260610151334_folder_labels.down.sql b/backend/migrations/20260610151334_folder_labels.down.sql new file mode 100644 index 0000000000..1e9eeefca8 --- /dev/null +++ b/backend/migrations/20260610151334_folder_labels.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +DROP FUNCTION folder_labels(text, text); +ALTER TABLE folder DROP COLUMN labels; diff --git a/backend/migrations/20260610151334_folder_labels.up.sql b/backend/migrations/20260610151334_folder_labels.up.sql new file mode 100644 index 0000000000..88df630c76 --- /dev/null +++ b/backend/migrations/20260610151334_folder_labels.up.sql @@ -0,0 +1,13 @@ +-- Add up migration script here +ALTER TABLE folder ADD COLUMN labels text[]; + +-- Returns the labels of the folder containing the given item path ('f//...'). +-- SECURITY DEFINER so it bypasses folder RLS: inherited labels must be consistent +-- regardless of who reads the item or pushes the job (a user can have access to an +-- item without being in the folder's extra_perms, and label values are already +-- workspace-visible via the labels/list endpoint). +CREATE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[] +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT labels FROM folder + WHERE workspace_id = w_id AND item_path LIKE 'f/%' AND name = split_part(item_path, '/', 2) +$$; diff --git a/backend/migrations/20260614075900_dedup_folder_labels.down.sql b/backend/migrations/20260614075900_dedup_folder_labels.down.sql new file mode 100644 index 0000000000..51650bf747 --- /dev/null +++ b/backend/migrations/20260614075900_dedup_folder_labels.down.sql @@ -0,0 +1,6 @@ +-- Restore the original passthrough definition (the one-time data cleanup is not reverted). +CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[] +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT labels FROM folder + WHERE workspace_id = w_id AND item_path LIKE 'f/%' AND name = split_part(item_path, '/', 2) +$$; diff --git a/backend/migrations/20260614075900_dedup_folder_labels.up.sql b/backend/migrations/20260614075900_dedup_folder_labels.up.sql new file mode 100644 index 0000000000..fbf0be8177 --- /dev/null +++ b/backend/migrations/20260614075900_dedup_folder_labels.up.sql @@ -0,0 +1,33 @@ +-- Folder labels are exposed verbatim as `inherited_labels` (via folder_labels) and +-- rendered in keyed `{#each}` blocks in the UI, which throw `each_key_duplicate` on a +-- repeated key. The write paths now dedup, but make the read resilient regardless of +-- how a row was populated, and clean up any duplicates already persisted. + +-- Dedup while preserving first-seen order. +CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[] +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT ( + SELECT array_agg(l ORDER BY first_ord) + FROM ( + SELECT u.l, min(u.ord) AS first_ord + FROM unnest(f.labels) WITH ORDINALITY AS u(l, ord) + GROUP BY u.l + ) deduped + ) + FROM folder f + WHERE f.workspace_id = w_id AND item_path LIKE 'f/%' AND f.name = split_part(item_path, '/', 2) +$$; + +-- One-time cleanup of rows that already contain duplicate labels, so direct reads of +-- folder.labels (folder list, editor) are also safe. +UPDATE folder +SET labels = ( + SELECT array_agg(l ORDER BY first_ord) + FROM ( + SELECT u.l, min(u.ord) AS first_ord + FROM unnest(labels) WITH ORDINALITY AS u(l, ord) + GROUP BY u.l + ) deduped +) +WHERE labels IS NOT NULL + AND cardinality(labels) <> (SELECT count(DISTINCT x) FROM unnest(labels) AS x); diff --git a/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.down.sql b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.down.sql new file mode 100644 index 0000000000..3074d34959 --- /dev/null +++ b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.down.sql @@ -0,0 +1,4 @@ +-- Irreversible: the original `created_at` values were already lost by +-- 20260609165313 (it defaulted them to the migration's now()); this migration +-- only changed them from the migration timestamp to the epoch, so there is +-- nothing to restore. diff --git a/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.up.sql b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.up.sql new file mode 100644 index 0000000000..2459dae9cf --- /dev/null +++ b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.up.sql @@ -0,0 +1,33 @@ +-- Repair `created_at` for the draft-only items migrated by +-- 20260609165313_remove_draft_only. That migration inserted the legacy +-- (email IS NULL) draft stubs without an explicit `created_at`, so every row +-- it created defaulted to the migration's `now()` — which is +-- `transaction_timestamp()`, constant for the whole transaction. They all +-- landed at the migration instant and, sorted newest-first, flooded the top +-- of the home list. +-- +-- The original per-item timestamps are unrecoverable (the source script/flow/ +-- app rows were deleted by that migration and the draft value carries no +-- timestamp), so reset them to the epoch: these never-deployed, never-resaved +-- stubs sort to the bottom instead of the top. Editing one later bumps its +-- `created_at` to now() and it floats back up naturally. +-- +-- Identification is exact and safe. sqlx applies a transactional migration and +-- inserts its `_sqlx_migrations` bookkeeping row in ONE transaction, both via +-- `DEFAULT now()`, so that row's `installed_on` is byte-identical to the +-- `created_at` of every row the migration inserted. Matching on it touches +-- only those rows and skips any edited since (their `created_at` was bumped, +-- so it no longer equals `installed_on`). If the values somehow don't match, +-- this updates nothing — it can never clobber a real draft. +-- +-- Both columns are TIMESTAMPTZ (draft.created_at since +-- 20260514233244_convert_draft_created_at_to_timestamptz), so this is an exact +-- instant comparison — no implicit timestamp/timestamptz cast or timezone +-- sensitivity. +UPDATE draft d +SET created_at = 'epoch' +FROM _sqlx_migrations m +WHERE m.version = 20260609165313 + AND d.email IS NULL + AND d.typ IN ('script', 'flow', 'app', 'raw_app') + AND d.created_at = m.installed_on; diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 01becd80f3..87cddda16b 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -210,5 +210,19 @@ "instance_url": "https://{instance}.service-now.com" } } + }, + "outreach": { + "auth_url": "https://api.outreach.io/oauth/authorize", + "token_url": "https://api.outreach.io/oauth/token", + "scopes": [ + "accounts.all", + "prospects.all", + "sequences.all", + "sequenceStates.all", + "tasks.all", + "mailings.read", + "mailboxes.read" + ], + "req_body_auth": true } } diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 36abc87a45..5d7059d203 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.719.0" +version = "1.726.1" dependencies = [ "aho-corasick", "anyhow", @@ -6271,7 +6271,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.719.0" +version = "1.726.1" dependencies = [ "proc-macro2", "quote", @@ -6283,7 +6283,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.719.0" +version = "1.726.1" dependencies = [ "convert_case", "serde", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -6304,7 +6304,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde_json", @@ -6316,7 +6316,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "gosyn", @@ -6328,7 +6328,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde_json", @@ -6352,7 +6352,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "nu-parser", @@ -6363,7 +6363,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6374,7 +6374,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6386,7 +6386,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6397,7 +6397,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "async-recursion", @@ -6419,7 +6419,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde_json", @@ -6431,7 +6431,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -6445,7 +6445,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "convert_case", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "lazy_static", @@ -6505,7 +6505,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6521,7 +6521,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6537,7 +6537,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6569,7 +6569,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "serde", @@ -6580,7 +6580,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.719.0" +version = "1.726.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index d87cd6ee7d..5425aa9b35 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.719.0" +version = "1.726.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index b4d3cef4f9..a97320d5d9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1499,6 +1499,11 @@ Windmill Community Edition {GIT_VERSION} ee_oss::verify_license_key(conn.as_sql()).await; } + // self-throttled; picks up a key fixed on the server without + // waiting for the 12h reload + #[cfg(feature = "enterprise")] + crate::monitor::refetch_license_key_if_invalid(conn).await; + // update min version explicitly. // for sql connection it is the part of monitor_db. // TODO: pass worker names for min keep-alive alerts (for HTTP connection) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index b1706d9ce4..3608efb097 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -2149,7 +2149,7 @@ pub async fn reload_request_size(conn: &Connection) { } } -pub async fn reload_license_key(conn: &Connection) -> anyhow::Result<()> { +async fn resolve_license_key_value(conn: &Connection, quiet: bool) -> anyhow::Result { let q = load_value_from_global_settings_with_conn(conn, LICENSE_KEY_SETTING, true) .await .map_err(|err| anyhow::anyhow!("Error reloading license key: {}", err.to_string()))?; @@ -2161,19 +2161,90 @@ pub async fn reload_license_key(conn: &Connection) -> anyhow::Result<()> { if let Some(q) = q { if let Ok(v) = serde_json::from_value::(q.clone()) { - tracing::info!( - "Loaded setting LICENSE_KEY from db config: {}", - truncate_token(&v) - ); + if !quiet { + tracing::info!( + "Loaded setting LICENSE_KEY from db config: {}", + truncate_token(&v) + ); + } value = v; } else { tracing::error!("Could not parse LICENSE_KEY found: {:#?}", &q); } }; - set_license_key(value, conn.as_sql()).await; + Ok(value) +} + +pub async fn reload_license_key(conn: &Connection) -> anyhow::Result<()> { + let value = resolve_license_key_value(conn, false).await?; + apply_license_key(value, conn).await; Ok(()) } +/// Applies the key and records it as the last accepted value only when +/// `set_license_key` actually stored it (validation passed, even if expired). +/// A rejected key is deliberately not recorded: validation can fail transiently +/// (DB error during the instance-hash check, offline key validated before +/// base_url is configured), and recording it would stop +/// `refetch_license_key_if_invalid` from ever retrying an unchanged key. +async fn apply_license_key(value: String, conn: &Connection) { + set_license_key(value.clone(), conn.as_sql()).await; + #[cfg(feature = "enterprise")] + if (**windmill_common::ee_oss::LICENSE_KEY.load()).as_str() == value { + *LAST_ACCEPTED_LICENSE_KEY.lock().unwrap() = Some(value); + } +} + +#[cfg(feature = "enterprise")] +lazy_static::lazy_static! { + static ref LAST_INVALID_KEY_REFETCH_AT: std::sync::Mutex> = + std::sync::Mutex::new(None); + static ref LAST_ACCEPTED_LICENSE_KEY: std::sync::Mutex> = + std::sync::Mutex::new(None); +} + +#[cfg(feature = "enterprise")] +const INVALID_LICENSE_KEY_REFETCH_INTERVAL: Duration = Duration::from_secs(60); + +/// When the in-memory license key is invalid, the key in settings may have moved on +/// (failed initial load, missed `license_key` notification, or renewed/fixed by +/// another instance) — without this, the stale in-memory key would keep being +/// flagged invalid until the next full settings reload (12h by default). Refetch +/// the latest key at most once per `INVALID_LICENSE_KEY_REFETCH_INTERVAL` and +/// re-apply it unless it matches the last accepted value (an unchanged key that +/// validated fine and is invalid for another reason — e.g. expired — is not +/// re-validated in a loop). +#[cfg(feature = "enterprise")] +pub async fn refetch_license_key_if_invalid(conn: &Connection) { + use windmill_common::ee_oss::LICENSE_KEY_VALID; + + if LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + { + let mut last = LAST_INVALID_KEY_REFETCH_AT.lock().unwrap(); + if last.is_some_and(|t| t.elapsed() < INVALID_LICENSE_KEY_REFETCH_INTERVAL) { + return; + } + *last = Some(Instant::now()); + } + let value = match resolve_license_key_value(conn, true).await { + Ok(v) => v, + Err(err) => { + tracing::error!("Failed to refetch license key while invalid: {err:#}"); + return; + } + }; + let changed = LAST_ACCEPTED_LICENSE_KEY.lock().unwrap().as_deref() != Some(value.as_str()); + if changed { + tracing::info!( + "In-memory license key is invalid, applying license key from settings: {}", + truncate_token(&value) + ); + apply_license_key(value, conn).await; + } +} + pub async fn reload_option_setting_with_tracing( conn: &Connection, setting_name: &str, @@ -2627,6 +2698,7 @@ pub async fn monitor_db( #[cfg(feature = "enterprise")] if !initial_load { verify_license_key(conn.as_sql()).await; + refetch_license_key_if_invalid(conn).await; } }; diff --git a/backend/tests/app_anonymous_execution_mode.rs b/backend/tests/app_anonymous_execution_mode.rs new file mode 100644 index 0000000000..fd84a0eb42 --- /dev/null +++ b/backend/tests/app_anonymous_execution_mode.rs @@ -0,0 +1,279 @@ +//! Test for the `RestrictAnonymousAppDeployment` workspace protection rule. +//! +//! By default (no rule), any user with write access can deploy an app with +//! `execution_mode: anonymous` (no login required) — the historical +//! behavior. When a workspace protection ruleset enables +//! `RestrictAnonymousAppDeployment`, only workspace admins and the +//! ruleset's bypass users/groups can create an anonymous app or flip an +//! existing app to anonymous. To avoid breaking existing workflows, +//! restricted users can still redeploy an app that is already anonymous +//! (no exposure change) and can downgrade it back to `publisher` +//! (exposure reduction). +//! +//! Users from the `base` fixture: +//! test-user (admin, token SECRET_TOKEN) +//! test-user-2 (non-admin, token SECRET_TOKEN_2) + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_protection_rules_cache; +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; +const USER_TOKEN: &str = "SECRET_TOKEN_2"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +fn app_payload(path: &str, execution_mode: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "Test app", + "value": {}, + "policy": { "execution_mode": execution_mode, "triggerables": {} } + }) +} + +fn policy_update(execution_mode: &str) -> serde_json::Value { + json!({ + "policy": { "execution_mode": execution_mode, "triggerables": {} } + }) +} + +/// Single test to avoid interference through the process-global protection +/// rules cache (keyed by workspace id, shared across parallel tests). +#[sqlx::test(fixtures("base"))] +async fn test_restrict_anonymous_app_deployment_rule(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_protection_rules_cache("test-workspace"); + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + // ======================================== + // 1. Default behavior (no rule): a non-admin can create an anonymous + // app and flip an app to anonymous, as before. + // ======================================== + + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/anon_default", "anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "without the rule, non-admin creating an anonymous app must succeed: {}", + resp.text().await? + ); + + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/test_app", "publisher")) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "without the rule, non-admin flipping an app to anonymous must succeed: {}", + resp.text().await? + ); + + // back to publisher for the gated scenarios below + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("publisher")) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + // ======================================== + // 2. Admin enables the RestrictAnonymousAppDeployment rule. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/workspaces/protection_rules")), + ADMIN_TOKEN, + ) + .json(&json!({ + "name": "no-public-apps", + "rules": ["RestrictAnonymousAppDeployment"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "admin should create the protection rule: {}", + resp.text().await? + ); + + // ======================================== + // 3. With the rule, a non-admin cannot create an anonymous app... + // ======================================== + + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/anon_blocked", "anonymous")) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 403, + "with the rule, non-admin creating an anonymous app must be rejected: {body}" + ); + assert!( + body.contains("no-public-apps"), + "error should name the blocking ruleset, got: {body}" + ); + + // ... nor flip an existing app to anonymous ... + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "with the rule, non-admin flipping an app to anonymous must be rejected" + ); + + // ... while a publisher create still works. + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/pub_ok", "publisher")) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + // ======================================== + // 4. An admin is never blocked by the rule. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + ADMIN_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "admin flipping an app to anonymous must succeed: {}", + resp.text().await? + ); + + // ======================================== + // 5. A restricted user can still redeploy an app that is already + // anonymous — keeping it anonymous does not change its exposure, and + // blocking it would prevent non-admin editors from deploying public + // apps at all (the frontend always sends the full policy on deploy). + // ======================================== + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&json!({ + "value": { "edited": true }, + "policy": { "execution_mode": "anonymous", "triggerables": {} } + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "restricted user redeploying an already-anonymous app must succeed: {}", + resp.text().await? + ); + + // ======================================== + // 6. A restricted user can downgrade the app back to publisher + // (reduces exposure), but cannot re-flip it to anonymous. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("publisher")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "restricted user downgrading anonymous to publisher must succeed: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "restricted user re-flipping to anonymous must be rejected" + ); + + // ======================================== + // 7. Bypass users are exempt from the rule. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/workspaces/protection_rules/no-public-apps")), + ADMIN_TOKEN, + ) + .json(&json!({ + "rules": ["RestrictAnonymousAppDeployment"], + "bypass_users": ["test-user-2"], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "admin should update the protection rule: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "bypass user flipping an app to anonymous must succeed: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/tests/app_preview_auth.rs b/backend/tests/app_preview_auth.rs index 4653303392..2a69004a7b 100644 --- a/backend/tests/app_preview_auth.rs +++ b/backend/tests/app_preview_auth.rs @@ -17,7 +17,12 @@ //! must not over-block the legitimate editor flow), //! - preview is confined to paths the caller can read (defense-in-depth //! against scoped tokens / cross-namespace preview), and -//! - run mode (no `force_viewer_static_fields`) is unaffected by the guard. +//! - run mode (no `force_viewer_static_fields`) is unaffected by the preview +//! guard, and +//! - run mode against a deployed Viewer app rejects caller-supplied inline +//! `raw_code` whose sha is not publisher-pinned (CVE-2026-22683 residual: +//! the Viewer default-triggerable fallback let any caller / an operator run +//! arbitrary code as themselves, bypassing the content-hash pin). use serde_json::json; use sqlx::{Pool, Postgres}; @@ -254,5 +259,90 @@ async fn test_app_preview_authorization(db: Pool) -> anyhow::Result<() "rejection must be the jobs:run scope gate, got: {body}" ); + // 9. RUN-MODE REGRESSION (CVE-2026-22683 residual): in run mode (no + // `force_viewer_static_fields`) against a deployed Viewer-mode app, + // caller-supplied inline `raw_code` whose `rawscript/` is not pinned + // in the app's `triggerables_v2` must be rejected by the policy — it must + // not resolve via the Viewer default triggerable and run as the caller + // (the same preview-class execution an operator is denied in step 1). + let run_mode_raw_code = json!({ + "args": {}, + "component": "comp", + "raw_code": { + "language": "bash", + "content": "id; echo RCE_$(whoami)", + "path": "x" + } + }); + let resp = authed( + client().post(format!("{base}/u/test-user/vapp")), + "OPERATOR_TOKEN", + ) + .json(&run_mode_raw_code) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "run-mode inline raw_code against a Viewer app must be rejected, not run as the caller (got {status}): {body}" + ); + assert!( + body.contains("forbidden by policy"), + "rejection must be the content-hash pin (unpinned rawscript), got: {body}" + ); + + // 10. The content-pin fix is not operator-specific: even a regular + // non-operator member (who could run their own code via + // `/jobs/run/preview`) must not be able to substitute unpinned code into + // someone else's deployed Viewer app — the deployed-app integrity break. + let resp = authed( + client().post(format!("{base}/u/test-user/vapp")), + "SECRET_TOKEN_2", + ) + .json(&run_mode_raw_code) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "run-mode unpinned raw_code must be rejected for any caller, not just operators (got {status}): {body}" + ); + + // 11. The pin requirement also covers `raw_code` carrying an `app_script` + // `id`. The id resolves to `rawscript/` of any app_script row + // by number (no app scoping), so without the pin a caller could run a + // script belonging to another app against this Viewer app. Here `999777` + // belongs to `u/test-user/private`, not to the targeted `vapp`, and its + // sha is absent from `vapp`'s empty `triggerables_v2` — it must be + // rejected, not fall back to the Viewer default. + let resp = authed( + client().post(format!("{base}/u/test-user/vapp")), + "OPERATOR_TOKEN", + ) + .json(&json!({ + "args": {}, + "component": "comp", + "id": 999777, + "raw_code": { + "language": "deno", + "content": "export function main() { return 1; }", + "path": "x" + } + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "run-mode raw_code with an unpinned app_script id must be rejected against a Viewer app (got {status}): {body}" + ); + assert!( + body.contains("forbidden by policy"), + "rejection must be the content-hash pin (unpinned app_script sha), got: {body}" + ); + Ok(()) } diff --git a/backend/tests/ci_tests.rs b/backend/tests/ci_tests.rs index c8551a30a5..46bed97094 100644 --- a/backend/tests/ci_tests.rs +++ b/backend/tests/ci_tests.rs @@ -10,6 +10,7 @@ mod ci_tests { fn quick_ns(content: &str, path: &str, parent_hash: Option) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Python3, lock: None, @@ -20,7 +21,6 @@ mod ci_tests { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 48a50ef9b9..0d58615be0 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -16,6 +16,7 @@ mod dependency_map { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -26,7 +27,6 @@ mod dependency_map { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -441,7 +441,6 @@ def main(): .unwrap(), ), schema: None, - draft_only: None, tag: None, dedicated_worker: None, timeout: None, diff --git a/backend/tests/fixtures/app_preview_auth.sql b/backend/tests/fixtures/app_preview_auth.sql index 9fcda61c51..b94e08f3fd 100644 --- a/backend/tests/fixtures/app_preview_auth.sql +++ b/backend/tests/fixtures/app_preview_auth.sql @@ -32,3 +32,11 @@ INSERT INTO app (id, workspace_id, path, summary, policy, versions) VALUES (999002, 'test-workspace', 'u/test-user-2/ownapp', 'own app', '{}'::jsonb, '{}'); INSERT INTO app_script (id, app, hash, code, code_sha256) VALUES (999778, 999002, repeat('c', 64), 'export function main(){ return "ok" }', repeat('d', 64)); + +-- A deployed empty Viewer-mode app owned by `test-user` with NO runnables pinned +-- in `triggerables_v2`. Used to assert run mode rejects caller-supplied inline +-- `raw_code` whose sha is not publisher-pinned (the CVE-2026-22683 residual: +-- the Viewer default fallback let any caller / an operator run arbitrary code). +INSERT INTO app (id, workspace_id, path, summary, policy, versions) VALUES + (999003, 'test-workspace', 'u/test-user/vapp', 'empty viewer app', + '{"execution_mode": "viewer", "triggerables_v2": {}}'::jsonb, '{}'); diff --git a/backend/tests/fixtures/inline_preview_auth.sql b/backend/tests/fixtures/inline_preview_auth.sql new file mode 100644 index 0000000000..59fe2dc917 --- /dev/null +++ b/backend/tests/fixtures/inline_preview_auth.sql @@ -0,0 +1,14 @@ +-- Fixture for the inline preview authorization regression test (GHSA-pp5h-96x3-3wqq). +-- Layered on top of `base` (which provides test-workspace and the non-operator +-- `test-user-2`/SECRET_TOKEN_2). Adds an Operator member so we can assert that +-- Operators cannot reach the arbitrary-code inline preview path +-- (`POST /jobs/run_inline/preview`). + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('operator@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Operator User'); + +INSERT INTO usr(workspace_id, email, username, is_admin, operator, role) VALUES + ('test-workspace', 'operator@windmill.dev', 'operator-user', false, true, 'Operator'); + +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES + (encode(sha256('OPERATOR_TOKEN'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN', 'operator@windmill.dev', 'operator token', false); diff --git a/backend/tests/fixtures/mcp_resource_authz.sql b/backend/tests/fixtures/mcp_resource_authz.sql new file mode 100644 index 0000000000..ecf3f01dc3 --- /dev/null +++ b/backend/tests/fixtures/mcp_resource_authz.sql @@ -0,0 +1,25 @@ +-- Fixture for the MCP resource-authorization regression test (WIN-2041, +-- GHSA-7qg3-pr4g-cq5x). +-- +-- Models a low-privileged developer (test-user-3, a plain workspace member) who +-- references, from an AI Agent flow, an MCP resource living in a private folder +-- they have NO access to. The AI agent worker must refuse to load that resource +-- because the developer's identity lacks resources:read on it. + +-- Private folder the developer cannot read (empty extra_perms, owned by admin). +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'private', 'Private Folder', '{"u/test-user"}', '{}', 'test-user'); + +-- MCP resource the developer is NOT allowed to read. The URL is a non-resolvable +-- public host so that, for an authorized caller, resolution succeeds but the +-- later connection step fails deterministically without network access. +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ( + 'test-workspace', + 'f/private/admin_mcp', + '{"name": "admin_mcp", "url": "https://mcp.invalid.windmill.test"}', + 'Private MCP resource only admin can read', + 'mcp', + '{}', + 'test-user' +); diff --git a/backend/tests/inline_preview_auth.rs b/backend/tests/inline_preview_auth.rs new file mode 100644 index 0000000000..97b70fb545 --- /dev/null +++ b/backend/tests/inline_preview_auth.rs @@ -0,0 +1,90 @@ +//! Regression test for the inline preview authorization bypass (GHSA-pp5h-96x3-3wqq). +//! +//! `POST /api/w/:workspace/jobs/run_inline/preview` -> `run_inline_preview_script` +//! runs request-supplied code inline (in-process via DuckDB), i.e. it is an +//! arbitrary-code-execution sibling of `/jobs/run/preview`. The bug was that +//! this handler was missing the Operator guard that `run_preview_script` +//! enforces, so an authenticated Operator (a run-only user who must not be able +//! to run preview jobs) could execute arbitrary code in a single request. This +//! was the incomplete-fix residual of CVE-2026-22683, whose v1.615.0 patch only +//! covered the entity-CRUD endpoints and left this direct inline-exec sink open. +//! +//! This test pins down: +//! - an Operator is rejected by the operator guard (the core fix; pre-fix this +//! reached the inline executor instead of returning 401), and +//! - a regular non-operator passes the guard (the fix must not over-block the +//! legitimate inline preview flow): in the test harness the worker inline +//! utils are not registered, so a caller past the guard gets the distinct +//! "worker inline functions" error rather than the operator rejection. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +/// An inline preview request: request-supplied `content` to run via DuckDB. +fn inline_preview_body() -> serde_json::Value { + json!({ + "language": "duckdb", + "content": "SELECT content FROM read_text(['/etc/passwd']);", + "args": {} + }) +} + +const OPERATOR_GUARD_MSG: &str = "Operators cannot run preview jobs"; + +#[sqlx::test(fixtures("base", "inline_preview_auth"))] +async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = format!("http://localhost:{port}/api/w/test-workspace/jobs/run_inline/preview"); + + // 1. CORE REGRESSION: an Operator must be rejected by the operator guard. + // Pre-fix this fell through to the inline executor (arbitrary code + // execution); post-fix it returns 401 with the operator guard message. + let resp = authed(client().post(&url), "OPERATOR_TOKEN") + .json(&inline_preview_body()) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 401, + "Operator must be rejected from inline preview (got {status}): {body}" + ); + assert!( + body.contains(OPERATOR_GUARD_MSG), + "rejection must be the operator guard, got: {body}" + ); + + // 2. The fix must NOT over-block a legitimate non-operator: a regular member + // passes the operator + scope checks. The test harness does not register + // the worker inline utils, so the request proceeds past the guard and + // fails later with the distinct "worker inline functions" error — proving + // the operator guard did not reject it. + let resp = authed(client().post(&url), "SECRET_TOKEN_2") + .json(&inline_preview_body()) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_ne!( + status, 401, + "non-operator must not be blocked by the operator guard (got {status}): {body}" + ); + assert!( + !body.contains(OPERATOR_GUARD_MSG), + "non-operator must not hit the operator guard, got: {body}" + ); + + Ok(()) +} diff --git a/backend/tests/mcp_resource_authz.rs b/backend/tests/mcp_resource_authz.rs new file mode 100644 index 0000000000..0fa35933f4 --- /dev/null +++ b/backend/tests/mcp_resource_authz.rs @@ -0,0 +1,102 @@ +//! Regression test for the MCP resource-authorization bypass +//! (WIN-2041, GHSA-7qg3-pr4g-cq5x). +//! +//! Invariant: the AI Agent worker (`load_mcp_tools`) must load an MCP resource +//! only when the job identity is allowed to read it — the same +//! `resources:read:{path}` + RLS gate the regular MCP tools API (`get_mcp_tools`) +//! enforces. It loads the resource through the job's permissioned client +//! (`AuthedClient::get_resource_value`, the `resources/get_value` path), not the +//! raw DB pool. Otherwise a low-privileged user who can edit a flow could make +//! the agent load and use an MCP resource (URL + inline headers + token) they are +//! not allowed to read: a confused-deputy bypass. +//! +//! This test pins, against the `mcp_resource_authz` fixture (an MCP resource in +//! a folder only the admin can read): +//! - a plain developer (test-user-3) is DENIED loading the private resource, +//! before any MCP connection is attempted, and the resource never leaks; +//! - an admin (test-user) clears the gate, the resource resolves, and loading +//! only fails later at the connect step — proving no over-blocking. +#![cfg(feature = "mcp")] + +use sqlx::{Pool, Postgres}; +use windmill_common::client::AuthedClient; +use windmill_test_utils::*; +use windmill_worker::{load_mcp_tools, McpResourceConfig}; + +fn config() -> Vec { + vec![McpResourceConfig { + resource_path: "$res:f/private/admin_mcp".to_string(), + include_tools: None, + exclude_tools: None, + }] +} + +// The Ok variant ((HashMap<_, Arc>, Vec)) does not implement +// Debug, so `expect_err` is unavailable — extract the error explicitly. +async fn load_err(db: &Pool, client: &AuthedClient, ctx: &str) -> String { + match load_mcp_tools(db, "test-workspace", config(), client).await { + Ok(_) => panic!("{ctx}"), + Err(e) => e.to_string(), + } +} + +#[sqlx::test(fixtures("base", "mcp_resource_authz"))] +async fn test_mcp_resource_not_loaded_without_authorization( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_internal_url = format!("http://localhost:{}", server.addr.port()); + + // ---- CORE REGRESSION: the developer cannot read the private MCP resource, + // so the worker must refuse to load it — before any MCP connection is + // attempted, and without the resource ever leaking. + let dev_client = AuthedClient::new( + base_internal_url.clone(), + "test-workspace".to_string(), + "SECRET_TOKEN_3".to_string(), + None, + ); + let msg = load_err( + &db, + &dev_client, + "developer must be denied loading a resource they can't read", + ) + .await; + assert!( + msg.contains("don't have access"), + "denial should come from the resource-RLS gate, not a connection error: {msg}" + ); + // An unauthorized caller must be blocked before MCP client creation, so no + // resource material (URL, headers, token) is ever loaded for them. + assert!( + !msg.contains("Failed to create MCP client"), + "developer must be blocked before the connection step (would mean the resource was loaded): {msg}" + ); + + // ---- NO OVER-BLOCKING: an admin clears the resource-RLS gate, so the + // resource resolves and loading only fails later at the connect step. + let admin_client = AuthedClient::new( + base_internal_url, + "test-workspace".to_string(), + "SECRET_TOKEN".to_string(), + None, + ); + let msg = load_err( + &db, + &admin_client, + "the fixture URL is non-resolvable, so the connect step must fail", + ) + .await; + assert!( + !msg.contains("don't have access"), + "admin must clear the resource-RLS gate, not be denied: {msg}" + ); + assert!( + msg.contains("Failed to create MCP client"), + "admin should resolve the resource and only fail at the connect step: {msg}" + ); + + Ok(()) +} diff --git a/backend/tests/relock_skip.rs b/backend/tests/relock_skip.rs index eb8dfb261d..8262a38cf4 100644 --- a/backend/tests/relock_skip.rs +++ b/backend/tests/relock_skip.rs @@ -14,6 +14,7 @@ mod relock_skip { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -24,7 +25,6 @@ mod relock_skip { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/retry.rs b/backend/tests/retry.rs index abd7c38d8d..e804b96f0e 100644 --- a/backend/tests/retry.rs +++ b/backend/tests/retry.rs @@ -262,6 +262,65 @@ def main(last, port): Ok(()) } + /* `skip_if` evaluation goes through windmill-jseval and requires `quickjs` */ + #[cfg(all(feature = "deno_core", feature = "quickjs"))] + #[sqlx::test(fixtures("base"))] + async fn test_skip_if_sees_previous_step_result_on_retry( + db: Pool, + ) -> anyhow::Result<()> { + initialize_tracing().await; + + /* step `b` fails on its first attempt and succeeds on retry. Its `skip_if` + * references the previous step's result: when re-evaluated for the retry, + * `previous_result` must still be step `a`'s result, not the error of step + * `b`'s failed attempt (which would wrongly flip `skip_if` to true and skip + * the retry) */ + let value = serde_json::from_value(json!({ + "modules": [{ + "id": "a", + "value": { + "input_transforms": {}, + "type": "rawscript", + "language": "deno", + "content": "export function main() { return \"ok\" }", + }, + }, { + "id": "b", + "skip_if": { "expr": "results.a?.error !== undefined" }, + "value": { + "input_transforms": { + "index": { "type": "static", "value": 1 }, + "port": { "type": "javascript", "expr": "flow_input.port" }, + }, + "type": "rawscript", + "language": "deno", + "content": inner_step(), + }, + "retry": { "constant": { "attempts": 1, "seconds": 0 } }, + }], + })) + .unwrap(); + + let (attempts, responses) = [ + /* fail step `b` once, then pass on retry */ + (1, None), + (1, Some(42)), + ] + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(); + let server = Server::start(responses).await; + let result = RunJob::from(JobPayload::RawFlow { value, path: None, restarted_from: None }) + .arg("port", json!(server.addr.port())) + .run_until_complete(&db, false, server.addr.port()) + .await + .json_result() + .unwrap(); + + assert_eq!(server.close().await, attempts); + assert_eq!(json!(42), result); + Ok(()) + } + #[cfg(feature = "python")] #[sqlx::test(fixtures("base"))] async fn test_with_failure_module(db: Pool) -> anyhow::Result<()> { diff --git a/backend/tests/script_auto_kind_failure.rs b/backend/tests/script_auto_kind_failure.rs index 4110bd4ba5..05d22e75ec 100644 --- a/backend/tests/script_auto_kind_failure.rs +++ b/backend/tests/script_auto_kind_failure.rs @@ -6,6 +6,7 @@ use windmill_test_utils::init_client; fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Bun, lock: None, @@ -16,7 +17,6 @@ fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: kind.map(|s| s.to_string()), diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index d1c20da407..c3f6357d4c 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -200,7 +200,7 @@ async fn test_handler_queries_websocket(db: Pool) -> anyhow::Result<() assert_eq!(trigger.base.permissioned_as, "u/test-user"); let triggers = handler - .list_triggers(&mut *conn, "test-workspace", None) + .list_triggers(&mut *conn, "test-workspace", None, None) .await?; assert!(triggers.iter().any(|t| t.base.path == "f/test/handler_ws")); diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 5d90775b77..9445e6cd6a 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4178,8 +4178,8 @@ async fn test_flow_lock_all(db: Pool) -> anyhow::Result<()> { visible_to_runner_only: None, on_behalf_of_email: None, }, - draft_only: None, deployment_message: None, + draft_only: None, }, ) .await diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs index 823ff22a43..ca988562fd 100644 --- a/backend/tests/workspace_export.rs +++ b/backend/tests/workspace_export.rs @@ -40,6 +40,7 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() .create_script( "test-workspace", &windmill_api_client::types::NewScript { + draft_only: None, content: "export function main() { return 42; }".to_string(), language: windmill_api_client::types::ScriptLang::Bun, path: "f/test_folder/test_script".to_string(), @@ -51,7 +52,6 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() parent_hash: None, schema: Default::default(), is_template: None, - draft_only: None, dedicated_worker: None, ws_error_handler_muted: None, priority: None, diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index e549e63041..8aa9addcae 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -560,6 +560,26 @@ fn convert_message(msg: &OpenAIMessage) -> Result { let mut content_blocks = Vec::new(); + // Replay the Claude reasoning block first: when thinking is enabled, + // Anthropic requires the reasoning block (with its unmodified signature) to + // precede toolUse in the assistant turn it was emitted in. The proxy + // round-trips it on the tool call's extra_content (see BedrockExtraContent). + if role == ConversationRole::Assistant { + if let Some(reasoning) = msg + .tool_calls + .as_ref() + .and_then(|tcs| { + tcs.iter() + .find_map(|tc| tc.extra_content.as_ref().and_then(|ec| ec.bedrock.as_ref())) + }) + .map(bedrock_reasoning_block_from_extra) + .transpose()? + .flatten() + { + content_blocks.push(reasoning); + } + } + // Handle content (text and/or images) if let Some(content) = &msg.content { match content { @@ -597,6 +617,37 @@ fn convert_message(msg: &OpenAIMessage) -> Result { .map_err(|e| Error::internal_err(format!("Failed to build message: {}", e))) } +/// Rebuild a Bedrock reasoning content block from the round-tripped +/// [`BedrockExtraContent`](crate::ai_types::BedrockExtraContent). +fn bedrock_reasoning_block_from_extra( + extra: &crate::ai_types::BedrockExtraContent, +) -> Result, Error> { + if let Some(redacted) = extra.redacted_content.as_deref() { + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, redacted) + .map_err(|e| { + Error::internal_err(format!("Failed to decode redacted reasoning: {}", e)) + })?; + return Ok(Some(ContentBlock::ReasoningContent( + aws_sdk_bedrockruntime::types::ReasoningContentBlock::RedactedContent(bytes.into()), + ))); + } + + let Some(text) = extra.reasoning_text.as_deref() else { + return Ok(None); + }; + let mut builder = aws_sdk_bedrockruntime::types::ReasoningTextBlock::builder().text(text); + if let Some(signature) = extra.signature.as_deref() { + builder = builder.signature(signature); + } + Ok(Some(ContentBlock::ReasoningContent( + aws_sdk_bedrockruntime::types::ReasoningContentBlock::ReasoningText( + builder.build().map_err(|e| { + Error::internal_err(format!("Failed to build reasoning block: {}", e)) + })?, + ), + ))) +} + /// Convert OpenAI tool call to Bedrock ToolUse content block fn convert_tool_call_to_content(tool_call: &OpenAIToolCall) -> Result { let input = json_to_document( @@ -814,7 +865,10 @@ pub fn streaming_tool_calls_to_openai(tool_calls: Vec) -> Vec id: tc.id, function: OpenAIFunction { name: tc.name, arguments: tc.arguments }, r#type: FUNCTION_TYPE.to_string(), - extra_content: None, // Bedrock doesn't use thought signatures + // Worker agent requests never enable thinking, so there is no + // reasoning block to round-trip here (the chat proxy path does — + // see providers/bedrock.rs). + extra_content: None, }) .collect() } @@ -858,6 +912,7 @@ pub fn build_tool_config( #[cfg(test)] mod tests { use super::*; + use aws_sdk_bedrockruntime::types::ReasoningContentBlock; use serde_json::value::RawValue; fn text_message(role: &str, content: &str) -> OpenAIMessage { @@ -934,6 +989,45 @@ mod tests { )); } + #[test] + fn openai_messages_to_bedrock_replays_reasoning_before_tool_use() { + let tool_call = OpenAIToolCall { + id: "call_1".to_string(), + function: OpenAIFunction { name: "lookup".to_string(), arguments: "{}".to_string() }, + r#type: FUNCTION_TYPE.to_string(), + extra_content: Some(crate::ai_types::ExtraContent { + bedrock: Some(crate::ai_types::BedrockExtraContent { + reasoning_text: Some("let me think".to_string()), + signature: Some("sig-abc".to_string()), + redacted_content: None, + }), + ..Default::default() + }), + }; + let assistant = OpenAIMessage { + role: "assistant".to_string(), + tool_calls: Some(vec![tool_call]), + ..Default::default() + }; + let messages = vec![text_message("user", "hi"), assistant]; + + let (bedrock_messages, _) = + openai_messages_to_bedrock(&messages, false).expect("bedrock conversion succeeds"); + + let content = bedrock_messages + .last() + .expect("assistant message") + .content(); + match &content[0] { + ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(rt)) => { + assert_eq!(rt.text(), "let me think"); + assert_eq!(rt.signature(), Some("sig-abc")); + } + other => panic!("expected reasoning block first, got {:?}", other), + } + assert!(matches!(&content[1], ContentBlock::ToolUse(_))); + } + #[test] fn openai_messages_to_bedrock_skips_cache_points_when_disabled() { let messages = vec![ diff --git a/backend/windmill-ai/src/ai_google.rs b/backend/windmill-ai/src/ai_google.rs index 3743e6eb52..d0ede744d3 100644 --- a/backend/windmill-ai/src/ai_google.rs +++ b/backend/windmill-ai/src/ai_google.rs @@ -139,6 +139,22 @@ pub struct GeminiGenerationConfig { pub response_mime_type: Option, #[serde(rename = "responseSchema", skip_serializing_if = "Option::is_none")] pub response_schema: Option, + #[serde(rename = "thinkingConfig", skip_serializing_if = "Option::is_none")] + pub thinking_config: Option, +} + +/// Thinking controls. Gemini 3+ models take a level token (`thinkingLevel`); +/// Gemini 2.5 models take a token budget (`thinkingBudget`, `-1` = dynamic). +/// `includeThoughts` returns thought summaries for chat display — summaries are +/// free; thinking tokens are billed whether or not they are returned. +#[derive(Serialize, Debug, PartialEq)] +pub struct GeminiThinkingConfig { + #[serde(rename = "thinkingLevel", skip_serializing_if = "Option::is_none")] + pub thinking_level: Option, + #[serde(rename = "thinkingBudget", skip_serializing_if = "Option::is_none")] + pub thinking_budget: Option, + #[serde(rename = "includeThoughts", skip_serializing_if = "Option::is_none")] + pub include_thoughts: Option, } // ============================================================================ @@ -206,6 +222,9 @@ pub struct GeminiSSEPart { pub text: Option, #[serde(rename = "functionCall")] pub function_call: Option, + /// Marks a thought-summary part (returned when `includeThoughts` is set). + #[serde(default)] + pub thought: Option, /// Thought signature for Gemini 3+ models. #[serde(rename = "thoughtSignature")] pub thought_signature: Option, @@ -293,6 +312,7 @@ impl GeminiToolCallEvent { pub fn to_extra_content(&self) -> Option { self.thought_signature.as_ref().map(|sig| ExtraContent { google: Some(GoogleExtraContent { thought_signature: Some(sig.clone()) }), + bedrock: None, }) } } @@ -301,6 +321,9 @@ impl GeminiToolCallEvent { #[derive(Debug, Default)] pub struct GeminiParsedEvent { pub text: Option, + /// Thought-summary text (parts flagged `thought: true`), kept separate from + /// the answer so it can stream as `reasoning_content`. + pub reasoning: Option, pub tool_calls: Vec, pub annotations: Vec, pub used_websearch: bool, @@ -575,6 +598,9 @@ pub fn gemini_response_to_openai(parsed: &GeminiParsedEvent, model: &str) -> ser "role": "assistant", "content": content, }); + if let Some(reasoning) = &parsed.reasoning { + message["reasoning_content"] = serde_json::json!(reasoning); + } if !tool_calls.is_empty() { message["tool_calls"] = serde_json::json!(tool_calls); } @@ -604,6 +630,20 @@ pub fn gemini_event_to_openai_sse_chunks( ) -> Vec { let mut chunks = Vec::new(); + if let Some(reasoning) = &parsed.reasoning { + let chunk = serde_json::json!({ + "id": id, + "object": "chat.completion.chunk", + "model": model, + "choices": [{ + "index": 0, + "delta": { "reasoning_content": reasoning }, + "finish_reason": null, + }] + }); + chunks.push(format!("data: {}\n\n", chunk)); + } + if let Some(text) = &parsed.text { let chunk = serde_json::json!({ "id": id, @@ -718,9 +758,16 @@ fn extract_candidates_into(candidates: &[GeminiSSECandidate], parsed: &mut Gemin for part in parts { if let Some(text) = &part.text { if !text.is_empty() { - match parsed.text.as_mut() { + // Thought-summary parts go to the reasoning channel, + // not the answer. + let target = if part.thought == Some(true) { + &mut parsed.reasoning + } else { + &mut parsed.text + }; + match target.as_mut() { Some(existing) => existing.push_str(text), - None => parsed.text = Some(text.clone()), + None => *target = Some(text.clone()), } } } @@ -788,8 +835,8 @@ fn openai_tool_call_json( #[cfg(test)] mod tests { use super::{ - gemini_event_to_openai_sse_chunks, gemini_response_to_openai, sanitize_schema_for_google, - GeminiParsedEvent, GeminiToolCallEvent, + gemini_event_to_openai_sse_chunks, gemini_response_to_openai, parse_gemini_sse_event, + sanitize_schema_for_google, GeminiParsedEvent, GeminiToolCallEvent, }; #[test] @@ -856,6 +903,59 @@ mod tests { assert_eq!(tool_call["index"], 0); } + #[test] + fn gemini_thought_parts_route_to_reasoning() { + let event = r#"{ + "candidates": [{ + "content": { + "parts": [ + {"text": "summary of my thinking", "thought": true}, + {"text": "the answer"} + ] + } + }] + }"#; + let parsed = parse_gemini_sse_event(event) + .expect("parse succeeds") + .expect("event is recognised"); + assert_eq!(parsed.reasoning.as_deref(), Some("summary of my thinking")); + assert_eq!(parsed.text.as_deref(), Some("the answer")); + + let mut tool_call_index = 0; + let chunks = gemini_event_to_openai_sse_chunks( + &parsed, + "chatcmpl-test", + "gemini-3-flash-preview", + &mut tool_call_index, + ); + assert_eq!(chunks.len(), 2); + let reasoning_chunk: serde_json::Value = serde_json::from_str( + chunks[0] + .strip_prefix("data: ") + .and_then(|c| c.strip_suffix("\n\n")) + .unwrap(), + ) + .unwrap(); + assert_eq!( + reasoning_chunk["choices"][0]["delta"]["reasoning_content"], + "summary of my thinking" + ); + let text_chunk: serde_json::Value = serde_json::from_str( + chunks[1] + .strip_prefix("data: ") + .and_then(|c| c.strip_suffix("\n\n")) + .unwrap(), + ) + .unwrap(); + assert_eq!(text_chunk["choices"][0]["delta"]["content"], "the answer"); + + // Non-streaming conversion keeps the channels separate too. + let response = gemini_response_to_openai(&parsed, "gemini-3-flash-preview"); + let message = &response["choices"][0]["message"]; + assert_eq!(message["content"], "the answer"); + assert_eq!(message["reasoning_content"], "summary of my thinking"); + } + #[test] fn sanitize_schema_for_google_removes_property_names() { let mut schema = serde_json::json!({ diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index 52d04910de..5d8e763b69 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -177,6 +177,8 @@ impl TryFrom<&str> for AIProvider { pub struct ProviderConfig { pub resource_path: String, pub models: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_search_enabled: Option, } #[derive(Serialize, Deserialize, Debug)] diff --git a/backend/windmill-ai/src/ai_types.rs b/backend/windmill-ai/src/ai_types.rs index 47c72fd3da..c6f171daf3 100644 --- a/backend/windmill-ai/src/ai_types.rs +++ b/backend/windmill-ai/src/ai_types.rs @@ -101,11 +101,30 @@ pub struct GoogleExtraContent { pub thought_signature: Option, } +/// Bedrock-specific extra content carrying the Claude reasoning block emitted +/// in the same assistant turn as a tool call. Anthropic requires reasoning +/// blocks (text + signature, unmodified) to be replayed before `toolUse` when +/// thinking is enabled, so the proxy round-trips them through the +/// OpenAI-shaped tool call. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +pub struct BedrockExtraContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, + /// Base64 of a redacted (encrypted) reasoning block, when the provider + /// returned one instead of readable text. + #[serde(skip_serializing_if = "Option::is_none")] + pub redacted_content: Option, +} + /// Extra content for provider-specific metadata (e.g., Google thought signatures) #[derive(Deserialize, Serialize, Clone, Debug, Default)] pub struct ExtraContent { #[serde(skip_serializing_if = "Option::is_none")] pub google: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bedrock: Option, } #[derive(Deserialize, Serialize, Clone, Debug)] diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs index 75d523cf25..c9502bdbb2 100644 --- a/backend/windmill-ai/src/credentials.rs +++ b/backend/windmill-ai/src/credentials.rs @@ -20,6 +20,5 @@ pub struct ProviderCredentials { pub aws_secret_access_key: Option, pub aws_session_token: Option, pub platform: AIPlatform, - pub enable_1m_context: bool, pub custom_headers: HashMap, } diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 17a27e370f..05398ce212 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -369,12 +369,11 @@ pub struct AnthropicQueryBuilder { #[allow(dead_code)] provider_kind: AIProvider, platform: AIPlatform, - enable_1m_context: bool, } impl AnthropicQueryBuilder { - pub fn new(provider_kind: AIProvider, platform: AIPlatform, enable_1m_context: bool) -> Self { - Self { provider_kind, platform, enable_1m_context } + pub fn new(provider_kind: AIProvider, platform: AIPlatform) -> Self { + Self { provider_kind, platform } } fn is_vertex(&self) -> bool { @@ -456,13 +455,6 @@ impl AnthropicQueryBuilder { } } - if is_anthropic_sdk && credentials.enable_1m_context { - headers.push(( - "anthropic-beta".to_string(), - "context-1m-2025-08-07".to_string(), - )); - } - if let Some(api_key) = credentials.api_key.as_ref() { headers.push(("authorization".to_string(), format!("Bearer {}", api_key))); if !is_vertex { @@ -713,14 +705,10 @@ impl QueryBuilder for AnthropicQueryBuilder { vec![("Authorization", format!("Bearer {}", api_key))] } else { // Standard Anthropic API uses x-api-key and anthropic-version header - let mut headers = vec![ + vec![ ("x-api-key", api_key.to_string()), ("anthropic-version", ANTHROPIC_VERSION_STANDARD.to_string()), - ]; - if self.enable_1m_context { - headers.push(("anthropic-beta", "context-1m-2025-08-07".to_string())); - } - headers + ] } } } @@ -747,7 +735,6 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, platform, - enable_1m_context: false, custom_headers: HashMap::new(), } } @@ -762,7 +749,7 @@ mod tests { fn builds_standard_anthropic_proxy_request() { let credentials = credentials(AIPlatform::Standard); let builder = - AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard, false); + AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard); let method = Method::POST; let mut headers = HeaderMap::new(); headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); @@ -794,41 +781,13 @@ mod tests { )); } - #[test] - fn builds_anthropic_sdk_proxy_request_with_context_beta() { - let mut credentials = credentials(AIPlatform::Standard); - credentials.enable_1m_context = true; - let builder = AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard, true); - let method = Method::POST; - let mut headers = HeaderMap::new(); - headers.insert("X-Anthropic-SDK", HeaderValue::from_static("typescript")); - let body = br#"{"model":"claude-sonnet-4","messages":[]}"#; - - let request = builder - .build_proxy_request(&ProxyBuildArgs { - method: &method, - path: "v1/messages", - headers: &headers, - body, - credentials: &credentials, - }) - .unwrap(); - - assert_eq!(request.url, "https://api.anthropic.com/v1/messages"); - assert!(has_header( - &request.headers, - "anthropic-beta", - "context-1m-2025-08-07" - )); - } - #[test] fn builds_vertex_anthropic_proxy_request() { let mut credentials = credentials(AIPlatform::GoogleVertexAi); credentials.base_url = "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/anthropic/models".to_string(); credentials.user = Some("user-1".to_string()); let builder = - AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi, false); + AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi); let method = Method::POST; let mut headers = HeaderMap::new(); headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); @@ -872,7 +831,7 @@ mod tests { fn rejects_vertex_proxy_request_without_model() { let credentials = credentials(AIPlatform::GoogleVertexAi); let builder = - AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi, false); + AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::GoogleVertexAi); let method = Method::POST; let headers = HeaderMap::new(); diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 0eef359c36..ddc5f33998 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -12,11 +12,14 @@ use crate::{ bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, bedrock_stream_event_to_tool_start_with_block_index, build_tool_config, - create_inference_config, format_bedrock_error, openai_messages_to_bedrock, - streaming_tool_calls_to_openai, BearerTokenProvider, BedrockClient, StreamingToolCall, + create_inference_config, format_bedrock_error, json_to_document, + openai_messages_to_bedrock, streaming_tool_calls_to_openai, BearerTokenProvider, + BedrockClient, StreamingToolCall, }, ai_providers::USE_ENV_REGION, - ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction}, + ai_types::{ + BedrockExtraContent, ExtraContent, OpenAIFunction, OpenAIToolCall, ToolDefFunction, + }, image_handler::prepare_messages_for_api, proxy::ProxyBuildArgs, query_builder::{ParsedResponse, StreamEventSink}, @@ -45,6 +48,11 @@ struct OpenAIRequest { max_tokens: Option, #[serde(default)] temperature: Option, + /// Anthropic effort token from the chat reasoning setting (e.g. `low`, + /// `high`, `max`). Enables adaptive thinking via + /// `additionalModelRequestFields` — see [`bedrock_thinking_fields`]. + #[serde(default)] + reasoning_effort: Option, } #[derive(Deserialize, Debug)] @@ -349,7 +357,13 @@ async fn handle_bedrock_sdk_streaming( let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; - let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + // Adaptive thinking rejects sampling params; drop temperature when reasoning is on. + let temperature = openai_req + .reasoning_effort + .is_none() + .then_some(openai_req.temperature) + .flatten(); + let inference_config = create_inference_config(temperature, openai_req.max_tokens); let tool_config = build_tool_config_from_request( openai_req.tools.as_deref(), openai_req.tool_choice.as_ref(), @@ -374,6 +388,11 @@ async fn handle_bedrock_sdk_streaming( request_builder = request_builder.set_tool_config(Some(config)); } + if let Some(effort) = openai_req.reasoning_effort.as_deref() { + request_builder = + request_builder.additional_model_request_fields(bedrock_thinking_fields(effort)); + } + tracing::debug!("Bedrock SDK streaming: sending converse_stream request"); let stream_output = request_builder.send().await.map_err(|e| { let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e)); @@ -391,6 +410,17 @@ async fn handle_bedrock_sdk_streaming( }) } +/// Build the Converse `additionalModelRequestFields` enabling Claude adaptive +/// thinking at the given effort. `display: summarized` is billing-neutral on +/// Anthropic models and matches the direct-Anthropic chat path, which renders +/// summarized thinking in the UI. +fn bedrock_thinking_fields(effort: &str) -> aws_smithy_types::Document { + json_to_document(serde_json::json!({ + "thinking": { "type": "adaptive", "display": "summarized" }, + "output_config": { "effort": effort } + })) +} + pub fn sdk_stream_to_sse( stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver< aws_sdk_bedrockruntime::types::ConverseStreamOutput, @@ -438,6 +468,11 @@ struct BedrockSseStreamState { tool_calls: HashMap, tool_block_indexes: HashMap, next_tool_index: usize, + /// Claude reasoning block accumulated from `ReasoningContent` deltas + /// (text + signature, or redacted bytes). Attached to the first tool call + /// of the turn so the frontend round-trips it for replay. + reasoning: Option, + reasoning_attached: bool, } impl BedrockSseStreamState { @@ -449,6 +484,8 @@ impl BedrockSseStreamState { tool_calls: HashMap::new(), tool_block_indexes: HashMap::new(), next_tool_index: 0, + reasoning: None, + reasoning_attached: false, } } } @@ -459,6 +496,24 @@ fn bedrock_sse_chunks_for_event( ) -> Vec { let mut chunks = Vec::new(); + if let Some(reasoning_text) = accumulate_reasoning_delta(event, state) { + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "reasoning_content": reasoning_text + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + if let Some((block_index, tool_call)) = bedrock_stream_event_to_tool_start_with_block_index(event) { @@ -470,6 +525,25 @@ fn bedrock_sse_chunks_for_event( (tool_call.id.clone(), tool_call.name.clone(), String::new()), ); + let mut tool_call_json = serde_json::json!({ + "index": index, + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": "" + } + }); + // Attach the turn's reasoning block to the first tool call so the + // frontend echoes it back and the next request can replay it before + // toolUse (required by Claude when thinking is enabled). + if !state.reasoning_attached { + if let Some(reasoning) = state.reasoning.as_ref() { + tool_call_json["extra_content"] = serde_json::json!({ "bedrock": reasoning }); + state.reasoning_attached = true; + } + } + let chunk = serde_json::json!({ "id": state.id, "object": "chat.completion.chunk", @@ -478,15 +552,7 @@ fn bedrock_sse_chunks_for_event( "choices": [{ "index": 0, "delta": { - "tool_calls": [{ - "index": index, - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": "" - } - }] + "tool_calls": [tool_call_json] }, "finish_reason": serde_json::Value::Null }] @@ -573,6 +639,61 @@ fn bedrock_sse_chunks_for_event( chunks } +/// Fold a `ReasoningContent` stream delta into the state's pending reasoning +/// block. Returns the text delta (for a `reasoning_content` SSE chunk) when the +/// event carried readable reasoning text. +fn accumulate_reasoning_delta( + event: &aws_sdk_bedrockruntime::types::ConverseStreamOutput, + state: &mut BedrockSseStreamState, +) -> Option { + let aws_sdk_bedrockruntime::types::ConverseStreamOutput::ContentBlockDelta(delta_event) = event + else { + return None; + }; + let aws_sdk_bedrockruntime::types::ContentBlockDelta::ReasoningContent(reasoning) = + delta_event.delta()? + else { + return None; + }; + + let entry = state.reasoning.get_or_insert_with(Default::default); + match reasoning { + aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta::Text(text) => { + entry + .reasoning_text + .get_or_insert_with(String::new) + .push_str(text); + Some(text.clone()) + } + aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta::Signature(signature) => { + entry + .signature + .get_or_insert_with(String::new) + .push_str(signature); + None + } + aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta::RedactedContent(blob) => { + // Base64 of concatenated fragments != concatenated base64 fragments, + // so accumulate raw bytes and re-encode. + let mut bytes = entry + .redacted_content + .as_deref() + .and_then(|existing| { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, existing) + .ok() + }) + .unwrap_or_default(); + bytes.extend_from_slice(blob.as_ref()); + entry.redacted_content = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + bytes, + )); + None + } + _ => None, + } +} + async fn handle_bedrock_sdk_non_streaming( model: &str, body: &[u8], @@ -586,7 +707,13 @@ async fn handle_bedrock_sdk_non_streaming( let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; - let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + // Adaptive thinking rejects sampling params; drop temperature when reasoning is on. + let temperature = openai_req + .reasoning_effort + .is_none() + .then_some(openai_req.temperature) + .flatten(); + let inference_config = create_inference_config(temperature, openai_req.max_tokens); let tool_config = build_tool_config_from_request( openai_req.tools.as_deref(), openai_req.tool_choice.as_ref(), @@ -611,6 +738,11 @@ async fn handle_bedrock_sdk_non_streaming( request_builder = request_builder.set_tool_config(Some(config)); } + if let Some(effort) = openai_req.reasoning_effort.as_deref() { + request_builder = + request_builder.additional_model_request_fields(bedrock_thinking_fields(effort)); + } + tracing::debug!("Bedrock SDK non-streaming: sending converse request"); let response = request_builder.send().await.map_err(|e| { let error_msg = format!( @@ -643,6 +775,7 @@ async fn handle_bedrock_sdk_non_streaming( let mut text_content = String::new(); let mut tool_calls: Vec = Vec::new(); + let mut reasoning: Option = None; if let Some(aws_sdk_bedrockruntime::types::ConverseOutput::Message(message)) = response.output() { @@ -651,6 +784,29 @@ async fn handle_bedrock_sdk_non_streaming( aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => { text_content.push_str(text); } + aws_sdk_bedrockruntime::types::ContentBlock::ReasoningContent(rc) => { + let entry = reasoning.get_or_insert_with(Default::default); + match rc { + aws_sdk_bedrockruntime::types::ReasoningContentBlock::ReasoningText(rt) => { + entry + .reasoning_text + .get_or_insert_with(String::new) + .push_str(rt.text()); + if let Some(signature) = rt.signature() { + entry.signature = Some(signature.to_string()); + } + } + aws_sdk_bedrockruntime::types::ReasoningContentBlock::RedactedContent( + blob, + ) => { + entry.redacted_content = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + blob.as_ref(), + )); + } + _ => {} + } + } aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => { let input_json = document_to_json(tool_use.input()); tool_calls.push(OpenAIToolCall { @@ -668,7 +824,20 @@ async fn handle_bedrock_sdk_non_streaming( } } - let message = if !tool_calls.is_empty() { + // Attach the turn's reasoning block to the first tool call so the frontend + // round-trips it for replay (required by Claude when thinking is enabled). + if let (Some(reasoning_block), Some(first_tool_call)) = + (reasoning.as_ref(), tool_calls.first_mut()) + { + first_tool_call.extra_content = + Some(ExtraContent { bedrock: Some(reasoning_block.clone()), ..Default::default() }); + } + let reasoning_content = reasoning + .as_ref() + .and_then(|r| r.reasoning_text.clone()) + .filter(|t| !t.is_empty()); + + let mut message = if !tool_calls.is_empty() { serde_json::json!({ "role": "assistant", "content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) }, @@ -680,6 +849,9 @@ async fn handle_bedrock_sdk_non_streaming( "content": text_content }) }; + if let Some(reasoning_content) = reasoning_content { + message["reasoning_content"] = serde_json::Value::String(reasoning_content); + } let usage = if let Some(usage_data) = response.usage() { serde_json::json!({ @@ -1122,4 +1294,124 @@ mod tests { 0 ); } + + #[test] + fn bedrock_thinking_fields_carry_adaptive_thinking_and_effort() { + let fields = document_to_json(&bedrock_thinking_fields("xhigh")); + assert_eq!(fields["thinking"]["type"], "adaptive"); + assert_eq!(fields["thinking"]["display"], "summarized"); + assert_eq!(fields["output_config"]["effort"], "xhigh"); + } + + fn reasoning_delta( + block_index: i32, + delta: aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta, + ) -> ConverseStreamOutput { + ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(block_index) + .delta(ContentBlockDelta::ReasoningContent(delta)) + .build() + .unwrap(), + ) + } + + #[test] + fn bedrock_sse_streams_reasoning_and_attaches_block_to_first_tool_call() { + use aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta; + + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + + // Reasoning text streams as reasoning_content deltas. + let chunks = bedrock_sse_chunks_for_event( + &reasoning_delta(0, ReasoningContentBlockDelta::Text("let me ".to_string())), + &mut state, + ); + assert_eq!( + sse_json(&chunks[0])["choices"][0]["delta"]["reasoning_content"], + "let me " + ); + bedrock_sse_chunks_for_event( + &reasoning_delta(0, ReasoningContentBlockDelta::Text("think".to_string())), + &mut state, + ); + // Signature deltas accumulate silently (no chunk emitted). + assert!(bedrock_sse_chunks_for_event( + &reasoning_delta( + 0, + ReasoningContentBlockDelta::Signature("sig-abc".to_string()) + ), + &mut state, + ) + .is_empty()); + + // The first tool call carries the full reasoning block for replay. + let tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(1) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_1") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let start_json = sse_json(&bedrock_sse_chunks_for_event(&tool_start, &mut state)[0]); + let tool_call = &start_json["choices"][0]["delta"]["tool_calls"][0]; + assert_eq!( + tool_call["extra_content"]["bedrock"]["reasoning_text"], + "let me think" + ); + assert_eq!( + tool_call["extra_content"]["bedrock"]["signature"], + "sig-abc" + ); + + // Subsequent tool calls don't repeat the block. + let second_tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(2) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_2") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let second_json = + sse_json(&bedrock_sse_chunks_for_event(&second_tool_start, &mut state)[0]); + assert!(second_json["choices"][0]["delta"]["tool_calls"][0] + .get("extra_content") + .is_none()); + } + + #[test] + fn bedrock_sse_accumulates_redacted_reasoning_bytes() { + use aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta; + + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + for fragment in [b"ab".as_slice(), b"cd".as_slice()] { + assert!(bedrock_sse_chunks_for_event( + &reasoning_delta( + 0, + ReasoningContentBlockDelta::RedactedContent(fragment.to_vec().into()), + ), + &mut state, + ) + .is_empty()); + } + + let encoded = state.reasoning.unwrap().redacted_content.unwrap(); + let decoded = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).unwrap(); + assert_eq!(decoded, b"abcd"); + } } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index cf00ddc142..650e11c0c7 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -4,7 +4,7 @@ use crate::{ openai_tools_to_gemini, parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, - GeminiPredictContent, GeminiTextRequest, GeminiTool, + GeminiPredictContent, GeminiTextRequest, GeminiThinkingConfig, GeminiTool, }, image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, proxy::{ProxyBuildArgs, ProxyRequest}, @@ -158,6 +158,8 @@ impl GoogleAIQueryBuilder { args.max_tokens, response_mime_type, response_schema, + // Worker AI-agent requests carry no reasoning knob; keep provider defaults. + None, ) } } @@ -187,23 +189,73 @@ fn build_gemini_generation_config( max_tokens: Option, response_mime_type: Option, response_schema: Option, + thinking_config: Option, ) -> Option { if temperature.is_some() || max_tokens.is_some() || response_mime_type.is_some() || response_schema.is_some() + || thinking_config.is_some() { Some(GeminiGenerationConfig { temperature, max_output_tokens: max_tokens, response_mime_type, response_schema, + thinking_config, }) } else { None } } +/// Map an OpenAI-style `reasoning_effort` token onto Gemini's native thinking +/// controls. Gemini models think by default, so this is what makes the chat +/// effort setting (including explicit `none`) actually change model behavior. +/// +/// - Gemini 3+: `thinkingLevel` takes the token verbatim (provider-native open +/// vocabulary: `low`/`medium`/`high`, plus `minimal` on Flash). `none` maps to +/// the lowest supported level — thinking cannot be fully disabled on Pro. +/// - Gemini 2.5: `thinkingBudget` in tokens, using the same tiering as Google's +/// own OpenAI-compatibility layer. 2.5 Pro cannot disable thinking and has a +/// minimum budget of 128; Flash accepts 0 (off). Unknown tokens fall back to +/// `-1` (dynamic, the provider default). +fn gemini_thinking_config(model: &str, effort: &str) -> GeminiThinkingConfig { + let model = model.to_lowercase(); + let is_flash = model.contains("flash"); + let is_gemini_2_5 = model.contains("gemini-2.5"); + // Thought summaries are free to return (thinking tokens are billed either + // way); skip them only when the user explicitly turned reasoning off. + let include_thoughts = (effort != "none").then_some(true); + + if is_gemini_2_5 { + let budget = match effort { + "none" if is_flash => 0, + "none" => 128, + "low" => 1024, + "medium" => 8192, + "high" => 24576, + _ => -1, + }; + GeminiThinkingConfig { + thinking_level: None, + thinking_budget: Some(budget), + include_thoughts, + } + } else { + let level = match effort { + "none" if is_flash => "minimal".to_string(), + "none" => "low".to_string(), + other => other.to_string(), + }; + GeminiThinkingConfig { + thinking_level: Some(level), + thinking_budget: None, + include_thoughts, + } + } +} + #[derive(Deserialize, Debug)] struct GoogleAIProxyChatRequest { model: String, @@ -214,6 +266,11 @@ struct GoogleAIProxyChatRequest { temperature: Option, #[serde(default)] max_tokens: Option, + /// OpenAI-style effort token from the chat reasoning setting (e.g. `low`, + /// `high`, or `none` for an explicit off). Mapped to Gemini's native + /// `thinkingConfig` — see [`gemini_thinking_config`]. + #[serde(default)] + reasoning_effort: Option, #[serde(default)] tools: Option>, } @@ -344,10 +401,20 @@ fn build_google_ai_chat_proxy_request( vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] }); + let thinking_config = request + .reasoning_effort + .as_deref() + .map(|effort| gemini_thinking_config(&request.model, effort)); let body = build_gemini_text_request_body( &request.messages, gemini_tools, - build_gemini_generation_config(request.temperature, request.max_tokens, None, None), + build_gemini_generation_config( + request.temperature, + request.max_tokens, + None, + None, + thinking_config, + ), )? .into_bytes(); @@ -707,7 +774,6 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, platform, - enable_1m_context: false, custom_headers: HashMap::new(), } } @@ -752,9 +818,108 @@ mod tests { let body: serde_json::Value = serde_json::from_slice(&request.request.body).unwrap(); assert_eq!(body["generationConfig"]["maxOutputTokens"], 123); assert_eq!(body["generationConfig"]["temperature"], 0.2); + // No reasoning_effort in the request -> no thinkingConfig (provider defaults). + assert!(body["generationConfig"].get("thinkingConfig").is_none()); assert!(body["contents"].is_array()); } + fn proxy_body_for(model: &str, reasoning_effort: &str) -> serde_json::Value { + let credentials = credentials( + "https://generativelanguage.googleapis.com/v1beta/", + AIPlatform::Standard, + ); + let method = Method::POST; + let headers = HeaderMap::new(); + let body = format!( + r#"{{ + "model": "{model}", + "messages": [{{"role": "user", "content": "hello"}}], + "reasoning_effort": "{reasoning_effort}", + "stream": false + }}"# + ); + + let request = build_google_ai_chat_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body: body.as_bytes(), + credentials: &credentials, + }) + .unwrap(); + + serde_json::from_slice(&request.request.body).unwrap() + } + + #[test] + fn maps_reasoning_effort_to_thinking_level_on_gemini_3() { + let body = proxy_body_for("gemini-3-pro-preview", "low"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingLevel"], + "low" + ); + // Thought summaries are requested whenever reasoning is on... + assert_eq!( + body["generationConfig"]["thinkingConfig"]["includeThoughts"], + true + ); + // ...but not when the user explicitly turned it off. + let body = proxy_body_for("gemini-3-pro-preview", "none"); + assert!(body["generationConfig"]["thinkingConfig"] + .get("includeThoughts") + .is_none()); + assert!(body["generationConfig"]["thinkingConfig"] + .get("thinkingBudget") + .is_none()); + } + + #[test] + fn maps_reasoning_effort_none_per_gemini_3_model() { + // Pro cannot disable thinking -> lowest level. + let body = proxy_body_for("gemini-3-pro-preview", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingLevel"], + "low" + ); + // Flash supports `minimal`. + let body = proxy_body_for("gemini-3-flash-preview", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingLevel"], + "minimal" + ); + } + + #[test] + fn maps_reasoning_effort_to_thinking_budget_on_gemini_2_5() { + let body = proxy_body_for("gemini-2.5-flash", "medium"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + 8192 + ); + assert!(body["generationConfig"]["thinkingConfig"] + .get("thinkingLevel") + .is_none()); + + // Off: Flash can fully disable; Pro has a 128-token minimum. + let body = proxy_body_for("gemini-2.5-flash", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + 0 + ); + let body = proxy_body_for("gemini-2.5-pro", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + 128 + ); + + // Unknown token -> dynamic budget (provider default behavior). + let body = proxy_body_for("gemini-2.5-pro", "custom-level"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + -1 + ); + } + #[test] fn builds_standard_google_ai_endpoint_from_model_resource_name() { assert_eq!( diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index fb50707221..80fbc8fcca 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -23,7 +23,6 @@ pub fn create_query_builder(credentials: &ProviderCredentials) -> Box Box::new(AnthropicQueryBuilder::new( credentials.provider.clone(), credentials.platform.clone(), - credentials.enable_1m_context, )), AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), _ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())), diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 32ba35cd6d..891fa89486 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -165,7 +165,6 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, platform: AIPlatform::Standard, - enable_1m_context: false, custom_headers: HashMap::new(), } } diff --git a/backend/windmill-ai/src/sse.rs b/backend/windmill-ai/src/sse.rs index 35a4e43acc..bb1b9d9731 100644 --- a/backend/windmill-ai/src/sse.rs +++ b/backend/windmill-ai/src/sse.rs @@ -522,6 +522,7 @@ impl SSEParser for GeminiSSEParser { let extra_content = tool_call.thought_signature.map(|sig| ExtraContent { google: Some(GoogleExtraContent { thought_signature: Some(sig) }), + bedrock: None, }); self.accumulated_tool_calls.insert( diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 56a796e41d..8f864468db 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -193,9 +193,6 @@ pub struct ProviderResource { /// Platform (standard or google_vertex_ai) #[serde(default)] pub platform: AIPlatform, - /// Enable 1M context window for Anthropic - #[serde(alias = "enable_1M_context", default)] - pub enable_1m_context: bool, /// Custom HTTP headers to include in AI requests #[serde(default)] pub headers: HashMap, @@ -246,7 +243,6 @@ impl ProviderWithResource { aws_secret_access_key: self.resource.aws_secret_access_key.clone(), aws_session_token: self.resource.aws_session_token.clone(), platform: self.resource.platform.clone(), - enable_1m_context: self.resource.enable_1m_context, custom_headers: self.resource.headers.clone(), }) } @@ -275,10 +271,6 @@ impl ProviderWithResource { &self.resource.platform } - pub fn get_enable_1m_context(&self) -> bool { - self.resource.enable_1m_context - } - pub fn get_headers(&self) -> &HashMap { &self.resource.headers } @@ -891,6 +883,26 @@ mod tests { } } + #[test] + fn provider_resource_ignores_legacy_enable_1m_context_keys() { + // Resources created before the field was removed still carry the legacy key + // (lowercase `enable_1m_context` or the frontend alias `enable_1M_context`). + // The struct has no `deny_unknown_fields`, so both must be silently ignored. + let json = r#"{ + "base_url": "https://api.anthropic.com", + "enable_1m_context": true, + "enable_1M_context": true + }"#; + + let resource: ProviderResource = + serde_json::from_str(json).expect("legacy resource must still deserialize"); + + assert_eq!( + resource.base_url.as_deref(), + Some("https://api.anthropic.com") + ); + } + #[test] fn test_make_strict_adds_additional_properties_false() { let mut schema = object_schema(vec![("name", string_schema())]); diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index f0c87633d2..016e72be91 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -3,11 +3,11 @@ use anyhow::{anyhow, Error, Result}; #[cfg(feature = "embedding")] use std::{collections::HashMap, path::PathBuf, sync::Arc}; #[cfg(feature = "embedding")] +use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT; +#[cfg(feature = "embedding")] use windmill_common::DEFAULT_HUB_BASE_URL; #[cfg(feature = "embedding")] use windmill_common::HUB_BASE_URL; -#[cfg(feature = "embedding")] -use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT; use axum::Router; @@ -159,23 +159,52 @@ pub struct ModelInstance { #[cfg(feature = "embedding")] impl ModelInstance { + async fn get_hf_file( + repo_api: &hf_hub::api::tokio::ApiRepo, + filename: &str, + ) -> Result { + // HuggingFace downloads have no built-in retry, so a single transient + // network blip would fail the whole image build. Retry with exponential + // backoff (1s, 2s, 4s, 8s, capped at 8s) up to MAX_ATTEMPTS times. + const MAX_ATTEMPTS: u32 = 5; + let mut attempt: u32 = 0; + loop { + attempt += 1; + match repo_api.get(filename).await { + Ok(path) => return Ok(path), + Err(e) => { + if attempt >= MAX_ATTEMPTS { + return Err(anyhow!( + "Failed to get {} from hugging face after {} attempts: {}", + filename, + attempt, + e + )); + } + let delay_secs = 1u64 << (attempt - 1).min(3); + tracing::warn!( + "Failed to get {} from hugging face (attempt {}/{}): {}. Retrying in {}s...", + filename, + attempt, + MAX_ATTEMPTS, + e, + delay_secs + ); + tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await; + } + } + } + } + pub async fn load_model_files() -> Result<(PathBuf, PathBuf, PathBuf)> { let api = Api::new()?; let repo_api = api.model("thenlper/gte-small".to_string()); - let (config_filename, tokenizer_filename, weights_filename) = - ( - repo_api - .get("config.json") - .await - .map_err(|e| anyhow!("Failed to get config.json from hugging face: {}", e))?, - repo_api.get("tokenizer.json").await.map_err(|e| { - anyhow!("Failed to get tokenizer.json from hugging face: {}", e) - })?, - repo_api.get("model.safetensors").await.map_err(|e| { - anyhow!("Failed to get model.safetensors from hugging face: {}", e) - })?, - ); + let (config_filename, tokenizer_filename, weights_filename) = ( + Self::get_hf_file(&repo_api, "config.json").await?, + Self::get_hf_file(&repo_api, "tokenizer.json").await?, + Self::get_hf_file(&repo_api, "model.safetensors").await?, + ); Ok((config_filename, tokenizer_filename, weights_filename)) } diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index c63d53171c..b5f8e2f285 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -21,7 +21,8 @@ use windmill_api_auth::{ }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ - utils::{WithStarredInfoQuery, HTTP_CLIENT}, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, + utils::HTTP_CLIENT, webhook::{WebhookMessage, WebhookShared}, DB, }; @@ -49,7 +50,6 @@ use windmill_common::{ flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, - scripts::Schema, utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, }; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; @@ -68,7 +68,6 @@ pub fn workspaced_service() -> Router { .route("/list_tokens/{*path}", get(list_tokens)) .route("/get/{*path}", get(get_flow_by_path)) .route("/deployment_status/p/{*path}", get(get_deployment_status)) - .route("/get/draft/{*path}", get(get_flow_by_path_w_draft)) .route("/exists/{*path}", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) .route("/history/p/{*path}", get(get_flow_history)) @@ -130,6 +129,7 @@ async fn list_search_flows( async fn list_flows( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -151,10 +151,16 @@ async fn list_flows( "archived", "extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", - "o.labels" + "o.labels", + "draft.email IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users", + "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() .join("favorite") @@ -165,7 +171,8 @@ async fn list_flows( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?" + .bind(&authed.email), ) .left() .join("flow_version fv") @@ -194,15 +201,16 @@ async fn list_flows( sqlb.and_where_is_not_null("favorite.path"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("o.draft_only IS NOT TRUE"); - } if let Some(dw) = &lq.dedicated_worker { sqlb.and_where_eq("dedicated_worker", dw); } if let Some(label) = &lq.label { for l in label.split(',') { - sqlb.and_where("o.labels @> ARRAY[?]".bind(&l.trim())); + sqlb.and_where( + "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])" + .bind(&l.trim()) + .bind(&l.trim()), + ); } } @@ -216,13 +224,92 @@ async fn list_flows( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "flows", "read"); - let rows = sqlx::query_as::<_, ListableFlow>(&sql) + let mut rows = sqlx::query_as::<_, ListableFlow>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.edited_by.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` + `DISTINCT ON (path)` ordered NULL-last; see scripts.rs. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'flow' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM flow f + WHERE f.workspace_id = draft.workspace_id + AND f.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // The Path widget binds `$pathStore` one-way (`flow.path → $pathStore`), + // so the editor writes a separate `draft_path` field only when the typed + // path differs from the deployed one. `None` = unchanged. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableFlow { + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + edited_by: Some(authed.email.clone()), + edited_at: Some(row.created_at), + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + starred: false, + draft_only: Some(true), + ws_error_handler_muted: None, + deployment_msg: None, + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -512,22 +599,21 @@ async fn create_flow( sqlx::query!( r#"INSERT INTO flow ( workspace_id, path, summary, description, - dependency_job, lock_error_logs, draft_only, tag, + dependency_job, lock_error_logs, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, ws_error_handler_muted, value, schema, edited_by, edited_at, labels ) VALUES ( $1, $2, $3, $4, - NULL, '', $5, $6, - $7, $8, $9, - $10, - $11, $12::text::json, $13, now(), $14 + NULL, '', $5, + $6, $7, $8, + $9, + $10, $11::text::json, $12, now(), $13 )"#, w_id, nf.path, nf.summary, nf.description.as_deref().unwrap_or(""), - nf.draft_only, nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), @@ -566,12 +652,15 @@ async fn create_flow( ).execute(&mut *tx).await?; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", nf.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -714,18 +803,6 @@ async fn check_schedule_conflict<'c>( Ok(()) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2", - "flow", - ) - .await; -} - #[derive(Serialize)] pub struct FlowVersion { pub id: i64, @@ -794,7 +871,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -845,7 +922,6 @@ async fn get_flow_version_by_id( flow.description, flow.archived, flow.extra_perms, - flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, @@ -982,7 +1058,6 @@ async fn update_flow( description = $3, dependency_job = NULL, lock_error_logs = '', - draft_only = NULL, tag = $4, dedicated_worker = $5, visible_to_runner_only = $6, @@ -1024,8 +1099,8 @@ async fn update_flow( // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1169,12 +1244,15 @@ async fn update_flow( } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", flow_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1353,10 +1431,12 @@ async fn update_flow( } async fn list_tokens( + authed: ApiAuthed, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> JsonResult> { let path = path.to_path(); + check_scopes(&authed, || format!("flows:read:{}", path))?; list_tokens_internal(&db, &w_id, &path, true).await } @@ -1392,12 +1472,21 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetFlowByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + async fn get_flow_by_path( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1411,15 +1500,15 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, + folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, flow_version.value, @@ -1438,7 +1527,7 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1452,15 +1541,15 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, + folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, flow_version.value, @@ -1474,90 +1563,26 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct FlowWDraft { - pub path: String, - pub summary: String, - pub description: String, - pub schema: Option, - pub value: sqlx::types::Json>, - pub extra_perms: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, -} - -async fn get_flow_by_path_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let flow_o = sqlx::query_as::<_, FlowWDraft>( - "SELECT - flow.path, - flow.summary, - flow.description, - flow_version.schema, - flow_version.value, - flow.extra_perms, - flow.draft_only, - flow.ws_error_handler_muted, - flow.dedicated_worker, - draft.value AS draft, - draft.created_at AS draft_created_at, - flow.tag, - flow.visible_to_runner_only, - flow.on_behalf_of_email, - flow.labels - FROM flow - LEFT JOIN draft - ON flow.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'flow' - LEFT JOIN flow_version - ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.path = $1 - AND flow.workspace_id = $2", + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Flow, + path, + query.get_draft, + flow_o, + || windmill_common::error::Error::NotFound(format!("Flow not found at path {path}")), ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) .await?; - - tx.commit().await?; - - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) + Ok(Json(overlay)) } async fn exists_flow_by_path( @@ -2084,8 +2109,7 @@ mod tests { }, "stop_after_if": { "expr": "foo = 'bar'", - "skip_if_stopped": false, - "error_message": null + "skip_if_stopped": false } }, { @@ -2106,8 +2130,7 @@ mod tests { }, "stop_after_if": { "expr": "previous.isEmpty()", - "skip_if_stopped": false, - "error_message": null + "skip_if_stopped": false } } ], @@ -2120,8 +2143,7 @@ mod tests { }, "stop_after_if": { "expr": "previous.isEmpty()", - "skip_if_stopped": false, - "error_message": null + "skip_if_stopped": false } }, }); diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index fa23a21df6..eedb43bc1a 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -61,6 +61,8 @@ pub struct Folder { pub created_by: Option, pub edited_at: Option>, pub default_permissioned_as: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Deserialize)] @@ -71,6 +73,7 @@ pub struct NewFolder { pub owners: Option>, pub extra_perms: Option, pub default_permissioned_as: Option, + pub labels: Option>, } #[derive(Deserialize)] @@ -80,6 +83,15 @@ pub struct UpdateFolder { pub owners: Option>, pub extra_perms: Option, pub default_permissioned_as: Option, + pub labels: Option>, +} + +// Folder labels are surfaced verbatim as `inherited_labels` and rendered in keyed +// `{#each}` blocks; a repeated label is a duplicate key that crashes the list views. +// The UI dedups on entry but API/CLI/git-sync writes do not, so normalize on write. +fn dedup_labels(labels: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + labels.retain(|l| seen.insert(l.clone())); } #[derive(Deserialize)] @@ -99,7 +111,7 @@ async fn list_folders( let rows = sqlx::query_as!( Folder, - "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", + "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", w_id, per_page as i64, offset as i64 @@ -211,7 +223,7 @@ async fn check_name_conflict<'c>( } lazy_static! { - static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9]+$"#).unwrap(); + static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9-]+$"#).unwrap(); } async fn create_folder( @@ -222,8 +234,11 @@ async fn create_folder( Extension(webhook): Extension, Extension(cache): Extension>, Path(w_id): Path, - Json(ng): Json, + Json(mut ng): Json, ) -> Result { + if let Some(labels) = ng.labels.as_mut() { + dedup_labels(labels); + } if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), @@ -240,7 +255,7 @@ async fn create_folder( if !VALID_FOLDER_NAME.is_match(&ng.name) { return Err(windmill_common::error::Error::BadRequest(format!( - "Folder name can only contain alphanumeric characters, underscores" + "Folder name can only contain alphanumeric characters, underscores, and hyphens" ))); } check_name_conflict(&mut tx, &w_id, &ng.name).await?; @@ -280,7 +295,7 @@ async fn create_folder( if let Err(e) = sqlx::query_as!( Folder, - "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)", + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9)", w_id, ng.name, ng.display_name.unwrap_or(ng.name.clone()), @@ -288,7 +303,8 @@ async fn create_folder( extra_perms, ng.summary, authed.username, - default_permissioned_as + default_permissioned_as, + ng.labels.as_deref().filter(|l| !l.is_empty()) as Option<&[String]> ) .execute(&mut *tx) .await { @@ -466,6 +482,26 @@ async fn update_folder( ); } + if let Some(labels) = ng.labels.as_mut() { + dedup_labels(labels); + if labels.is_empty() { + // normalize cleared labels to NULL so the field stays out of API/tarball output + sqlb.set("labels", "NULL"); + } else { + sqlb.set( + "labels", + "?".bind(&format!( + "{{{}}}", + labels + .iter() + .map(|x| format!("\"{}\"", x.replace('\\', "\\\\").replace('"', "\\\""))) + .collect::>() + .join(","), + )), + ); + } + } + sqlb.returning("*"); let mut tx = user_db.begin(&authed).await?; @@ -570,7 +606,7 @@ pub async fn get_folderopt<'c>( ) -> Result> { let folderopt = sqlx::query_as!( Folder, - "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE name = $1 AND workspace_id = $2", + "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as, labels FROM folder WHERE name = $1 AND workspace_id = $2", name, w_id ) @@ -943,3 +979,22 @@ pub async fn log_folder_permission_change<'c, E: sqlx::Executor<'c, Database = P .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folder_name_allows_hyphens() { + // #8474: hyphens are valid in folder names, consistent with owner/path + // validation (which already permits them) and with folders created via + // the CLI / by deploying to an `f//...` path. + assert!(VALID_FOLDER_NAME.is_match("folder-name")); + assert!(VALID_FOLDER_NAME.is_match("foo_bar")); + assert!(VALID_FOLDER_NAME.is_match("Foo123")); + // Disallowed characters are still rejected. + assert!(!VALID_FOLDER_NAME.is_match("foo/bar")); + assert!(!VALID_FOLDER_NAME.is_match("foo bar")); + assert!(!VALID_FOLDER_NAME.is_match("")); + } +} diff --git a/backend/windmill-api-integration-tests/tests/apps.rs b/backend/windmill-api-integration-tests/tests/apps.rs index 63bd96345b..3d70c09ce1 100644 --- a/backend/windmill-api-integration-tests/tests/apps.rs +++ b/backend/windmill-api-integration-tests/tests/apps.rs @@ -82,12 +82,6 @@ async fn test_app_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get/p", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_app").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_app"); - // --- get lite --- let resp = authed_get(port, "get/lite", "u/test-user/test_app").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index d86c91b6a1..b3bac8d010 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -1,105 +1,297 @@ use serde_json::json; use sqlx::{Pool, Postgres}; - use windmill_test_utils::*; -fn client() -> reqwest::Client { - reqwest::Client::new() +const WS: &str = "test-workspace"; + +/// A reqwest client that sends `Authorization: Bearer `. The base +/// fixture seeds: SECRET_TOKEN (test-user, admin), SECRET_TOKEN_2 +/// (test-user-2, non-admin), SECRET_TOKEN_3 (test-user-3, non-admin). +fn client_for(token: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() } -fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - builder.header("Authorization", "Bearer SECRET_TOKEN") +fn save_url(port: u16, kind: &str, path: &str) -> String { + format!("http://localhost:{port}/api/w/{WS}/drafts/update/{kind}/{path}") } +async fn draft_count(db: &Pool, path: &str, kind: &str, email: &str) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = $3::text::DRAFT_KIND AND email = $4", + ) + .bind(WS) + .bind(path) + .bind(kind) + .bind(email) + .fetch_one(db) + .await + .unwrap() +} + +/// Upsert → conflict (stale last_sync) → force-overwrite → delete, the +/// optimistic-concurrency contract `update_draft` exists to enforce. #[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_draft_endpoints(db: Pool) -> anyhow::Result<()> { +async fn test_update_draft_conflict_lifecycle(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); - let base = format!("http://localhost:{port}/api/w/test-workspace/drafts"); + let c = client_for("SECRET_TOKEN"); + let path = "u/test-user/draft_x"; + let url = save_url(port, "script", path); - // create a script first so the draft has a valid path - let resp = authed(client().post(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/create" - ))) - .json(&json!({ - "path": "u/test-user/draft_script", - "summary": "Script for draft test", - "description": "", - "content": "export async function main() { return 1; }", - "language": "deno", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": {}, - "required": [] - } - })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); - - // --- create draft --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 2; }", - "language": "deno" - } - })) + // First save: no last_sync ("treat as fresh") → saved. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 1 } })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create draft: {}", resp.text().await?); + .await?; + assert_eq!(r.status(), 200, "first save"); + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved"); + let ts1 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 1 + ); - // verify draft exists via script get/draft endpoint - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_object(), "expected draft to be present"); + // A tiny gap so the next now() is strictly greater than ts1. + tokio::time::sleep(std::time::Duration::from_millis(15)).await; - // --- update draft (create with same path overwrites) --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 3; }", - "language": "deno" - } - })) + // Save with the matching last_sync → not stale → saved, newer ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 2 }, "last_sync": ts1 })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201); + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved", "in-order save"); + let ts2 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_ne!(ts1, ts2, "timestamp should advance"); - // --- delete draft --- - let resp = authed(client().delete(format!( - "{base}/delete/script/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); + // Save with the now-stale ts1 → conflict, server reports its current ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1 })) + .send() + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "conflict", "stale save must conflict"); + assert_eq!(body["current_timestamp"].as_str().unwrap(), ts2); - // verify draft is gone - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_null(), "expected draft to be deleted"); + // The conflicting write must NOT have landed — value is still {n:2}. + let stored: serde_json::Value = sqlx::query_scalar::<_, sqlx::types::Json>( + "SELECT value FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = 'script' AND email = 'test@windmill.dev'", + ) + .bind(WS) + .bind(path) + .fetch_one(&db) + .await? + .0; + assert_eq!(stored["n"], 2, "conflicting write must be rejected"); + + // force = true overrides the conflict check. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1, "force": true })) + .send() + .await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "force" + ); + + // Delete (value: null) → saved, row gone. + let r = c.post(&url).json(&json!({ "value": null })).send().await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "delete" + ); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 0, + "row removed after delete" + ); + + Ok(()) +} + +/// require_can_write_path: own namespace allowed, another user's namespace +/// rejected, operators rejected outright. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let user2 = client_for("SECRET_TOKEN_2"); // test-user-2, non-admin + + // Own namespace → allowed. + let r = user2 + .post(save_url(port, "script", "u/test-user-2/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 200, "own namespace allowed"); + + // Another user's namespace, no grant → rejected. + let r = user2 + .post(save_url(port, "script", "u/test-user/theirs")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "other user's namespace rejected"); + + // Operators can't save drafts at all. + sqlx::query( + "UPDATE usr SET operator = true WHERE workspace_id = $1 AND username = 'test-user-3'", + ) + .bind(WS) + .execute(&db) + .await?; + let op = client_for("SECRET_TOKEN_3"); + let r = op + .post(save_url(port, "script", "u/test-user-3/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "operator rejected"); + + Ok(()) +} + +/// The item-level extra_perms fallback: a user granted write on a deployed +/// item (via the Share dialog) can save a draft on it even though it's +/// outside their namespace. Regression test for the authz drop. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_extra_perms_writer(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let path = "u/test-user/shared"; + + // A deployed script owned by test-user, shared with test-user-2 (write). + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 1, $2, '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": true}'::jsonb, 'test-user')", + ) + .bind(WS) + .bind(path) + .execute(&db) + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + let r = user2 + .post(save_url(port, "script", path)) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!( + r.status(), + 200, + "extra_perms writer can save a draft: {}", + r.text().await? + ); + + // Without a grant on a different shared item → still rejected. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 2, 'u/test-user/private', '', '', 'x', 'deno', '{}'::jsonb, \ + '{}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/private")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "no grant → rejected"); + + // A READ-ONLY grant (`extra_perms` value false) must not allow draft + // saves: the write check defers to RLS via `SELECT ... FOR UPDATE`, + // and locking applies the UPDATE policies — visibility under the + // SELECT policy alone isn't enough. Pins the FOR UPDATE semantics the + // probe relies on. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 3, 'u/test-user/readonly', '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": false}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/readonly")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "read-only grant → rejected"); + + Ok(()) +} + +/// Cross-user draft viewing (`GET /drafts/get/{kind}/{path}`) is disabled +/// for the drawer kinds (resource/variable/triggers) so a viewer can't +/// read another user's draft; it stays available for script/flow/app. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_cross_user_draft_privacy(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // test-user saves a variable draft and a script draft in a shared folder. + let admin = client_for("SECRET_TOKEN"); + admin + .post(save_url(port, "variable", "f/shared/v")) + .json(&json!({ "value": { "variable": { "value": "x", "is_secret": false } } })) + .send() + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + // Variable is a drawer kind → cross-user view is forbidden regardless of + // path access (the kind gate fires first). + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/variable/f/shared/v?username=test-user" + )) + .send() + .await?; + assert_eq!( + r.status(), + 404, + "variable drafts are private to their owner" + ); + + // Sharing kinds (script) are NOT gated by the kind check — a missing + // draft / no access yields 404 too, but the "private to their owner" + // wording is specific to the drawer kinds, so assert it's absent here. + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/script/f/shared/s?username=test-user" + )) + .send() + .await?; + let body = r.text().await?; + assert!( + !body.contains("private to their owner"), + "script kind must not be blocked by the cross-user privacy gate: {body}" + ); Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/flows.rs b/backend/windmill-api-integration-tests/tests/flows.rs index b6075c8e69..774c118e7b 100644 --- a/backend/windmill-api-integration-tests/tests/flows.rs +++ b/backend/windmill-api-integration-tests/tests/flows.rs @@ -82,12 +82,6 @@ async fn test_flow_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_flow").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_flow"); - // --- list --- let resp = authed(client().get(format!("{base}/list"))) .send() diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index c374b757a4..e5f6cb8aa8 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -98,12 +98,6 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { let body = resp.json::().await?; assert_eq!(body["path"], "u/test-user/test_script"); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_script").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_script"); - // --- raw by path (requires language extension) --- let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index dccc4048e4..a0f7f7d460 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -92,8 +92,8 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re // Create app sqlx::query!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only) - VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}', false)" + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms) + VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')" ) .execute(&db) .await?; @@ -634,6 +634,69 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re "Non-existent item should be deleted from workspace_diff" ); + // ============================================================== + // Stale Archived Cache Test (regression) + // ============================================================== + // + // Unlike the lazy_test above (has_changes = NULL → always re-evaluated), a + // cached `has_changes = true` row is trusted without re-running the per-kind + // comparison. It can go stale: after a rename the old path keeps only + // archived versions, and for lock-gen languages the `has_changes = NULL` + // reset is deferred to the dependency job — so until that runs the archived + // old path lingers as a live "ahead" change carrying `exists_in_fork = true`. + // The visibility check treats archived as non-existent and finds nothing, so + // even this superadmin used to get `all_ahead_items_visible = false`. The fix + // re-validates such rows and drops the archived (== non-existent) item. + sqlx::query!( + "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted) + VALUES ('wm-fork-test-workspace', 'f/shared/renamed_away', 67890, 'def main(): return 1', '', '', 'python3', 'test@windmill.dev', NOW(), true, false, false, false)" + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork) + VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/renamed_away', 'script', 1, 0, true, false, true)" + ) + .execute(&db) + .await?; + + let comparison3: serde_json::Value = client + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace" + )) + .send() + .await? + .json() + .await?; + + // The archived item must be dropped (not surfaced) and must not trip the + // "changes not visible to your user" warning for a superadmin. + assert_eq!( + comparison3["all_ahead_items_visible"].as_bool(), + Some(true), + "archived (renamed-away) item must not trip the 'changes not visible' warning: {comparison3}" + ); + assert!( + !comparison3["diffs"] + .as_array() + .unwrap() + .iter() + .any(|d| d["path"] == "f/shared/renamed_away"), + "archived item should be dropped, not surfaced as a diff: {comparison3}" + ); + let stale_archived = sqlx::query!( + "SELECT has_changes FROM workspace_diff + WHERE path = 'f/shared/renamed_away' AND kind = 'script' AND source_workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert!( + stale_archived.is_none(), + "stale archived diff row should be re-evaluated and deleted" + ); + Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 3d208d4e25..fcbdeea8ba 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -646,6 +646,68 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .unwrap(); assert_eq!(resp.json::().await?, true); + // --- create_fork over an existing (active) workspace id: clear 400, not a raw SQL 500 --- + let resp = authed(client().post(format!("{new_ws_base}/create_fork"))) + .json(&json!({ + "id": "wm-fork-renamed", + "name": "Conflicting Fork" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400, "create_fork over active workspace"); + let body = resp.text().await?; + assert!( + body.contains("already exists"), + "create_fork conflict body: {body}" + ); + + // --- create_fork over an archived workspace id: error must mention it is archived --- + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/wm-fork-renamed/workspaces/archive" + ))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "archive fork: {}", resp.text().await?); + + let resp = authed(client().post(format!("{new_ws_base}/create_fork"))) + .json(&json!({ + "id": "wm-fork-renamed", + "name": "Conflicting Fork" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400, "create_fork over archived workspace"); + let body = resp.text().await?; + assert!( + body.contains("archived"), + "create_fork archived-conflict body: {body}" + ); + + // --- hard delete frees up the id for a new fork --- + let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "delete fork: {}", resp.text().await?); + + let resp = authed(client().post(format!("{new_ws_base}/create_fork"))) + .json(&json!({ + "id": "wm-fork-renamed", + "name": "Recreated Fork" + })) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 200, + "create_fork after hard delete: {}", + resp.text().await? + ); + // clean up renamed fork let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed"))) .send() diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index 5de2c51795..d8f1826948 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use windmill_common::{ db::UserDB, error, - jobs::{JobKind, JobStatus, JobTriggerKind}, + jobs::{is_safe_log_file_path, JobKind, JobStatus, JobTriggerKind}, scripts::ScriptLang, utils::{paginate, paginate_without_limits, require_admin, Pagination}, }; @@ -328,6 +328,20 @@ pub async fn import_completed_jobs( ) -> error::Result { require_admin(authed.is_admin, &authed.username)?; + // log_file_index is read back by the log endpoints as paths under the windmill + // log directory; an attacker-supplied traversal here would become an arbitrary + // file read. Reject anything that could escape the log directory at ingestion. + for job in &jobs { + if let Some(file_index) = &job.log_file_index { + if file_index.iter().any(|p| !is_safe_log_file_path(p)) { + return Err(error::Error::BadRequest(format!( + "Invalid log_file_index for job {}: entries must be relative paths without '..'", + job.id + ))); + } + } + } + let mut tx = user_db.begin(&authed).await?; for job in jobs { diff --git a/backend/windmill-api-openapi/Cargo.toml b/backend/windmill-api-openapi/Cargo.toml index ee1727c059..24a6f2aed8 100644 --- a/backend/windmill-api-openapi/Cargo.toml +++ b/backend/windmill-api-openapi/Cargo.toml @@ -11,7 +11,12 @@ path = "src/lib.rs" [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } -windmill-store.workspace = true +# `try_get_resource_from_db_as` is used unconditionally below but is +# cfg-gated behind a trigger feature in windmill-store; forward +# `http_trigger` so the import resolves even when this crate's targets are +# built in isolation (e.g. `--all-targets` under resolver 2), not only via +# whole-workspace feature unification. +windmill-store = { workspace = true, features = ["http_trigger"] } windmill-trigger-http.workspace = true anyhow.workspace = true axum.workspace = true diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index e2d495eaad..7cc0111c28 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -25,6 +25,10 @@ use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, schedule::Schedule, + user_drafts::{ + delete_all_drafts_for_path, fetch_draft_only_list_rows, overlay_or_draft_only, + UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{ escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath, }, @@ -678,6 +682,9 @@ pub struct ListScheduleQuery { pub summary: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)] @@ -695,10 +702,24 @@ pub struct ScheduleLight { pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the schedules page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, + /// Labels inherited from the parent folder, computed at read time. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, } async fn list_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lsq): Query, ) -> JsonResult> { @@ -718,14 +739,22 @@ async fn list_schedule( "summary", "extra_perms", "labels", + "folder_labels(workspace_id, path) as inherited_labels", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = schedule.workspace_id \ + AND draft.path = schedule.path AND draft.typ = 'trigger_schedule' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&w_id)) .offset(offset) .limit(per_page) .clone(); - if let Some(path) = lsq.path { - sqlb.and_where_eq("script_path", "?".bind(&path)); + if let Some(path) = lsq.path.as_ref() { + sqlb.and_where_eq("script_path", "?".bind(path)); } if let Some(is_flow) = lsq.is_flow { sqlb.and_where_eq("is_flow", "?".bind(&is_flow)); @@ -760,14 +789,104 @@ async fn list_schedule( } if let Some(label) = &lsq.label { for l in label.split(',') { - sqlb.and_where("labels @> ARRAY[?]".bind(&l.trim())); + sqlb.and_where( + "(labels @> ARRAY[?] OR folder_labels(workspace_id, path) @> ARRAY[?])" + .bind(&l.trim()) + .bind(&l.trim()), + ); } } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; - let rows = sqlx::query_as::<_, ScheduleLight>(&sql) + let mut rows = sqlx::query_as::<_, ScheduleLight>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + + // Append the authed user's draft-only schedules; see scripts.rs. + if lsq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lsq.path.is_none() + && lsq.is_flow.is_none() + && lsq.args.is_none() + && lsq.path_start.is_none() + && lsq.schedule_path.is_none() + && lsq.description.is_none() + && lsq.summary.is_none() + && lsq.broad_filter.is_none() + && lsq.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + ) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // Schedule editor's draft mirrors NewSchedule: { path, schedule, timezone, script_path, is_flow, enabled?, summary?, labels? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() { + continue; + } + let schedule = v + .get("schedule") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let timezone = v + .get("timezone") + .and_then(|x| x.as_str()) + .unwrap_or("UTC") + .to_string(); + let script_path = v + .get("script_path") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let is_flow = v.get("is_flow").and_then(|x| x.as_bool()).unwrap_or(false); + let enabled = v.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + let summary = v + .get("summary") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + + rows.push(ScheduleLight { + workspace_id: w_id.clone(), + path, + edited_by: String::new(), + edited_at: row.created_at, + schedule, + timezone, + enabled, + script_path, + is_flow, + summary, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -830,16 +949,28 @@ async fn list_schedule_with_jobs( async fn get_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("schedules:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let schedule_o = windmill_queue::schedule::get_schedule_opt(&mut *tx, &w_id, path).await?; - let schedule = not_found_if_none(schedule_o, "Schedule", path)?; tx.commit().await?; - Ok(Json(schedule)) + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + path, + q.get_draft, + schedule_o, + || Error::NotFound(format!("Schedule not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn exists_schedule( @@ -1128,6 +1259,9 @@ async fn delete_schedule( tx.commit().await?; + // Schedule gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::TriggerSchedule, path).await?; + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 281e71ae46..9e12faaef5 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -13,6 +13,7 @@ use windmill_api_auth::{ ApiAuthed, }; use windmill_common::{ + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, @@ -33,7 +34,6 @@ use itertools::Itertools; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; use serde_json::json; -use serde_json::value::RawValue; use sql_builder::prelude::*; use sqlx::{FromRow, Postgres, Transaction}; use std::{collections::HashMap, sync::Arc}; @@ -46,8 +46,7 @@ use windmill_common::{ assets::{ clear_script_triggers, clear_static_asset_usage, clear_static_asset_usage_by_script_hash, insert_script_trigger, insert_static_asset_usage, parse_duration_secs, - parse_pipeline_annotations, trigger_spec_to_row, AssetUsageKind, AssetWithAltAccessType, - TriggerSpec, + parse_pipeline_annotations, trigger_spec_to_row, AssetUsageKind, TriggerSpec, }, error::{self, to_anyhow}, min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2}, @@ -84,122 +83,6 @@ use windmill_queue::{ const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20; -#[derive(Serialize, sqlx::FromRow)] -pub struct ScriptWDraft { - pub hash: ScriptHash, - pub path: String, - pub summary: String, - pub description: String, - pub content: String, - pub language: ScriptLang, - pub kind: ScriptKind, - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - pub schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub envs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ttl: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ignore_s3_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub restart_unless_cancelled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_use: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_secs: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_preprocessor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub assets: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub modules: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, - #[serde(flatten)] - #[sqlx(flatten)] - pub runnable_settings: SR, -} - -impl ScriptWDraft { - pub async fn prefetch_cached<'a>( - self, - db: &DB, - ) -> error::Result> { - let (debouncing_settings, concurrency_settings) = - windmill_common::runnable_settings::prefetch_cached_from_handle( - self.runnable_settings.runnable_settings_handle, - db, - ) - .await?; - - Ok(ScriptWDraft { - runnable_settings: ScriptRunnableSettingsInline { - concurrency_settings: concurrency_settings.maybe_fallback( - self.runnable_settings.concurrency_key, - self.runnable_settings.concurrent_limit, - self.runnable_settings.concurrency_time_window_s, - ), - debouncing_settings: debouncing_settings.maybe_fallback( - self.runnable_settings.debounce_key, - self.runnable_settings.debounce_delay_s, - ), - }, - hash: self.hash, - path: self.path, - summary: self.summary, - description: self.description, - content: self.content, - language: self.language, - kind: self.kind, - tag: self.tag, - draft: self.draft, - draft_created_at: self.draft_created_at, - schema: self.schema, - draft_only: self.draft_only, - envs: self.envs, - cache_ttl: self.cache_ttl, - cache_ignore_s3_path: self.cache_ignore_s3_path, - dedicated_worker: self.dedicated_worker, - ws_error_handler_muted: self.ws_error_handler_muted, - priority: self.priority, - restart_unless_cancelled: self.restart_unless_cancelled, - delete_after_use: self.delete_after_use, - delete_after_secs: self.delete_after_secs, - timeout: self.timeout, - visible_to_runner_only: self.visible_to_runner_only, - auto_kind: self.auto_kind, - has_preprocessor: self.has_preprocessor, - on_behalf_of_email: self.on_behalf_of_email, - assets: self.assets, - modules: self.modules, - labels: self.labels, - }) - } -} - pub fn global_service() -> Router { Router::new() .route("/hub/top", get(get_top_hub_scripts)) @@ -224,7 +107,6 @@ pub fn workspaced_service() -> Router { .route("/create", post(create_script)) .route("/create_snapshot", post(create_snapshot_script)) .route("/archive/p/{*path}", post(archive_script_by_path)) - .route("/get/draft/{*path}", get(get_script_by_path_w_draft)) .route("/get/p/{*path}", get(get_script_by_path)) .route("/list_tokens/{*path}", get(list_tokens)) .route("/raw/p/{*path}", get(raw_script_by_path)) @@ -297,6 +179,7 @@ async fn list_search_scripts( async fn list_scripts( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -307,7 +190,7 @@ async fn list_scripts( "hash", "o.path", "summary", - "COALESCE(draft.created_at, o.created_at) as created_at", + "o.created_at as created_at", "archived", "extra_perms", if !lq.without_description.unwrap_or(false) { @@ -319,13 +202,23 @@ async fn list_scripts( "language", "favorite.path IS NOT NULL as starred", "tag", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", "auto_kind", "codebase IS NOT NULL as use_codebase", "kind", - "o.labels" + "o.labels", + "draft.email IS NOT NULL as is_draft", + // Canonical reference for the draft-feature comments; flows/apps point here. + // Per-path draft owners as a JSON array (`Json>`); NULL -> None, + // never an empty array. LEFT JOIN `usr` keeps orphaned drafts (user left workspace) + // visible with `username = None`. In the `admins` workspace username IS the email, + // so fall back to `d.email` there or the authed user's own draft resolves to a phantom + // "Legacy workspace draft"; the genuine NULL-email legacy row stays None. + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users", + "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() .join("favorite") @@ -336,7 +229,8 @@ async fn list_scripts( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?" + .bind(&authed.email), ) .order_desc("favorite.path IS NOT NULL") .order_by("created_at", lq.order_desc.unwrap_or(true)) @@ -362,10 +256,6 @@ async fn list_scripts( sqlb.and_where("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("draft_only IS NOT TRUE"); - } - if lq.show_archived.unwrap_or(false) { sqlb.and_where_eq( "o.ctid", @@ -406,7 +296,11 @@ async fn list_scripts( } if let Some(label) = &lq.label { for l in label.split(',') { - sqlb.and_where("o.labels @> ARRAY[?]".bind(&l.trim())); + sqlb.and_where( + "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])" + .bind(&l.trim()) + .bind(&l.trim()), + ); } } if authed.is_operator { @@ -431,7 +325,7 @@ async fn list_scripts( .fields(&["dm.deployment_msg"]); } - if let Some(languages) = lq.languages { + if let Some(languages) = &lq.languages { sqlb.and_where_in( "language", &languages @@ -444,13 +338,114 @@ async fn list_scripts( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "scripts", "read"); - let rows = sqlx::query_as::<_, ListableScript>(&sql) + let mut rows = sqlx::query_as::<_, ListableScript>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Canonical reference for draft-only synthesis; the other kinds point here. + // Append the authed user's drafts at paths with no deployed script. Gated on + // `include_draft_only` so picker callers stay deployed-only (home page opts in); + // skipped past page 0 or under any narrowing filter to keep pagination clean. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.created_by.is_none() + && lq.first_parent_hash.is_none() + && lq.last_parent_hash.is_none() + && lq.parent_hash.is_none() + && lq.is_template.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && lq.languages.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` surfaces the user's own draft-only rows plus + // legacy NULL-email workspace rows; `DISTINCT ON (path)` ordered `email IS NULL` + // last collapses a path holding both to the owned row. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'script' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM script s + WHERE s.workspace_id = draft.workspace_id + AND s.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + let language: ScriptLang = v + .get("language") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or_default(); + let kind: ScriptKind = v + .get("kind") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or(ScriptKind::Script); + // Scripts bind the Path widget to `script.path`, so the typed path + // round-trips through the draft JSON's own `path` (no `draft_path` field). + let draft_path = v + .get("path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableScript { + hash: ScriptHash(0), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + created_at: row.created_at, + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + language, + starred: false, + tag: v.get("tag").and_then(|s| s.as_str()).map(|s| s.to_string()), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + draft_only: Some(true), + has_deploy_errors: false, + ws_error_handler_muted: None, + auto_kind: None, + use_codebase: false, + deployment_msg: None, + kind, + labels: None, + // Synthesized rows have no deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft (single-user case). + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -718,7 +713,6 @@ async fn is_noop_deploy_against_parent( language, kind, tag, - draft_only, envs, concurrency_settings, debouncing_settings, @@ -795,7 +789,6 @@ async fn is_noop_deploy_against_parent( || visible_to_runner_only != &parent.visible_to_runner_only || has_preprocessor != &parent.has_preprocessor || is_template.unwrap_or(false) != parent.is_template.unwrap_or(false) - || draft_only.unwrap_or(false) != parent.draft_only.unwrap_or(false) { return Ok(false); } @@ -1023,20 +1016,10 @@ async fn create_script_internal<'c>( let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { (None, None) => Ok(None), - (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( + (None, Some(s)) => Err(Error::BadRequest(format!( "Path conflict for {} with non-archived hash {}", &ns.path, &s.hash ))), - (None, Some(s)) => { - sqlx::query!( - "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", - s.hash.0, - &w_id - ) - .execute(&mut *tx) - .await?; - Ok(None) - } (Some(p_hash), o) => { // Lock the parent row to prevent concurrent updates with the same parent_hash // This ensures linear lineage - only one script can have a given parent at a time @@ -1335,10 +1318,10 @@ async fn create_script_internal<'c>( sqlx::query!( "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \ content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", &w_id, &hash.0, ns.path, @@ -1354,7 +1337,6 @@ async fn create_script_internal<'c>( lang as ScriptLang, ns.kind.unwrap_or(ScriptKind::Script) as ScriptKind, ns.tag, - ns.draft_only, envs, guarded_concurrent_limit, guarded_concurrency_time_window_s, @@ -1421,10 +1403,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { if !skip_draft_deletion { + // Canonical: on deploy only wipe the deployer's own draft (plus the legacy + // NULL-email row). Teammates' drafts are independent — they stay and fire the + // StaleDraftModal on the teammate's next reload rather than vanishing silently. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", p_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1508,10 +1495,14 @@ async fn create_script_internal<'c>( } } } else if !skip_draft_deletion { + // See the matching branch above — only wipe the deployer's own + // draft (plus the legacy NULL-email row). sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", ns.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1859,91 +1850,93 @@ pub async fn pick_hub_script_by_path( Ok::<_, Error>((status_code, headers, response)) } +// Canonical: fields inlined rather than `#[serde(flatten)]` from +// `WithStarredInfoQuery` / `WithDraftQuery`. axum's `serde_urlencoded` extractor +// drops type info through flatten, so `?get_draft=true` arrives as a String and +// fails the inner bool deserializer. Inlining lets the bool adapter see it directly. +#[derive(Deserialize)] +struct GetScriptByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + #[axum::debug_handler] async fn get_script_by_path( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult> { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("scripts:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let script_o = if query.with_starred_info.unwrap_or(false) { sqlx::query_as::<_, ScriptWithStarred>( - "SELECT s.*, favorite.path IS NOT NULL as starred + "SELECT s.*, favorite.path IS NOT NULL as starred, + folder_labels(s.workspace_id, s.path) as inherited_labels FROM script s LEFT JOIN favorite - ON favorite.favorite_kind = 'script' - AND favorite.workspace_id = s.workspace_id - AND favorite.path = s.path + ON favorite.favorite_kind = 'script' + AND favorite.workspace_id = s.workspace_id + AND favorite.path = s.path AND favorite.usr = $3 WHERE s.path = $1 AND s.workspace_id = $2 ORDER BY s.created_at DESC LIMIT 1", ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? } else { sqlx::query_as::<_, ScriptWithStarred>( &format!( - "SELECT {}, NULL as starred FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + "SELECT {}, NULL as starred, folder_labels(workspace_id, path) as inherited_labels FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", windmill_common::scripts::SCRIPT_COLUMNS, ), ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let script = windmill_common::scripts::prefetch_cached_script_with_starred( - not_found_if_none(script_o, "Script", path)?, + // Canonical: with no deployed row and `get_draft` set, fall back to the draft + // table so editing a never-deployed draft works like a deployed reload. + let deployed = match script_o { + Some(script_o) => Some( + windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db).await?, + ), + None => None, + }; + let overlay = overlay_or_draft_only( &db, + &w_id, + &authed.email, + UserDraftItemKind::Script, + path, + query.get_draft, + deployed, + || windmill_common::error::Error::NotFound(format!("Script not found at path {path}")), ) .await?; - Ok(Json(script)) + Ok(Json(overlay)) } async fn list_tokens( + authed: ApiAuthed, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> JsonResult> { - let path = path.to_path(); - list_tokens_internal(&db, &w_id, &path, false).await -} - -async fn get_script_by_path_w_draft( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON - script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' - WHERE script.path = $1 AND script.workspace_id = $2 - ORDER BY script.created_at DESC LIMIT 1", - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - - let script = not_found_if_none(script_o, "Script", path)?; - Ok(Json(script.prefetch_cached(&db).await?)) + list_tokens_internal(&db, &w_id, &path, false).await } async fn get_script_history( @@ -2488,13 +2481,14 @@ async fn get_script_by_hash_internal<'c>( ) -> Result> { let script_o = if let Some(username) = with_starred_info_for_username { sqlx::query_as::<_, ScriptWithStarred>( - "SELECT s.*, favorite.path IS NOT NULL as starred + "SELECT s.*, favorite.path IS NOT NULL as starred, + folder_labels(s.workspace_id, s.path) as inherited_labels FROM script s - LEFT JOIN favorite - ON favorite.favorite_kind = 'script' - AND favorite.workspace_id = s.workspace_id - AND favorite.path = s.path - AND favorite.usr = $1 + LEFT JOIN favorite + ON favorite.favorite_kind = 'script' + AND favorite.workspace_id = s.workspace_id + AND favorite.path = s.path + AND favorite.usr = $1 WHERE s.hash = $2 AND s.workspace_id = $3", ) .bind(&username) @@ -2504,7 +2498,7 @@ async fn get_script_by_hash_internal<'c>( .await? } else { sqlx::query_as::<_, ScriptWithStarred>(&format!( - "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", + "SELECT {}, NULL as starred, folder_labels(workspace_id, path) as inherited_labels FROM script WHERE hash = $1 AND workspace_id = $2", windmill_common::scripts::SCRIPT_COLUMNS, )) .bind(hash) @@ -2605,18 +2599,6 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", - "script", - ) - .await; -} - async fn archive_script_by_path( authed: ApiAuthed, Extension(webhook): Extension, @@ -2864,18 +2846,7 @@ async fn delete_script_by_path( let mut tx = user_db.begin(&authed).await?; - let draft_only = sqlx::query_scalar!( - "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", - path, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - if !draft_only { - require_admin(authed.is_admin, &authed.username)?; - } + require_admin(authed.is_admin, &authed.username)?; // Capture all script versions and drafts for trashbin before deleting let trash_scripts: Vec = sqlx::query_scalar( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 00cebc32ff..265a74d1ed 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3640,6 +3640,27 @@ pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: & return Ok(()); } +/// Reject fork creation when the target workspace id is already taken, +/// distinguishing archived workspaces: archiving is a soft delete that keeps +/// the id reserved, which users frequently mistake for a permanent delete. +async fn check_fork_w_id_conflict(db: &DB, w_id: &str) -> Result<()> { + let deleted = sqlx::query_scalar!("SELECT deleted FROM workspace WHERE id = $1", w_id) + .fetch_optional(db) + .await?; + match deleted { + Some(true) => Err(Error::BadRequest(format!( + "Workspace '{w_id}' already exists but is archived (archiving does not free up the workspace id). \ + To reuse this id, permanently delete the archived workspace first — its owner or a superadmin \ + can do so from the fork creation dialog, the superadmin workspaces page, or the CLI \ + (`wmill workspace delete-fork`) — or choose a different fork id." + ))), + Some(false) => Err(Error::BadRequest(format!( + "Workspace '{w_id}' already exists. Delete the existing fork first or choose a different fork id." + ))), + None => Ok(()), + } +} + lazy_static::lazy_static! { pub static ref CREATE_WORKSPACE_REQUIRE_SUPERADMIN: bool = { @@ -3823,10 +3844,15 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } +// `authed_email` is the forker's email — `clone_drafts` only carries this +// user's per-user drafts (and the legacy NULL-email workspace draft, if any) +// across, since other users aren't added to the fork's `usr` table and +// their drafts would dangle as orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed_email: &str, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -3867,6 +3893,14 @@ async fn clone_workspace_data( // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + // Clone the forker's own per-user drafts (plus the legacy NULL-email + // workspace draft, if any) so they keep their pending edits in the + // fork. Other users' drafts are intentionally NOT cloned — they don't + // own a `usr` row in the fork (see `clone_workspace_full`) so their + // drafts would dangle and the home-page `draft_users` aggregate would + // surface them as duplicate legacy entries. + clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; + // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -4233,8 +4267,8 @@ async fn clone_folders( target_workspace_id: &str, ) -> Result<()> { sqlx::query!( - "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as) - SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels) + SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels FROM folder WHERE workspace_id = $1", source_workspace_id, @@ -4343,7 +4377,7 @@ async fn clone_scripts( r#"INSERT INTO script ( workspace_id, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4353,7 +4387,7 @@ async fn clone_scripts( SELECT $1, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4396,12 +4430,12 @@ async fn clone_flows( sqlx::query!( "INSERT INTO flow ( workspace_id, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, dependency_job, draft_only, tag, + archived, schema, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, versions, on_behalf_of_email, lock_error_logs ) SELECT $2, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, NULL, draft_only, tag, + archived, schema, extra_perms, NULL, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs FROM flow @@ -4482,7 +4516,7 @@ async fn clone_apps( ) -> Result> { // Get all apps from source workspace let apps = sqlx::query!( - "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path + "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path FROM app WHERE workspace_id = $1", source_workspace_id @@ -4495,8 +4529,8 @@ async fn clone_apps( // Clone apps with new IDs for app in apps { let new_app_id = sqlx::query_scalar!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", target_workspace_id, app.path, @@ -4504,7 +4538,6 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - app.draft_only, app.custom_path, ) .fetch_one(&mut **tx) @@ -4708,6 +4741,39 @@ async fn clone_raw_apps( Ok(()) } +/// Clone every per-user draft (and the legacy NULL-email workspace draft, +/// if present) from the parent. The fork target is empty at create time so +/// a plain INSERT is safe — no need to UPSERT against the partial unique +/// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the +/// BIGSERIAL synthetic PK and is regenerated by the default; we don't list +/// it in the column set. `created_at` is preserved so the per-tab +/// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's +/// `draft_saved_at`) lines up with the parent's timeline — otherwise the +/// fork's first POST from any open editor would race a stale `last_sync` +/// and trip the conflict modal on every cloned draft. +// Only `email = authed_email` and the legacy NULL row are cloned — see +// `clone_workspace_data` for the rationale. +async fn clone_drafts( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + authed_email: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + SELECT $2, path, typ, value, created_at, email + FROM draft + WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + source_workspace_id, + target_workspace_id, + authed_email, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + async fn clone_workspace_runnable_dependencies( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -4795,6 +4861,10 @@ async fn create_workspace_fork_branch( validate_fork_workspace_id(&nw.id)?; + // Fail before creating any git branch so a name conflict doesn't leave a + // dangling branch on the synced repos. + check_fork_w_id_conflict(&db, &nw.id).await?; + Ok(Json( handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?, )) @@ -4933,6 +5003,12 @@ async fn create_workspace_fork( ))); } + validate_fork_workspace_id(&nw.id)?; + // Check the id conflict before the CE workspace-count limit so that + // re-using a taken (possibly archived) fork id reports the actual + // conflict instead of a misleading "maximum number of workspaces" error. + check_fork_w_id_conflict(&db, &nw.id).await?; + #[cfg(not(feature = "enterprise"))] _check_nb_of_workspaces(&db).await?; @@ -4954,8 +5030,6 @@ async fn create_workspace_fork( let mut tx: Transaction<'_, Postgres> = db.begin().await?; - validate_fork_workspace_id(&nw.id)?; - let forked_id = nw.id; sqlx::query!( @@ -4994,7 +5068,7 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; + clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id, &authed.email).await?; // Clone triggers and schedules unconditionally, always with mode='disabled' / // enabled=false. Disabled rows have no side effects (no listener @@ -5164,6 +5238,18 @@ async fn archive_workspace( ActionKind::Update, &w_id, Some(&authed.email), + Some(audit_params_refs.clone()), + ) + .await?; + // Also record under the instance-level "admins" workspace so superadmins can + // discover who archived a workspace after it becomes hidden from the UI. + audit_log( + &mut *tx, + &authed, + "workspaces.archive", + ActionKind::Update, + "admins", + Some(&w_id), Some(audit_params_refs), ) .await?; @@ -5225,6 +5311,18 @@ async fn unarchive_workspace( None, ) .await?; + // Also record under the instance-level "admins" workspace so superadmins keep + // a durable trail of who unarchived a workspace. + audit_log( + &mut *tx, + &authed, + "workspaces.unarchive", + ActionKind::Update, + "admins", + Some(&w_id), + None, + ) + .await?; tx.commit().await?; Ok(format!("Unarchived workspace {}", &w_id)) @@ -6290,13 +6388,67 @@ async fn compare_workspaces( .fetch_all(&db) .await?; + // A cached `has_changes = true` row is trusted without re-running the + // per-kind comparison, but that verdict can go stale: an item archived or + // deleted after it was cached still carries `exists_in_*=true` here. The + // common offender is the old path after a rename — for lock-gen languages + // (Python/TS/…) the `has_changes=NULL` reset is deferred to the dependency + // job, so until that runs (or if it fails) the archived old path looks like + // a live ahead change. Treat archived as non-existent: re-validate such rows + // against the live tables and, if the item no longer exists on a side the + // cache claims, re-evaluate it below so it gets corrected or removed. + // + // Only scripts/flows can hit this (they have `archived`; other kinds reset + // synchronously on delete). Probe both sides in one batched query per kind + // (mirroring `query_visible_items`) rather than per row, to keep the hot + // compare path off an O(number of cached diffs) sequence of round trips. + let (live_source, live_fork) = { + let mut cached_source: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut cached_fork: HashMap<&str, Vec<&str>> = HashMap::new(); + for item in &diff_items { + if item.has_changes == Some(true) && (item.kind == "script" || item.kind == "flow") { + if item.exists_in_source.unwrap_or(false) { + cached_source + .entry(item.kind.as_str()) + .or_default() + .push(item.path.as_str()); + } + if item.exists_in_fork.unwrap_or(false) { + cached_fork + .entry(item.kind.as_str()) + .or_default() + .push(item.path.as_str()); + } + } + } + ( + existing_runnables(&db, &source_workspace_id, &cached_source).await?, + existing_runnables(&db, &fork_workspace_id, &cached_fork).await?, + ) + }; + let mut confirmed_diffs = vec![]; for item in diff_items { if let Some(has_changes) = item.has_changes { - if has_changes { - confirmed_diffs.push(item); + if !has_changes { + // Defensive: rows that compared equal are normally deleted, so + // this is rarely hit. Not a diff — skip. + continue; } - continue; + // Stale only applies to script/flow (others aren't in the probed + // sets); a row whose claimed-existing side has no live version is + // stale and falls through to re-evaluation. + let key = (item.kind.clone(), item.path.clone()); + let fork_stale = item.exists_in_fork.unwrap_or(false) && !live_fork.contains(&key); + let source_stale = + item.exists_in_source.unwrap_or(false) && !live_source.contains(&key); + let probed = item.kind == "script" || item.kind == "flow"; + if !(probed && (fork_stale || source_stale)) { + // Cache is still valid (or not a probed kind) — trust it. + confirmed_diffs.push(item); + continue; + } + // Stale cache: fall through to re-evaluate (and correct/delete) below. } let item_comparison = match item.kind.as_str() { @@ -6721,6 +6873,50 @@ async fn query_visible_items<'c>( Ok(visible) } +/// Batched existence probe used to detect stale `workspace_diff` cache rows. +/// +/// Given candidate paths grouped by kind, returns the set of `(kind, path)` +/// that currently have a *deployable* (non-archived) version in the workspace, +/// mirroring the existence semantics of `compare_two_scripts` / +/// `compare_two_flows`. Only scripts and flows are probed — they're the only +/// kinds with `archived`, and the only ones whose diff-row reset can lag behind +/// the actual change (deferred dependency job for lock-gen languages); other +/// kinds reset synchronously on delete, so their cache is trusted (and they're +/// never passed in). One query per kind keeps the compare path off a per-row +/// sequence of round trips. Runs on `&db` (no RLS) — this is a pure existence +/// check; authorization stays in `filter_visible_diffs` / `query_visible_items`. +async fn existing_runnables( + db: &DB, + workspace_id: &str, + items_by_kind: &HashMap<&str, Vec<&str>>, +) -> Result> { + let mut existing = HashSet::new(); + for (kind, paths) in items_by_kind { + let paths_vec: Vec = paths.iter().map(|s| s.to_string()).collect(); + let found: Vec = match *kind { + "script" => sqlx::query_scalar!( + "SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(db) + .await?, + "flow" => sqlx::query_scalar!( + "SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false", + workspace_id, + &paths_vec + ) + .fetch_all(db) + .await?, + _ => vec![], + }; + for path in found { + existing.insert((kind.to_string(), path)); + } + } + Ok(existing) +} + #[derive(Debug)] struct ItemComparison { has_changes: bool, @@ -6845,7 +7041,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", source_workspace_id, path ) @@ -6857,7 +7053,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", fork_workspace_id, path ) @@ -7303,7 +7499,7 @@ async fn get_cloud_quotas( let scripts_prunable = sqlx::query_scalar!( "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", &w_id @@ -7398,7 +7594,7 @@ async fn prune_versions( "DELETE FROM script WHERE workspace_id = $1 AND hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", ) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 3bfc45039a..4c6b57172f 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -279,8 +279,8 @@ pub(crate) async fn change_workspace_id( info!("Duplicating flow table rows"); sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) - SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) + SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs FROM flow WHERE workspace_id = $2", &rw.new_id, &old_id @@ -469,8 +469,8 @@ pub(crate) async fn change_workspace_id( // Duplicate folders with new workspace id (FK constraint) info!("Duplicating folder table rows"); sqlx::query!( - "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as) \ - SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as \ + "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels) \ + SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels \ FROM folder WHERE workspace_id = $2", &rw.new_id, &old_id @@ -872,13 +872,16 @@ pub(crate) async fn delete_workspace( .execute(&mut *tx) .await?; + // Record under the instance-level "admins" workspace. The per-workspace audit + // rows are deleted along with the workspace, so this instance-level entry is the + // only durable, superadmin-discoverable record of who deleted the workspace. audit_log( &mut *tx, &authed, "workspaces.delete", ActionKind::Delete, - &w_id, - Some(&authed.email), + "admins", + Some(&w_id), None, ) .await?; diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 00727e626f..0cf2e9aca0 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -1,7 +1,7 @@ { "openapi": "3.0.3", "info": { - "version": "1.713.1", + "version": "1.723.0", "title": "Windmill API", "contact": { "name": "Windmill Team", @@ -7879,6 +7879,54 @@ } } }, + "/users/tokens/update_label/{token_prefix}": { + "post": { + "summary": "update label of an existing token (owner only)", + "operationId": "updateTokenLabel", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "token_prefix", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "new label (null or omitted = no label)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "label updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users/tokens/list": { "get": { "summary": "list token", @@ -8202,6 +8250,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -8210,7 +8261,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListableVariable" + "allOf": [ + { + "$ref": "#/components/schemas/ListableVariable" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -8350,6 +8408,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft variables whose path has no\ndeployed variable. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -10264,6 +10331,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -10272,7 +10342,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Resource" + "allOf": [ + { + "$ref": "#/components/schemas/ListableResource" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -10507,6 +10584,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -12005,7 +12091,37 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "type": "object", + "properties": { + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nscript — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Surfaced for draft-only rows so\nthe home list can render the meaningful name\ninstead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + } + } + } + ] } } } @@ -12043,10 +12159,10 @@ } } }, - "/w/{workspace}/drafts/create": { - "post": { - "summary": "create draft", - "operationId": "createDraft", + "/w/{workspace}/drafts/list": { + "get": { + "summary": "list every draft the current user has in this workspace, across all kinds", + "operationId": "listDrafts", "tags": [ "draft" ], @@ -12055,42 +12171,42 @@ "$ref": "#/components/parameters/WorkspaceId" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "typ": { - "type": "string", - "enum": [ - "flow", - "script", - "app" - ] - }, - "value": {} - }, - "required": [ - "path", - "typ", - "enum" - ] - } - } - } - }, "responses": { - "201": { - "description": "draft created", + "200": { + "description": "the user's drafts", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/UserDraftItemKind" + }, + "path": { + "type": "string" + }, + "summary": { + "type": "string", + "description": "Best-effort, read from the draft JSON's `summary` field when the editor shape carries one." + }, + "draft_only": { + "type": "boolean", + "description": "No deployed counterpart exists at this path — the draft is the whole item." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "kind", + "path", + "draft_only", + "created_at" + ] + } } } } @@ -12098,10 +12214,10 @@ } } }, - "/w/{workspace}/drafts/delete/{kind}/{path}": { - "delete": { - "summary": "delete draft", - "operationId": "deleteDraft", + "/w/{workspace}/drafts/get/{kind}/{path}": { + "get": { + "summary": "fetch a single draft's content by workspace username (or the legacy workspace-level row)", + "operationId": "getDraftForUser", "tags": [ "draft" ], @@ -12114,25 +12230,122 @@ "in": "path", "required": true, "schema": { - "type": "string", - "enum": [ - "script", - "flow", - "app" - ] + "$ref": "#/components/schemas/UserDraftItemKind" + } + }, + { + "$ref": "#/components/parameters/ScriptPath" + }, + { + "name": "username", + "in": "query", + "required": false, + "description": "Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "draft content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": {}, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "value", + "created_at" + ] + } + } + } + }, + "404": { + "description": "no draft for that owner at that path" + } + } + } + }, + "/w/{workspace}/drafts/update/{kind}/{path}": { + "post": { + "summary": "upsert (or clear) the current user's draft at a path", + "operationId": "updateDraft", + "tags": [ + "draft" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserDraftItemKind" } }, { "$ref": "#/components/parameters/ScriptPath" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules." + }, + "last_sync": { + "type": "string", + "format": "date-time", + "description": "Server timestamp of the client's last known sync for this draft. Omit on first save." + }, + "force": { + "type": "boolean", + "description": "Skip the conflict check and overwrite the server copy." + } + } + } + } + } + }, "responses": { "200": { - "description": "draft deleted", + "description": "save result", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "saved", + "conflict" + ] + }, + "current_timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "status", + "current_timestamp" + ] } } } @@ -12729,6 +12942,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -12737,7 +12953,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -12806,35 +13029,6 @@ } } }, - "/w/{workspace}/scripts/get/draft/{path}": { - "get": { - "summary": "get script by path with draft", - "operationId": "getScriptByPathWithDraft", - "tags": [ - "script" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "script details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewScriptWithDraft" - } - } - } - } - } - } - }, "/w/{workspace}/scripts/history/p/{path}": { "get": { "summary": "get history of a script by path", @@ -14541,6 +14735,42 @@ } } }, + "/w/{workspace}/jobs/job_view_token/{id}": { + "get": { + "summary": "mint a read-only share token for a job", + "description": "Returns a stateless `{job_id}.{hmac}` token that grants an authenticated workspace member read access to this job (and its flow subtree) via a `view_token` query param or `X-View-Token` header. Only callable by a user who can already read the job.\n", + "operationId": "getJobViewToken", + "tags": [ + "job" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "the share read token", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/w/{workspace}/flows/list_paths": { "get": { "summary": "list all flow paths", @@ -14722,11 +14952,29 @@ { "type": "object", "properties": { - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nflow — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's\n`draft_path` field (the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } } } } @@ -14950,6 +15198,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -14958,7 +15209,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Flow" + "allOf": [ + { + "$ref": "#/components/schemas/Flow" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -15110,52 +15368,6 @@ } } }, - "/w/{workspace}/flows/get/draft/{path}": { - "get": { - "summary": "get flow by path with draft", - "operationId": "getFlowByPathWithDraft", - "tags": [ - "flow" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "flow details with draft", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Flow" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/Flow" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - } - } - } - } - } - } - }, "/w/{workspace}/flows/exists/{path}": { "get": { "summary": "exists flow by path", @@ -15224,9 +15436,6 @@ { "type": "object", "properties": { - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -15643,6 +15852,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -16059,9 +16271,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16140,9 +16349,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16245,6 +16451,17 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" + }, + { + "name": "raw_app", + "in": "query", + "description": "When no deployed app exists at this path and `get_draft` is set,\ndisambiguates which draft kind (`raw_app` or `app`) to look up.\nIgnored when a deployed row exists.\n", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -16253,7 +16470,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppWithLastVersion" + "allOf": [ + { + "$ref": "#/components/schemas/AppWithLastVersion" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -16290,35 +16514,6 @@ } } }, - "/w/{workspace}/apps/get/draft/{path}": { - "get": { - "summary": "get app by path with draft", - "operationId": "getAppByPathWithDraft", - "tags": [ - "app" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "app details with draft", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppWithLastVersionWDraft" - } - } - } - } - } - } - }, "/w/{workspace}/apps/history/p/{path}": { "get": { "summary": "get app history by path", @@ -18545,6 +18740,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -18962,6 +19171,20 @@ { "$ref": "#/components/parameters/Success" }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "$ref": "#/components/parameters/JobKinds" }, @@ -19345,6 +19568,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -21488,6 +21725,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21496,7 +21736,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Schedule" + "allOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -21618,6 +21865,15 @@ "type": "string" }, "description": "Filter by label" + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft schedules whose path has\nno deployed schedule. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -21966,6 +22222,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21974,7 +22233,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HttpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/HttpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22030,6 +22296,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22303,6 +22572,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22311,7 +22583,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebsocketTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/WebsocketTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22367,6 +22646,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22638,6 +22920,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22646,7 +22931,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KafkaTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/KafkaTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22702,6 +22994,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23039,6 +23334,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23047,7 +23345,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NatsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/NatsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23103,6 +23408,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23367,6 +23675,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23375,7 +23686,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SqsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/SqsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23431,6 +23749,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24145,6 +24466,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24620,6 +24944,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24628,7 +24955,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MqttTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/MqttTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -24684,6 +25018,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24948,6 +25285,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24956,7 +25296,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GcpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/GcpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25012,6 +25359,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -25389,6 +25739,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -25397,7 +25750,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AzureTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/AzureTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25443,6 +25803,9 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26302,6 +26665,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26310,7 +26676,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostgresTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/PostgresTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26366,6 +26739,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26630,6 +27006,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26638,7 +27017,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/EmailTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26694,6 +27080,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -27667,6 +28056,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -27729,6 +28124,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -31494,6 +31895,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -32604,6 +33019,24 @@ } }, "parameters": { + "GetDraft": { + "name": "get_draft", + "in": "query", + "required": false, + "description": "When true, overlay the authed user's draft (if any) onto the deployed payload.", + "schema": { + "type": "boolean" + } + }, + "IncludeDraftOnly": { + "name": "include_draft_only", + "in": "query", + "required": false, + "description": "When true, append per-user draft rows whose path has no\ndeployed counterpart. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge. Gated to\nnon-operators + page 0 + no narrowing filters on the backend so\npicker callers stay deployed-only and pagination stays clean.\n", + "schema": { + "type": "boolean" + } + }, "Id": { "name": "id", "in": "path", @@ -33148,6 +33581,73 @@ } }, "schemas": { + "UserDraftOverlay": { + "type": "object", + "description": "Overlay fields added to every \"get by path\" response that accepts\nthe `get_draft` query parameter. The deployed payload is sent\nuntouched in the response body; the authed user's saved draft\nfor this path — whatever shape the editor wrote — is attached\nas the sibling `draft` field when `get_draft=true` and a draft\nexists. The frontend pairs the two to present diff / reset /\ndiscard UI; the server never merges them.\n\nWhen `no_deployed=true` there is no deployed row at this path —\nthe response body is a best-effort stand-in synthesized from\nthe draft, and only `draft` is canonical. Callers should disable\n\"diff vs deployed\" UI in that case.\n", + "properties": { + "is_draft": { + "type": "boolean" + }, + "draft_saved_at": { + "type": "string", + "format": "date-time" + }, + "no_deployed": { + "type": "boolean" + }, + "draft": { + "type": "object", + "additionalProperties": true + }, + "other_drafts_users": { + "description": "Other workspace users (and the legacy NULL-email row, if any)\nwith a saved draft at the same path. Populated only on the\nauthed user's \"get by path\" responses for kinds the editor\nsurfaces a fork banner for (script, flow, app, raw_app).\nEmpty / omitted for kinds without that UI.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true, + "description": "Workspace username of the draft owner. `null` represents\nthe legacy workspace-level (NULL-email) row. Emails never\nleave the server.\n" + } + } + } + } + }, + "required": [ + "is_draft" + ] + }, + "UserDraftItemKind": { + "type": "string", + "description": "Closed set of item kinds a user can autosave as a draft. Mirrors the\nPostgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.\n", + "enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + }, "OpenFlow": { "type": "object", "description": "Top-level flow definition containing metadata, configuration, and the flow structure", @@ -33354,6 +33854,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ @@ -34174,6 +34678,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -35367,6 +35875,9 @@ "items": { "type": "string" } + }, + "web_search_enabled": { + "type": "boolean" } }, "required": [ @@ -35633,9 +36144,6 @@ "tag": { "type": "string" }, - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" }, @@ -35723,6 +36231,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -35787,9 +36302,6 @@ "tag": { "type": "string" }, - "draft_only": { - "type": "boolean" - }, "envs": { "type": "array", "items": { @@ -35930,32 +36442,6 @@ "language" ] }, - "NewScriptWithDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/NewScript" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/NewScript" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - }, - "hash": { - "type": "string" - } - }, - "required": [ - "hash" - ] - } - ] - }, "ScriptHistory": { "type": "object", "properties": { @@ -37391,6 +37877,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" }, @@ -37400,6 +37893,14 @@ }, "edited_by": { "type": "string" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nvariable at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38115,6 +38616,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" } @@ -38178,8 +38686,23 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nresource at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38384,6 +38907,21 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nschedule at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -38785,6 +39323,14 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\ntrigger at the same path. Set by list endpoints when\n`include_draft_only=true` synthesizes the row from the\ndraft. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -41871,6 +42417,13 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels set on the folder. Items inside the folder inherit them, exposed as `inherited_labels` on scripts and flows and stamped into job labels at run time.\n" } }, "required": [ @@ -42369,6 +42922,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42599,6 +43159,34 @@ "type": "string" }, "default": [] + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this app — either no\ndeployed row exists at this path (draft-only) or the user has\nsaved a per-user draft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's `draft_path` field (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and the legacy\nNULL-email row if any) who have a per-user draft at this\npath. Drives the home page's user-avatar circles inside the\nDraft badge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42691,6 +43279,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42780,27 +43375,6 @@ "raw_app" ] }, - "AppWithLastVersionWDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/AppWithLastVersion" - }, - { - "type": "object", - "properties": { - "draft_only": { - "type": "boolean" - }, - "draft": {}, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - }, "AppHistory": { "type": "object", "properties": { @@ -44350,7 +44924,8 @@ "enum": [ "DisableDirectDeployment", "DisableWorkspaceForking", - "RestrictDeployToDeployers" + "RestrictDeployToDeployers", + "RestrictAnonymousAppDeployment" ] }, "RuleBypasserGroups": { @@ -46030,6 +46605,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -46062,6 +46641,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 4ced5ef265..22792b38e3 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 1.713.1 + version: 1.723.0 title: Windmill API contact: name: Windmill Team @@ -146,18 +146,18 @@ paths: checks: type: object description: Detailed health checks - required: &ref_347 + required: &ref_352 - database - readiness - properties: &ref_348 + properties: &ref_353 database: type: object description: Database health status - required: &ref_349 + required: &ref_354 - healthy - latency_ms - pool - properties: &ref_350 + properties: &ref_355 healthy: type: boolean description: Whether the database is reachable @@ -168,11 +168,11 @@ paths: pool: type: object description: Database connection pool statistics - required: &ref_351 + required: &ref_356 - size - idle - max_connections - properties: &ref_352 + properties: &ref_357 size: type: integer description: Current number of connections in the pool @@ -186,13 +186,13 @@ paths: description: Workers health status nullable: true type: object - required: &ref_353 + required: &ref_358 - healthy - active_count - worker_groups - min_version - versions - properties: &ref_354 + properties: &ref_359 healthy: type: boolean description: Whether any workers are active @@ -219,10 +219,10 @@ paths: description: Job queue status nullable: true type: object - required: &ref_355 + required: &ref_360 - pending_jobs - running_jobs - properties: &ref_356 + properties: &ref_361 pending_jobs: type: integer format: int64 @@ -234,9 +234,9 @@ paths: readiness: type: object description: Server readiness status - required: &ref_357 + required: &ref_362 - healthy - properties: &ref_358 + properties: &ref_363 healthy: type: boolean description: Whether the server is ready to accept requests @@ -337,7 +337,7 @@ paths: - name: id in: path required: true - schema: &ref_79 + schema: &ref_84 type: integer responses: '200': @@ -488,24 +488,24 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: &ref_299 + schema: &ref_304 type: string format: date-time - name: after description: filter on created after (exclusive) timestamp in: query - schema: &ref_300 + schema: &ref_305 type: string format: date-time - name: username description: filter on exact username of user in: query - schema: &ref_308 + schema: &ref_313 type: string - name: operation description: filter on exact or prefix name of operation in: query - schema: &ref_309 + schema: &ref_314 type: string - name: operations in: query @@ -520,12 +520,12 @@ paths: - name: resource description: filter on exact or prefix name of resource in: query - schema: &ref_310 + schema: &ref_315 type: string - name: action_kind description: filter on type of operation in: query - schema: &ref_311 + schema: &ref_316 type: string enum: - Create @@ -562,12 +562,12 @@ paths: application/json: schema: type: object - properties: &ref_397 + properties: &ref_403 email: type: string password: type: string - required: &ref_398 + required: &ref_404 - email - password responses: @@ -757,7 +757,7 @@ paths: nullable: true allOf: - type: object - properties: &ref_394 + properties: &ref_400 source: type: string enum: @@ -775,7 +775,7 @@ paths: description: >- The instance group name (when source is 'instance_group') - required: &ref_395 + required: &ref_401 - source is_service_account: type: boolean @@ -813,7 +813,7 @@ paths: application/json: schema: type: object - properties: &ref_399 + properties: &ref_405 is_admin: type: boolean operator: @@ -1187,7 +1187,7 @@ paths: type: array items: type: object - properties: &ref_413 + properties: &ref_419 jwt_hash: type: integer format: int64 @@ -1210,7 +1210,7 @@ paths: last_used_at: type: string format: date-time - required: &ref_414 + required: &ref_420 - jwt_hash - email - username @@ -1342,7 +1342,7 @@ paths: type: array items: type: object - properties: &ref_400 + properties: &ref_406 label: type: string scopes: @@ -1351,7 +1351,7 @@ paths: type: string expiration: type: string - required: &ref_401 + required: &ref_407 - label - scopes description: Tokens owned by this user (will be deleted) @@ -1395,7 +1395,7 @@ paths: application/json: schema: type: object - properties: &ref_402 + properties: &ref_408 reassign_to: type: string description: 'Target for reassignment: ''u/{username}'' or ''f/{folder}''' @@ -1409,7 +1409,7 @@ paths: type: boolean default: true description: Whether to also remove the user from the workspace - required: &ref_403 + required: &ref_409 - reassign_to responses: '200': @@ -1428,7 +1428,7 @@ paths: on success. summary: type: object - properties: &ref_404 + properties: &ref_410 scripts_reassigned: type: integer flows_reassigned: @@ -1445,7 +1445,7 @@ paths: type: integer drafts_deleted: type: integer - required: &ref_405 + required: &ref_411 - scripts_reassigned - flows_reassigned - apps_reassigned @@ -1475,12 +1475,12 @@ paths: application/json: schema: type: object - properties: &ref_406 + properties: &ref_412 workspaces: type: array items: type: object - properties: &ref_408 + properties: &ref_414 workspace_id: type: string username: @@ -1489,11 +1489,11 @@ paths: type: object properties: *ref_12 required: *ref_13 - required: &ref_409 + required: &ref_415 - workspace_id - username - preview - required: &ref_407 + required: &ref_413 - workspaces /users/offboard/{email}: post: @@ -1515,12 +1515,12 @@ paths: application/json: schema: type: object - properties: &ref_410 + properties: &ref_416 reassignments: type: object additionalProperties: type: object - properties: &ref_411 + properties: &ref_417 reassign_to: type: string description: 'Target: ''u/{username}'' or ''f/{folder}''' @@ -1529,7 +1529,7 @@ paths: description: >- Required when reassign_to is a folder. Username to use as permissioned_as. - required: &ref_412 + required: &ref_418 - reassign_to description: Map of workspace_id to reassignment config delete_user: @@ -1589,7 +1589,7 @@ paths: application/json: schema: type: array - items: &ref_562 + items: &ref_563 type: object properties: workspace_id: @@ -1699,7 +1699,7 @@ paths: application/json: schema: type: object - properties: &ref_502 + properties: &ref_504 email: type: string workspaces: @@ -1774,7 +1774,7 @@ paths: - username - color - disabled - required: &ref_503 + required: &ref_505 - email - workspaces /w/{workspace}/workspaces/get_as_superadmin: @@ -1836,7 +1836,7 @@ paths: application/json: schema: type: object - properties: &ref_504 + properties: &ref_506 id: type: string name: @@ -1845,7 +1845,7 @@ paths: type: string color: type: string - required: &ref_505 + required: &ref_507 - id - name responses: @@ -2021,7 +2021,7 @@ paths: properties: &ref_24 logs: type: object - properties: &ref_470 + properties: &ref_472 super_admin: type: string enum: &ref_21 @@ -2828,11 +2828,11 @@ paths: type: array items: type: object - properties: &ref_545 + properties: &ref_546 name: type: string value: {} - required: &ref_546 + required: &ref_547 - name - value /settings/instance_config: @@ -2930,9 +2930,9 @@ paths: application/json: schema: type: object - required: &ref_370 + required: &ref_375 - keys - properties: &ref_371 + properties: &ref_376 keys: type: array items: @@ -3049,11 +3049,11 @@ paths: type: array items: type: object - required: &ref_368 + required: &ref_373 - workspace_id - path - error - properties: &ref_369 + properties: &ref_374 workspace_id: type: string description: Workspace ID where the secret is located @@ -4348,13 +4348,13 @@ paths: application/json: schema: type: object - required: &ref_554 + required: &ref_555 - all_ahead_items_visible - all_behind_items_visible - skipped_comparison - diffs - summary - properties: &ref_555 + properties: &ref_556 all_ahead_items_visible: type: boolean description: >- @@ -4375,7 +4375,7 @@ paths: description: List of differences found between workspaces items: type: object - required: &ref_556 + required: &ref_557 - kind - path - ahead @@ -4383,7 +4383,7 @@ paths: - has_changes - exists_in_source - exists_in_fork - properties: &ref_557 + properties: &ref_558 kind: type: string enum: @@ -4428,7 +4428,7 @@ paths: summary: description: Summary statistics of the comparison type: object - required: &ref_558 + required: &ref_559 - total_diffs - total_ahead - total_behind @@ -4442,7 +4442,7 @@ paths: - schedules_changed - triggers_changed - conflicts - properties: &ref_559 + properties: &ref_560 total_diffs: type: integer description: Total number of items with differences @@ -4639,12 +4639,12 @@ paths: type: array items: type: object - properties: &ref_531 + properties: &ref_532 pattern: type: string allow: type: string - required: &ref_532 + required: &ref_533 - pattern - allow secondary_storage: @@ -4777,7 +4777,7 @@ paths: auto_invite: type: object description: Configuration for auto-inviting users to the workspace - properties: &ref_359 + properties: &ref_364 enabled: type: boolean default: false @@ -4818,14 +4818,16 @@ paths: type: object additionalProperties: type: object - properties: &ref_378 + properties: &ref_383 resource_path: type: string models: type: array items: type: string - required: &ref_379 + web_search_enabled: + type: boolean + required: &ref_384 - resource_path - models default_model: @@ -4871,7 +4873,7 @@ paths: error_handler: type: object description: Configuration for the workspace error handler - properties: &ref_360 + properties: &ref_365 path: type: string description: Path to the error handler script or flow @@ -4888,7 +4890,7 @@ paths: success_handler: type: object description: Configuration for the workspace success handler - properties: &ref_361 + properties: &ref_366 path: type: string description: Path to the success handler script or flow @@ -5192,7 +5194,7 @@ paths: type: array items: type: object - properties: &ref_507 + properties: &ref_509 importer_path: type: string importer_kind: @@ -5206,7 +5208,7 @@ paths: items: type: string nullable: true - required: &ref_508 + required: &ref_510 - importer_path - importer_kind /w/{workspace}/workspaces/get_imports/{importer_path}: @@ -5264,13 +5266,13 @@ paths: type: array items: type: object - properties: &ref_509 + properties: &ref_511 imported_path: type: string count: type: integer format: int64 - required: &ref_510 + required: &ref_512 - imported_path - count /w/{workspace}/workspaces/get_dependency_map: @@ -5293,7 +5295,7 @@ paths: type: array items: type: object - properties: &ref_506 + properties: &ref_508 workspace_id: type: string nullable: true @@ -5825,7 +5827,7 @@ paths: type: array items: type: object - properties: &ref_380 + properties: &ref_385 provider: type: string enum: *ref_51 @@ -5833,7 +5835,7 @@ paths: type: array items: type: string - required: &ref_381 + required: &ref_386 - provider - models default_model: @@ -5924,10 +5926,10 @@ paths: Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_362 + oneOf: &ref_367 - type: object description: New grouped format for editing error handler - properties: &ref_363 + properties: &ref_368 path: type: string description: Path to the error handler script or flow @@ -5945,7 +5947,7 @@ paths: description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: &ref_364 + properties: &ref_369 error_handler: type: string description: Path to the error handler script or flow @@ -5984,10 +5986,10 @@ paths: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_365 + oneOf: &ref_370 - type: object description: New grouped format for editing success handler - properties: &ref_366 + properties: &ref_371 path: type: string description: Path to the success handler script or flow @@ -5999,7 +6001,7 @@ paths: description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: &ref_367 + properties: &ref_372 success_handler: type: string description: Path to the success handler script or flow @@ -6116,10 +6118,10 @@ paths: type: array items: type: object - required: &ref_523 + required: &ref_524 - datatable_name - schemas - properties: &ref_524 + properties: &ref_525 datatable_name: type: string schemas: @@ -6161,10 +6163,10 @@ paths: type: array items: type: object - required: &ref_525 + required: &ref_526 - datatable_name - schemas - properties: &ref_526 + properties: &ref_527 datatable_name: type: string schemas: @@ -6209,12 +6211,12 @@ paths: application/json: schema: type: object - required: &ref_527 + required: &ref_528 - datatable_name - schema_name - table_name - columns - properties: &ref_528 + properties: &ref_529 datatable_name: type: string schema_name: @@ -6975,7 +6977,7 @@ paths: type: array items: type: object - properties: &ref_396 + properties: &ref_402 email: type: string executions: @@ -7038,7 +7040,7 @@ paths: type: array items: type: object - properties: &ref_518 + properties: &ref_520 name: type: string description: @@ -7048,7 +7050,7 @@ paths: type: array items: type: object - properties: &ref_516 + properties: &ref_518 value: type: string label: @@ -7058,11 +7060,11 @@ paths: nullable: true requires_resource_path: type: boolean - required: &ref_517 + required: &ref_519 - value - label - requires_resource_path - required: &ref_519 + required: &ref_521 - name - scopes /users/tokens/create: @@ -7078,7 +7080,7 @@ paths: application/json: schema: type: object - properties: &ref_415 + properties: &ref_421 label: type: string expiration: @@ -7119,7 +7121,7 @@ paths: application/json: schema: type: object - properties: &ref_416 + properties: &ref_422 label: type: string expiration: @@ -7129,7 +7131,7 @@ paths: type: string workspace_id: type: string - required: &ref_417 + required: &ref_423 - impersonate_email responses: '201': @@ -7189,6 +7191,36 @@ paths: text/plain: schema: type: string + /users/tokens/update_label/{token_prefix}: + post: + summary: update label of an existing token (owner only) + operationId: updateTokenLabel + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new label (null or omitted = no label) + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + nullable: true + responses: + '200': + description: label updated + content: + text/plain: + schema: + type: string /users/tokens/list: get: summary: list token @@ -7217,7 +7249,7 @@ paths: type: array items: type: object - properties: &ref_102 + properties: &ref_108 label: type: string expiration: @@ -7241,7 +7273,7 @@ paths: type: string read_only: type: boolean - required: &ref_103 + required: &ref_109 - token_prefix - created_at - last_used_at @@ -7297,7 +7329,7 @@ paths: application/json: schema: type: object - properties: &ref_420 + properties: &ref_426 path: type: string description: The path to the variable @@ -7326,7 +7358,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_421 + required: &ref_427 - path - value - is_secret @@ -7448,7 +7480,7 @@ paths: application/json: schema: type: object - properties: &ref_422 + properties: &ref_428 path: type: string description: The path to the variable @@ -7504,59 +7536,168 @@ paths: in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: &ref_77 + type: boolean responses: '200': description: variable content: application/json: schema: - type: object - properties: &ref_61 - workspace_id: - type: string - path: - type: string - value: - type: string - is_secret: - type: boolean - description: - type: string - account: - type: integer - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - expires_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - edited_at: - type: string - format: date-time - edited_by: - type: string - required: &ref_62 - - workspace_id - - path - - is_secret - - extra_perms + allOf: + - type: object + properties: &ref_61 + workspace_id: + type: string + path: + type: string + value: + type: string + is_secret: + type: boolean + description: + type: string + account: + type: integer + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + expires_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + edited_at: + type: string + format: date-time + edited_by: + type: string + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + variable at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_62 + - workspace_id + - path + - is_secret + - extra_perms + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: &ref_78 + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: > + Other workspace users (and the legacy NULL-email row, + if any) + + with a saved draft at the same path. Populated only on + the + + authed user's "get by path" responses for kinds the + editor + + surfaces a fork banner for (script, flow, app, + raw_app). + + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: > + Workspace username of the draft owner. `null` + represents + + the legacy workspace-level (NULL-email) row. + Emails never + + leave the server. + required: &ref_79 + - is_draft /w/{workspace}/variables/get_value/{path}: get: summary: get variable value @@ -7662,6 +7803,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: '200': description: variable list @@ -7693,7 +7843,7 @@ paths: type: array items: type: object - properties: &ref_418 + properties: &ref_424 name: type: string value: @@ -7702,7 +7852,7 @@ paths: type: string is_custom: type: boolean - required: &ref_419 + required: &ref_425 - name - value - description @@ -7889,12 +8039,12 @@ paths: description: >- A workspace protection rule defining restrictions and bypass permissions - required: &ref_565 + required: &ref_566 - name - rules - bypass_groups - bypass_users - properties: &ref_566 + properties: &ref_567 name: type: string description: Unique name for the protection rule @@ -7906,10 +8056,11 @@ paths: description: Configuration of protection restrictions items: &ref_64 type: string - enum: &ref_567 + enum: &ref_568 - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers + - RestrictAnonymousAppDeployment bypass_groups: type: array description: Groups that can bypass this ruleset @@ -8066,11 +8217,11 @@ paths: type: array items: type: object - required: &ref_568 + required: &ref_569 - username - email - is_admin - properties: &ref_569 + properties: &ref_570 username: type: string email: @@ -8125,10 +8276,10 @@ paths: type: array items: type: object - required: &ref_570 + required: &ref_571 - username - email - properties: &ref_571 + properties: &ref_572 username: type: string email: @@ -9051,7 +9202,7 @@ paths: application/json: schema: type: object - properties: &ref_427 + properties: &ref_433 path: type: string description: The path to the resource @@ -9068,7 +9219,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_428 + required: &ref_434 - path - value - resource_type @@ -9159,7 +9310,7 @@ paths: application/json: schema: type: object - properties: &ref_429 + properties: &ref_435 path: type: string description: The path to the resource @@ -9230,44 +9381,123 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: resource content: application/json: schema: - type: object - properties: &ref_430 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_431 - - path - - resource_type - - is_oauth + allOf: + - type: object + properties: &ref_80 + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + account: + type: number + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + resource at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_81 + - path + - resource_type + - is_oauth + - is_linked + - is_refreshed + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/resources/get_value_interpolated/{path}: get: summary: get resource interpolated (variables and resources are fully unrolled) @@ -9438,6 +9668,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: resource list @@ -9447,49 +9686,8 @@ paths: type: array items: type: object - properties: &ref_432 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - account: - type: number - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_433 - - path - - resource_type - - is_oauth - - is_linked - - is_refreshed + properties: *ref_80 + required: *ref_81 /w/{workspace}/resources/list_search: get: summary: list resources for search @@ -9565,7 +9763,7 @@ paths: - name: name in: path required: true - schema: &ref_273 + schema: &ref_278 type: string responses: '200': @@ -9602,7 +9800,7 @@ paths: application/json: schema: type: object - properties: &ref_77 + properties: &ref_82 workspace_id: type: string name: @@ -9619,7 +9817,7 @@ paths: type: string is_fileset: type: boolean - required: &ref_78 + required: &ref_83 - name responses: '201': @@ -9698,7 +9896,7 @@ paths: application/json: schema: type: object - properties: &ref_434 + properties: &ref_436 schema: {} description: type: string @@ -9733,8 +9931,8 @@ paths: application/json: schema: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/exists/{path}: get: summary: does resource_type exists @@ -9778,8 +9976,8 @@ paths: type: array items: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/listnames: get: summary: list resource_types names @@ -10061,7 +10259,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: flow @@ -10075,7 +10273,7 @@ paths: description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: &ref_120 + properties: &ref_124 summary: type: string description: Short description of what this flow does @@ -10087,7 +10285,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_610 + properties: &ref_611 modules: type: array description: >- @@ -10098,7 +10296,7 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: &ref_82 + properties: &ref_87 id: type: string description: >- @@ -10112,14 +10310,14 @@ paths: Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: &ref_88 + oneOf: &ref_93 - type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: &ref_323 + properties: &ref_328 input_transforms: type: object description: >- @@ -10133,14 +10331,14 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_80 + oneOf: &ref_85 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: &ref_136 + properties: &ref_140 value: description: >- The static value. For resources, use @@ -10149,7 +10347,7 @@ paths: type: string enum: - static - required: &ref_137 + required: &ref_141 - type - type: object description: >- @@ -10159,7 +10357,7 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: &ref_84 + properties: &ref_89 expr: type: string description: >- @@ -10172,7 +10370,7 @@ paths: type: string enum: - javascript - required: &ref_85 + required: &ref_90 - expr - type - type: object @@ -10180,14 +10378,14 @@ paths: Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: &ref_86 + properties: &ref_91 type: type: string enum: - ai - required: &ref_87 + required: &ref_92 - type - discriminator: &ref_81 + discriminator: &ref_86 propertyName: type mapping: static: >- @@ -10297,7 +10495,7 @@ paths: - r - w - rw - required: &ref_324 + required: &ref_329 - type - content - language @@ -10307,7 +10505,7 @@ paths: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: &ref_325 + properties: &ref_330 input_transforms: type: object description: >- @@ -10321,8 +10519,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10347,7 +10545,7 @@ paths: description: >- If true, this script is a trigger that can start the flow - required: &ref_326 + required: &ref_331 - type - path - input_transforms @@ -10356,7 +10554,7 @@ paths: Reference to an existing flow by path. Use this to call another flow as a subflow - properties: &ref_327 + properties: &ref_332 input_transforms: type: object description: >- @@ -10370,8 +10568,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10381,7 +10579,7 @@ paths: type: string enum: - flow - required: &ref_328 + required: &ref_333 - type - path - input_transforms @@ -10394,7 +10592,7 @@ paths: 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: &ref_329 + properties: &ref_334 modules: type: array description: >- @@ -10406,8 +10604,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: &ref_83 + properties: *ref_87 + required: &ref_88 - value - id iterator: @@ -10416,8 +10614,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -10439,11 +10637,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_330 + required: &ref_335 - modules - iterator - skip_failures @@ -10455,7 +10653,7 @@ paths: condition after each iteration. Use stop_after_if on modules to control loop termination - properties: &ref_331 + properties: &ref_336 modules: type: array description: >- @@ -10467,8 +10665,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -10489,11 +10687,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_332 + required: &ref_337 - modules - skip_failures - type @@ -10505,7 +10703,7 @@ paths: one with a true expression runs. If no branches match, the default branch executes - properties: &ref_333 + properties: &ref_338 branches: type: array description: >- @@ -10536,8 +10734,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -10551,13 +10749,13 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: - branchone - required: &ref_334 + required: &ref_339 - branches - default - type @@ -10568,7 +10766,7 @@ paths: BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: &ref_335 + properties: &ref_340 branches: type: array description: >- @@ -10595,8 +10793,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -10609,7 +10807,7 @@ paths: If true, all branches execute concurrently. If false, they execute sequentially - required: &ref_336 + required: &ref_341 - branches - type - type: object @@ -10617,7 +10815,7 @@ paths: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: &ref_337 + properties: &ref_342 type: type: string enum: @@ -10627,7 +10825,7 @@ paths: description: >- If true, marks this as a flow identity (special handling) - required: &ref_338 + required: &ref_343 - type - type: object description: >- @@ -10635,7 +10833,7 @@ paths: accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: &ref_339 + properties: &ref_344 input_transforms: type: object description: >- @@ -10647,22 +10845,22 @@ paths: Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: &ref_341 + oneOf: &ref_346 - type: object description: >- Static provider configuration passed directly to the AI agent - properties: &ref_595 + properties: &ref_596 value: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: &ref_593 + properties: &ref_594 kind: type: string description: Supported AI provider types - enum: &ref_316 + enum: &ref_321 - openai - azure_openai - anthropic @@ -10685,7 +10883,7 @@ paths: description: >- Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro') - required: &ref_594 + required: &ref_595 - kind - resource - model @@ -10693,7 +10891,7 @@ paths: type: string enum: - static - required: &ref_596 + required: &ref_597 - type - value - type: object @@ -10704,16 +10902,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_342 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_347 propertyName: type mapping: static: >- @@ -10728,8 +10926,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -10743,8 +10941,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation @@ -10756,8 +10954,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. @@ -10769,8 +10967,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -10783,27 +10981,27 @@ paths: Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: &ref_343 + oneOf: &ref_348 - type: object description: >- Static memory configuration passed directly to the AI agent - properties: &ref_601 + properties: &ref_602 value: description: Conversation memory configuration - oneOf: &ref_599 + oneOf: &ref_600 - type: object description: No conversation memory/context - properties: &ref_317 + properties: &ref_322 kind: type: string enum: - 'off' - required: &ref_318 + required: &ref_323 - kind - type: object description: Automatic context management - properties: &ref_319 + properties: &ref_324 kind: type: string enum: @@ -10818,11 +11016,11 @@ paths: description: >- Identifier for persistent memory across agent invocations - required: &ref_320 + required: &ref_325 - kind - type: object description: Explicit message history - properties: &ref_321 + properties: &ref_326 kind: type: string enum: @@ -10832,7 +11030,7 @@ paths: items: type: object description: A single message in conversation history - properties: &ref_597 + properties: &ref_598 role: type: string enum: @@ -10841,13 +11039,13 @@ paths: - system content: type: string - required: &ref_598 + required: &ref_599 - role - content - required: &ref_322 + required: &ref_327 - kind - messages - discriminator: &ref_600 + discriminator: &ref_601 propertyName: kind mapping: 'off': '#/components/schemas/MemoryOff' @@ -10857,7 +11055,7 @@ paths: type: string enum: - static - required: &ref_602 + required: &ref_603 - type - value - type: object @@ -10868,16 +11066,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_344 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_349 propertyName: type mapping: static: >- @@ -10892,8 +11090,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI @@ -10914,8 +11112,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -10932,8 +11130,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -10947,8 +11145,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Float. Controls randomness/creativity of responses. @@ -10967,8 +11165,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -10990,7 +11188,7 @@ paths: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: &ref_345 + properties: &ref_350 id: type: string description: >- @@ -11008,12 +11206,12 @@ paths: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: &ref_608 + oneOf: &ref_609 - description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: &ref_603 + allOf: &ref_604 - type: object properties: tool_type: @@ -11027,8 +11225,8 @@ paths: step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: &ref_89 + oneOf: *ref_93 + discriminator: &ref_94 propertyName: type mapping: rawscript: '#/components/schemas/schemas-RawScript' @@ -11046,7 +11244,7 @@ paths: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: &ref_604 + properties: &ref_605 tool_type: type: string enum: @@ -11070,7 +11268,7 @@ paths: MCP server items: type: string - required: &ref_605 + required: &ref_606 - tool_type - resource_path - type: object @@ -11078,26 +11276,32 @@ paths: A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: &ref_606 + properties: &ref_607 tool_type: type: string enum: - websearch - required: &ref_607 + required: &ref_608 - tool_type - discriminator: &ref_609 + discriminator: &ref_610 propertyName: tool_type mapping: flowmodule: '#/components/schemas/FlowModuleTool' mcp: '#/components/schemas/McpToolValue' websearch: '#/components/schemas/WebsearchToolValue' - required: &ref_346 + required: &ref_351 - id - value type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. + If not set, the AI agent step runs on + the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -11111,15 +11315,15 @@ paths: description: >- If true, the agent can execute multiple tool calls in parallel - required: &ref_340 + required: &ref_345 - tools - type - input_transforms - discriminator: *ref_89 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: &ref_90 + properties: &ref_95 skip_if_stopped: type: boolean description: >- @@ -11143,13 +11347,22 @@ paths: If empty string, a default error message is used. If null or omitted, no error is raised. - required: &ref_91 + error_include_result: + type: boolean + description: >- + When stopping with an error + (error_message set), embed the stopping + step's own result inside the raised + error object (as error.result) instead + of discarding it. The top-level result + stays { error }. Defaults to false. + required: &ref_96 - expr stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -11169,8 +11382,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: >- @@ -11183,8 +11396,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -11243,8 +11456,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: >- @@ -11275,7 +11488,7 @@ paths: Retry configuration for failed module executions type: object - properties: &ref_315 + properties: &ref_320 constant: type: object description: >- @@ -11316,14 +11529,14 @@ paths: description: >- Conditional retry based on error or result - properties: &ref_195 + properties: &ref_199 expr: type: string description: >- JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables - required: &ref_196 + required: &ref_200 - expr debouncing: description: >- @@ -11360,21 +11573,21 @@ paths: description: >- Maximum number of debounces before forced execution - required: *ref_83 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -11464,7 +11677,7 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: &ref_145 + properties: &ref_149 id: type: string description: Unique identifier for the note @@ -11523,7 +11736,7 @@ paths: description: >- For group notes, the IDs of nodes contained within this group - required: &ref_146 + required: &ref_150 - id - text - color @@ -11543,7 +11756,7 @@ paths: collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: &ref_147 + properties: &ref_151 summary: type: string description: Display name for this group @@ -11569,10 +11782,10 @@ paths: color: type: string description: Color for the group in the flow editor - required: &ref_148 + required: &ref_152 - start_id - end_id - required: &ref_611 + required: &ref_612 - modules schema: type: object @@ -11586,7 +11799,7 @@ paths: description: >- The flow will be run with the permissions of the user with this email. - required: &ref_121 + required: &ref_125 - summary - value /apps/hub/list: @@ -11639,7 +11852,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app @@ -11669,7 +11882,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: raw app @@ -11699,7 +11912,7 @@ paths: - name: custom_path in: path required: true - schema: &ref_133 + schema: &ref_137 type: string responses: '200': @@ -11709,7 +11922,7 @@ paths: schema: allOf: - type: object - properties: &ref_128 + properties: &ref_132 id: type: integer workspace_id: @@ -11730,7 +11943,7 @@ paths: value: {} policy: type: object - properties: &ref_127 + properties: &ref_131 triggerables: type: object additionalProperties: @@ -11783,7 +11996,7 @@ paths: items: type: string default: [] - required: &ref_129 + required: &ref_133 - id - workspace_id - path @@ -11810,7 +12023,7 @@ paths: - name: path in: path required: true - schema: &ref_92 + schema: &ref_97 type: string responses: '200': @@ -11829,7 +12042,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script details @@ -11860,7 +12073,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script pick recorded @@ -11923,7 +12136,7 @@ paths: type: number kind: type: string - enum: &ref_93 + enum: &ref_98 - script - failure - trigger @@ -11994,7 +12207,7 @@ paths: type: string kind: type: string - enum: *ref_93 + enum: *ref_98 score: type: number required: @@ -12056,7 +12269,7 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: &ref_118 + schema: &ref_122 type: boolean - name: created_by description: >- @@ -12064,7 +12277,7 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: &ref_119 + schema: &ref_123 type: string - name: path_start description: mask to filter matching starting path @@ -12204,183 +12417,225 @@ paths: schema: type: array items: - type: object - properties: &ref_99 - workspace_id: - type: string - hash: - type: string - path: - type: string - parent_hashes: - type: array - description: > - The first element is the direct parent of the script, - the second is the parent of the first, etc - items: - type: string - summary: - type: string - description: - type: string - content: - type: string - created_by: - type: string - created_at: - type: string - format: date-time - archived: - type: boolean - schema: - type: object - deleted: - type: boolean - is_template: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - lock: - type: string - lock_error_logs: - type: string - language: - type: string - enum: &ref_94 - - python3 - - deno - - go - - bash - - powershell - - postgresql - - mysql - - bigquery - - snowflake - - mssql - - oracledb - - graphql - - nativets - - bun - - php - - rust - - ansible - - csharp - - nu - - java - - ruby - - rlang - - duckdb - - bunnative - kind: - type: string - enum: - - script - - failure - - trigger - - command - - approval - - preprocessor - starred: - type: boolean - tag: - type: string - has_draft: - type: boolean - draft_only: - type: boolean - envs: - type: array - items: - type: string - concurrent_limit: - type: integer - concurrency_time_window_s: - type: integer - concurrency_key: - type: string - debounce_key: - type: string - debounce_delay_s: - type: integer - debounce_args_to_accumulate: - type: array - items: - type: string - max_total_debouncing_time: - type: integer - max_total_debounces_amount: - type: integer - cache_ttl: - type: number - dedicated_worker: - type: boolean - ws_error_handler_muted: - type: boolean - priority: - type: integer - restart_unless_cancelled: - type: boolean - timeout: - type: integer - delete_after_secs: - type: integer - description: >- - If set, delete the job's args, result and logs after - this many seconds following job completion - visible_to_runner_only: - type: boolean - auto_kind: - type: string - codebase: - type: string - has_preprocessor: - type: boolean - on_behalf_of_email: - type: string - modules: - type: object - nullable: true - description: Additional script modules keyed by relative file path - additionalProperties: - type: object - description: An additional module file associated with a script - properties: &ref_95 - content: + allOf: + - type: object + properties: &ref_105 + workspace_id: + type: string + hash: + type: string + path: + type: string + parent_hashes: + type: array + description: > + The first element is the direct parent of the + script, the second is the parent of the first, etc + items: type: string - description: The source code content of this module - language: + summary: + type: string + description: + type: string + content: + type: string + created_by: + type: string + created_at: + type: string + format: date-time + archived: + type: boolean + schema: + type: object + deleted: + type: boolean + is_template: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + lock: + type: string + lock_error_logs: + type: string + language: + type: string + enum: &ref_99 + - python3 + - deno + - go + - bash + - powershell + - postgresql + - mysql + - bigquery + - snowflake + - mssql + - oracledb + - graphql + - nativets + - bun + - php + - rust + - ansible + - csharp + - nu + - java + - ruby + - rlang + - duckdb + - bunnative + kind: + type: string + enum: + - script + - failure + - trigger + - command + - approval + - preprocessor + starred: + type: boolean + tag: + type: string + draft_only: + type: boolean + envs: + type: array + items: type: string - enum: *ref_94 - lock: + concurrent_limit: + type: integer + concurrency_time_window_s: + type: integer + concurrency_key: + type: string + debounce_key: + type: string + debounce_delay_s: + type: integer + debounce_args_to_accumulate: + type: array + items: type: string - nullable: true - description: Lock file content for this module's dependencies - required: &ref_96 - - content - - language - labels: - type: array - items: - type: string - default: [] - required: &ref_100 - - hash - - path - - summary - - description - - content - - created_by - - created_at - - archived - - deleted - - is_template - - extra_perms - - language - - kind - - starred - - has_preprocessor + max_total_debouncing_time: + type: integer + max_total_debounces_amount: + type: integer + cache_ttl: + type: number + dedicated_worker: + type: boolean + ws_error_handler_muted: + type: boolean + priority: + type: integer + restart_unless_cancelled: + type: boolean + timeout: + type: integer + delete_after_secs: + type: integer + description: >- + If set, delete the job's args, result and logs after + this many seconds following job completion + visible_to_runner_only: + type: boolean + auto_kind: + type: string + codebase: + type: string + has_preprocessor: + type: boolean + on_behalf_of_email: + type: string + modules: + type: object + nullable: true + description: >- + Additional script modules keyed by relative file + path + additionalProperties: + type: object + description: An additional module file associated with a script + properties: &ref_101 + content: + type: string + description: The source code content of this module + language: + type: string + enum: *ref_99 + lock: + type: string + nullable: true + description: >- + Lock file content for this module's + dependencies + required: &ref_102 + - content + - language + labels: + type: array + items: + type: string + default: [] + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_106 + - hash + - path + - summary + - description + - content + - created_by + - created_at + - archived + - deleted + - is_template + - extra_perms + - language + - kind + - starred + - has_preprocessor + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: summary: list all scripts paths @@ -12401,10 +12656,12 @@ paths: type: array items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: >- + list every draft the current user has in this workspace, across all + kinds + operationId: listDrafts tags: - draft parameters: @@ -12412,37 +12669,75 @@ paths: in: path required: true schema: *ref_4 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: - - flow - - script - - app - value: {} - required: - - path - - typ - - enum responses: - '201': - description: draft created + '200': + description: the user's drafts content: - text/plain: + application/json: schema: - type: string - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + type: array + items: + type: object + properties: + kind: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. + Mirrors the + + Postgres `DRAFT_KIND` enum and the backend + `UserDraftItemKind`. + enum: &ref_100 + - script + - flow + - app + - raw_app + - resource + - variable + - trigger_schedule + - trigger_webhook + - trigger_default_email + - trigger_email + - trigger_http + - trigger_websocket + - trigger_postgres + - trigger_kafka + - trigger_nats + - trigger_mqtt + - trigger_sqs + - trigger_gcp + - trigger_azure + - trigger_poll + - trigger_cli + - trigger_nextcloud + - trigger_google + - trigger_github + path: + type: string + summary: + type: string + description: >- + Best-effort, read from the draft JSON's `summary` field + when the editor shape carries one. + draft_only: + type: boolean + description: >- + No deployed counterpart exists at this path — the draft + is the whole item. + created_at: + type: string + format: date-time + required: + - kind + - path + - draft_only + - created_at + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: >- + fetch a single draft's content by workspace username (or the legacy + workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -12455,21 +12750,107 @@ paths: required: true schema: type: string - enum: - - script - - flow - - app + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 + - name: username + in: query + required: false + description: >- + Workspace username of the draft owner. Omit to fetch the legacy + workspace-level (NULL email) row. + schema: + type: string responses: '200': - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: + - value + - created_at + '404': + description: no draft for that owner at that path + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + tags: + - draft + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: kind + in: path + required: true + schema: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 + - name: path + in: path + required: true + schema: *ref_97 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + nullable: true + description: >- + Draft content to save. `null` (or omitted) signals a delete + — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: >- + Server timestamp of the client's last known sync for this + draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + responses: + '200': + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: + - saved + - conflict + current_timestamp: + type: string + format: date-time + required: + - status + - current_timestamp /w/{workspace}/scripts/create: post: summary: create script @@ -12507,7 +12888,7 @@ paths: application/json: schema: type: object - properties: &ref_104 + properties: &ref_392 path: type: string parent_hash: @@ -12526,7 +12907,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 kind: type: string enum: @@ -12538,8 +12919,6 @@ paths: - preprocessor tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -12611,7 +12990,7 @@ paths: type: string kind: type: string - enum: &ref_301 + enum: &ref_306 - s3object - resource - ducklake @@ -12636,8 +13015,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 labels: type: array items: @@ -12647,7 +13026,7 @@ paths: description: >- When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path. - required: &ref_105 + required: &ref_393 - path - summary - content @@ -12673,7 +13052,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -12780,19 +13159,19 @@ paths: application/json: schema: type: object - properties: &ref_385 + properties: &ref_390 workspace_id: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: type: string description: type: string content: type: string - required: &ref_386 + required: &ref_391 - workspace_id - language - content @@ -12819,7 +13198,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12847,7 +13226,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12879,7 +13258,7 @@ paths: type: array items: type: object - properties: &ref_97 + properties: &ref_103 id: type: integer archived: @@ -12892,13 +13271,13 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_id: type: string created_at: type: string format: date-time - required: &ref_98 + required: &ref_104 - workspace_id - language - created_at @@ -12921,7 +13300,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12934,8 +13313,8 @@ paths: application/json: schema: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 /w/{workspace}/scripts/archive/p/{path}: post: summary: archive script by path @@ -12950,7 +13329,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script archived @@ -12972,7 +13351,7 @@ paths: - name: hash in: path required: true - schema: &ref_101 + schema: &ref_107 type: string responses: '200': @@ -12981,8 +13360,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/h/{hash}: post: summary: delete script by hash (erase content but keep hash, require admin) @@ -12998,7 +13377,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13006,8 +13385,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/p/{path}: post: summary: delete script at a given path (require admin) @@ -13023,7 +13402,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -13085,20 +13464,63 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: script details content: application/json: schema: - type: object - properties: *ref_99 - required: *ref_100 + allOf: + - type: object + properties: *ref_105 + required: *ref_106 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/scripts/get_triggers_count/{path}: get: summary: get triggers count of script @@ -13113,7 +13535,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -13121,7 +13543,7 @@ paths: application/json: schema: type: object - properties: &ref_125 + properties: &ref_129 primary_schedule: type: object properties: @@ -13173,7 +13595,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -13183,50 +13605,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: script details - content: - application/json: - schema: - allOf: &ref_387 - - type: object - properties: *ref_104 - required: *ref_105 - - type: object - properties: - draft: - type: object - properties: *ref_104 - required: *ref_105 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. - hash: - type: string - required: - - hash + properties: *ref_108 + required: *ref_109 /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -13241,7 +13621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script history @@ -13251,12 +13631,12 @@ paths: type: array items: type: object - properties: &ref_106 + properties: &ref_110 script_hash: type: string deployment_msg: type: string - required: &ref_107 + required: &ref_111 - script_hash /w/{workspace}/scripts/list_paths_from_workspace_runnable/{path}: get: @@ -13272,7 +13652,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of script paths @@ -13294,7 +13674,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - script responses: @@ -13304,8 +13684,8 @@ paths: application/json: schema: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 /w/{workspace}/scripts/history_update/h/{hash}/p/{path}: post: summary: update history of a script @@ -13320,11 +13700,11 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Script deployment message required: true @@ -13412,7 +13792,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13436,12 +13816,12 @@ paths: - name: token in: path required: true - schema: &ref_305 + schema: &ref_310 type: string - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13463,7 +13843,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: does it exists @@ -13485,7 +13865,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: with_starred_info in: query schema: @@ -13501,8 +13881,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/raw/h/{path}: get: summary: raw script by hash @@ -13517,7 +13897,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13539,7 +13919,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13589,7 +13969,7 @@ paths: type: array items: type: object - properties: &ref_108 + properties: &ref_112 test_script_path: type: string job_id: @@ -13603,7 +13983,7 @@ paths: type: string format: date-time nullable: true - required: &ref_109 + required: &ref_113 - test_script_path /w/{workspace}/scripts/ci_test_results_batch: post: @@ -13652,8 +14032,8 @@ paths: type: array items: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 /w/{workspace}/scripts/raw_temp/store: post: summary: store raw script content temporarily for CLI lock generation @@ -13720,7 +14100,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: description: named workspace dependency (null for default) type: string @@ -13817,7 +14197,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -13839,20 +14219,20 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: &ref_110 + schema: &ref_114 type: string format: uuid - name: tag description: Override the tag to use in: query - schema: &ref_111 + schema: &ref_115 type: string - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: &ref_112 + schema: &ref_116 type: string - name: job_id description: >- @@ -13861,7 +14241,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: &ref_113 + schema: &ref_117 type: string format: uuid - name: invisible_to_owner @@ -13900,23 +14280,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13924,7 +14304,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -13933,19 +14313,19 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: &ref_114 + schema: &ref_118 type: string - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: &ref_115 + schema: &ref_119 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: &ref_116 + schema: &ref_120 type: boolean requestBody: description: script args @@ -13975,23 +14355,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13999,7 +14379,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14008,13 +14388,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14022,12 +14402,12 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: &ref_117 + schema: &ref_121 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 responses: '200': description: job result @@ -14048,7 +14428,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14057,13 +14437,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14071,11 +14451,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14123,13 +14503,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14137,11 +14517,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14188,13 +14568,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14202,7 +14582,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14210,11 +14590,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14241,7 +14621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14250,13 +14630,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14264,11 +14644,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14310,7 +14690,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14319,13 +14699,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14333,7 +14713,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14341,11 +14721,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14391,13 +14771,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14405,11 +14785,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14463,13 +14843,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14477,7 +14857,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14485,11 +14865,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14523,23 +14903,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14547,7 +14927,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14556,17 +14936,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14602,23 +14982,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14626,7 +15006,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14635,13 +15015,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14649,11 +15029,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14688,17 +15068,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14706,7 +15086,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14715,17 +15095,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14768,17 +15148,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14786,7 +15166,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14795,13 +15175,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14809,11 +15189,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14854,6 +15234,35 @@ paths: content: application/json: schema: {} + /w/{workspace}/jobs/job_view_token/{id}: + get: + summary: mint a read-only share token for a job + description: > + Returns a stateless `{job_id}.{hmac}` token that grants an authenticated + workspace member read access to this job (and its flow subtree) via a + `view_token` query param or `X-View-Token` header. Only callable by a + user who can already read the job. + operationId: getJobViewToken + tags: + - job + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: the share read token + content: + text/plain: + schema: + type: string /w/{workspace}/flows/list_paths: get: summary: list all flow paths @@ -14924,14 +15333,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15006,15 +15415,15 @@ paths: type: array items: allOf: - - allOf: &ref_124 + - allOf: &ref_128 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object - properties: &ref_512 + properties: &ref_514 workspace_id: type: string path: @@ -15028,7 +15437,7 @@ paths: type: boolean extra_perms: type: object - additionalProperties: &ref_511 + additionalProperties: &ref_513 type: boolean starred: type: boolean @@ -15053,7 +15462,15 @@ paths: items: type: string default: [] - required: &ref_513 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, + computed at read time. Read-only — edit them on + the folder. + required: &ref_515 - path - edited_by - edited_at @@ -15067,10 +15484,40 @@ paths: type: number - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: summary: get flow history by path @@ -15083,7 +15530,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15095,7 +15542,7 @@ paths: type: array items: type: object - properties: &ref_122 + properties: &ref_126 id: type: integer created_at: @@ -15103,7 +15550,7 @@ paths: format: date-time deployment_msg: type: string - required: &ref_123 + required: &ref_127 - id - created_at /w/{workspace}/flows/get_latest_version/{path}: @@ -15118,7 +15565,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15128,8 +15575,8 @@ paths: application/json: schema: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 /w/{workspace}/flows/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list flow paths from workspace runnable @@ -15144,7 +15591,7 @@ paths: - name: runnable_kind in: path required: true - schema: &ref_132 + schema: &ref_136 type: string enum: - script @@ -15152,7 +15599,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: match_path_start in: query schema: @@ -15188,7 +15635,7 @@ paths: content: application/json: schema: - allOf: *ref_124 + allOf: *ref_128 /w/{workspace}/flows/history_update/v/{version}: post: summary: update flow history @@ -15239,18 +15686,61 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: flow details content: application/json: schema: - allOf: *ref_124 + allOf: + - allOf: *ref_128 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/flows/deployment_status/p/{path}: get: summary: get flow deployment status @@ -15265,7 +15755,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow status @@ -15293,7 +15783,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -15301,7 +15791,7 @@ paths: application/json: schema: type: object - properties: *ref_125 + properties: *ref_129 /w/{workspace}/flows/list_tokens/{path}: get: summary: get tokens with flow scope @@ -15316,7 +15806,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -15326,8 +15816,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 /w/{workspace}/flows/toggle_workspace_error_handler/{path}: post: summary: Toggle ON and OFF the workspace error handler for a given flow @@ -15342,7 +15832,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -15360,40 +15850,6 @@ paths: text/plain: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: flow details with draft - content: - application/json: - schema: - allOf: - - allOf: *ref_124 - - type: object - properties: - draft: - allOf: *ref_124 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -15408,7 +15864,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow details @@ -15446,13 +15902,13 @@ paths: application/json: schema: allOf: - - allOf: &ref_126 + - allOf: &ref_130 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object properties: path: @@ -15485,8 +15941,6 @@ paths: - path - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -15528,7 +15982,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Partially filled flow required: true @@ -15536,7 +15990,7 @@ paths: application/json: schema: allOf: - - allOf: *ref_126 + - allOf: *ref_130 - type: object properties: deployment_message: @@ -15568,7 +16022,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: archiveFlow required: true @@ -15601,7 +16055,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -15647,14 +16101,14 @@ paths: type: array items: type: object - required: &ref_372 + required: &ref_377 - id - workspace_id - flow_path - created_at - updated_at - created_by - properties: &ref_373 + properties: &ref_378 id: type: string format: uuid @@ -15747,14 +16201,14 @@ paths: type: array items: type: object - required: &ref_374 + required: &ref_379 - id - conversation_id - message_type - content - created_at - created_seq - properties: &ref_375 + properties: &ref_380 id: type: string format: uuid @@ -15860,14 +16314,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15891,6 +16345,17 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: &ref_212 + type: boolean responses: '200': description: All raw apps @@ -15900,7 +16365,7 @@ paths: type: array items: type: object - properties: &ref_520 + properties: &ref_522 workspace_id: type: string path: @@ -15923,7 +16388,14 @@ paths: items: type: string default: [] - required: &ref_521 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_523 - workspace_id - path - summary @@ -16135,14 +16607,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -16189,7 +16661,7 @@ paths: type: array items: type: object - properties: &ref_514 + properties: &ref_516 id: type: integer workspace_id: @@ -16222,7 +16694,61 @@ paths: items: type: string default: [] - required: &ref_515 + is_draft: + type: boolean + description: > + True when the authed user has a draft for this app — + either no + + deployed row exists at this path (draft-only) or the + user has + + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: > + User-typed path the editor has staged but not yet + deployed. + + Sourced from the draft JSON's `draft_path` field (the + editor + + only writes it when the typed path differs from the + deployed + + one). Lets the home list render the meaningful name + instead of + + the autogenerated `u/{user}/draft_{uuid}` URL path. + Omitted + + when unchanged. + draft_users: + description: > + Workspace users (including the authed user, and the + legacy + + NULL-email row if any) who have a per-user draft at this + + path. Drives the home page's user-avatar circles inside + the + + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_517 - id - workspace_id - path @@ -16267,9 +16793,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16330,9 +16854,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16406,20 +16928,71 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: '200': description: app details content: application/json: schema: - type: object - properties: *ref_128 - required: *ref_129 + allOf: + - type: object + properties: *ref_132 + required: *ref_133 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/apps/get/lite/{path}: get: summary: get app lite by path @@ -16434,7 +17007,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app lite details @@ -16442,45 +17015,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: app details with draft - content: - application/json: - schema: - allOf: &ref_522 - - type: object - properties: *ref_128 - required: *ref_129 - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -16495,7 +17031,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app history @@ -16505,12 +17041,12 @@ paths: type: array items: type: object - properties: &ref_130 + properties: &ref_134 version: type: integer deployment_msg: type: string - required: &ref_131 + required: &ref_135 - version /w/{workspace}/apps/get_latest_version/{path}: get: @@ -16524,7 +17060,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - app responses: @@ -16534,8 +17070,8 @@ paths: application/json: schema: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 /w/{workspace}/apps/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list app paths from workspace runnable @@ -16550,11 +17086,11 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of app paths @@ -16578,11 +17114,11 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 - name: version in: path required: true - schema: &ref_306 + schema: &ref_311 type: integer requestBody: description: App deployment message @@ -16623,8 +17159,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps_u/public_resource/{path}: get: summary: get public resource @@ -16704,7 +17240,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app details @@ -16712,8 +17248,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/delete/{path}: delete: summary: delete app @@ -16760,7 +17296,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16776,7 +17312,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16817,7 +17353,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16836,7 +17372,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16883,7 +17419,7 @@ paths: - name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 responses: '200': description: custom path exists @@ -16914,7 +17450,7 @@ paths: type: array items: type: object - properties: &ref_134 + properties: &ref_138 s3: type: string filename: @@ -16923,7 +17459,7 @@ paths: type: string presigned: type: string - required: &ref_135 + required: &ref_139 - s3 required: - s3_objects @@ -16936,8 +17472,8 @@ paths: type: array items: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 /w/{workspace}/apps_u/execute_component/{path}: post: summary: executeComponent @@ -16952,7 +17488,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -17137,7 +17673,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17152,17 +17688,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17170,7 +17706,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17179,7 +17715,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17240,17 +17776,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17258,7 +17794,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17267,7 +17803,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17335,14 +17871,14 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_138 + oneOf: &ref_142 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 - type: object description: >- JavaScript expression evaluated at runtime. Can @@ -17351,16 +17887,16 @@ paths: 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_139 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_143 propertyName: type mapping: static: '#/components/schemas/schemas-StaticTransform' @@ -17380,8 +17916,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 use_latest_version: type: boolean responses: @@ -17407,7 +17943,7 @@ paths: - name: id in: path required: true - schema: &ref_172 + schema: &ref_176 type: string format: uuid - name: scheduled_for @@ -17426,11 +17962,11 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17438,7 +17974,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17447,7 +17983,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17521,7 +18057,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17536,23 +18072,23 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17560,7 +18096,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17569,7 +18105,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17609,7 +18145,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17622,7 +18158,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 requestBody: description: preview required: true @@ -17630,7 +18166,7 @@ paths: application/json: schema: type: object - properties: &ref_141 + properties: &ref_145 content: type: string description: The code to run @@ -17646,7 +18182,7 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 + enum: *ref_99 tag: type: string kind: @@ -17668,8 +18204,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 temp_script_refs: type: object nullable: true @@ -17679,7 +18215,7 @@ paths: local content instead of the deployed script additionalProperties: type: string - required: &ref_142 + required: &ref_146 - args responses: '201': @@ -17707,7 +18243,7 @@ paths: application/json: schema: type: object - properties: &ref_423 + properties: &ref_429 content: type: string description: The code to run @@ -17717,8 +18253,8 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 - required: &ref_424 + enum: *ref_99 + required: &ref_430 - content - args - language @@ -17742,7 +18278,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: script args required: true @@ -17750,7 +18286,7 @@ paths: application/json: schema: type: object - properties: &ref_140 + properties: &ref_144 args: type: object description: The arguments to pass to the script or flow @@ -17775,7 +18311,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 requestBody: description: script args required: true @@ -17783,7 +18319,7 @@ paths: application/json: schema: type: object - properties: *ref_140 + properties: *ref_144 responses: '200': description: script result @@ -17817,8 +18353,8 @@ paths: application/json: schema: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 responses: '200': description: job result @@ -17853,12 +18389,12 @@ paths: application/json: schema: type: object - properties: &ref_425 + properties: &ref_431 args: type: object description: The arguments to pass to the script or flow additionalProperties: true - required: &ref_426 + required: &ref_432 - args responses: '201': @@ -17891,15 +18427,15 @@ paths: type: array items: type: object - properties: &ref_143 + properties: &ref_147 raw_code: type: string path: type: string language: type: string - enum: *ref_94 - required: &ref_144 + enum: *ref_99 + required: &ref_148 - raw_code - path - language @@ -17943,8 +18479,8 @@ paths: type: array items: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 entrypoint: type: string required: @@ -17984,7 +18520,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_149 + properties: &ref_153 modules: type: array description: >- @@ -17995,22 +18531,22 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -18093,8 +18629,8 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 groups: type: array description: Semantic groups of modules for organizational purposes @@ -18107,9 +18643,9 @@ paths: naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 - required: &ref_150 + properties: *ref_151 + required: *ref_152 + required: &ref_154 - modules required: - path @@ -18141,7 +18677,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -18154,7 +18690,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -18168,14 +18704,14 @@ paths: application/json: schema: type: object - properties: &ref_152 + properties: &ref_156 value: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 path: type: string args: @@ -18186,7 +18722,7 @@ paths: type: string restarted_from: type: object - properties: &ref_151 + properties: &ref_155 flow_job_id: type: string format: uuid @@ -18219,7 +18755,7 @@ paths: `RestartedFlow` against `nested.flow_job_id` instead of fresh-launching it. type: object - properties: *ref_151 + properties: *ref_155 temp_script_refs: type: object nullable: true @@ -18230,7 +18766,7 @@ paths: of the deployed script additionalProperties: type: string - required: &ref_153 + required: &ref_157 - value - content - args @@ -18266,8 +18802,8 @@ paths: application/json: schema: type: object - properties: *ref_152 - required: *ref_153 + properties: *ref_156 + required: *ref_157 responses: '200': description: job result @@ -18292,7 +18828,7 @@ paths: application/json: schema: type: object - properties: &ref_529 + properties: &ref_530 entrypoint_function: type: string description: Name of the function to execute for dynamic select @@ -18313,7 +18849,7 @@ paths: description: Path to the deployed script or flow runnable_kind: type: string - enum: &ref_200 + enum: &ref_204 - script - flow required: @@ -18331,11 +18867,11 @@ paths: description: Code content for inline execution language: type: string - enum: *ref_94 + enum: *ref_99 required: - source - code - required: &ref_530 + required: &ref_531 - entrypoint_function - runnable_ref responses: @@ -18361,27 +18897,27 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: &ref_156 + schema: &ref_160 type: string - name: script_path_exact description: >- @@ -18389,7 +18925,7 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: &ref_157 + schema: &ref_161 type: string - name: script_path_start description: >- @@ -18397,12 +18933,12 @@ paths: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: &ref_158 + schema: &ref_162 type: string - name: schedule_path description: mask to filter by schedule path in: query - schema: &ref_159 + schema: &ref_163 type: string - name: trigger_path description: >- @@ -18410,7 +18946,7 @@ paths: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: &ref_307 + schema: &ref_312 type: string - name: trigger_kind description: >- @@ -18419,34 +18955,34 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: &ref_188 + schema: &ref_192 type: string - name: script_hash description: mask to filter exact matching path in: query - schema: &ref_160 + schema: &ref_164 type: string - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_161 + schema: &ref_165 type: string format: date-time - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_162 + schema: &ref_166 type: string format: date-time - name: success description: filter on successful jobs in: query - schema: &ref_170 + schema: &ref_174 type: boolean - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: &ref_164 + schema: &ref_168 type: boolean - name: job_kinds description: >- @@ -18454,36 +18990,36 @@ paths: ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: &ref_165 + schema: &ref_169 type: string - name: suspended description: filter on suspended jobs in: query - schema: &ref_166 + schema: &ref_170 type: boolean - name: running description: filter on running jobs in: query - schema: &ref_163 + schema: &ref_167 type: boolean - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: &ref_167 + schema: &ref_171 type: string - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: &ref_169 + schema: &ref_173 type: string - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: &ref_171 + schema: &ref_175 type: boolean - name: tag description: >- @@ -18491,7 +19027,7 @@ paths: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: &ref_168 + schema: &ref_172 type: string - name: page description: which page to return (start at 1, default 1) @@ -18522,7 +19058,7 @@ paths: type: array items: type: object - properties: &ref_191 + properties: &ref_195 workspace_id: type: string id: @@ -18597,14 +19133,14 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: &ref_175 + properties: &ref_179 step: type: integer modules: type: array items: type: object - properties: &ref_154 + properties: &ref_158 type: type: string enum: @@ -18758,20 +19294,20 @@ paths: type: array items: type: boolean - required: &ref_155 + required: &ref_159 - type user_states: additionalProperties: true preprocessor_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 failure_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 - type: object properties: parent_module: @@ -18786,13 +19322,13 @@ paths: items: type: string format: uuid - required: &ref_176 + required: &ref_180 - step - modules - failure_module workflow_as_code_status: type: object - properties: &ref_177 + properties: &ref_181 scheduled_for: type: string format: date-time @@ -18808,13 +19344,13 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -18835,7 +19371,7 @@ paths: type: boolean worker: type: string - required: &ref_192 + required: &ref_196 - id - running - canceled @@ -18951,14 +19487,14 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: &ref_174 + schema: &ref_178 type: string - name: worker description: >- @@ -18966,117 +19502,117 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: &ref_182 + schema: &ref_186 type: string format: date-time - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: &ref_183 + schema: &ref_187 type: string format: date-time - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_184 + schema: &ref_188 type: string format: date-time - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_185 + schema: &ref_189 type: string format: date-time - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: &ref_186 + schema: &ref_190 type: string format: date-time - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: &ref_187 + schema: &ref_191 type: string format: date-time - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: page description: which page to return (start at 1, default 1) in: query @@ -19105,6 +19641,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -19140,96 +19689,96 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19315,7 +19864,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: list of OTEL Span objects (compatible with OpenTelemetry Span proto) @@ -19343,7 +19892,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: &ref_173 + enum: &ref_177 - webhook - default_email - email @@ -19409,7 +19958,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 - name: trigger_path description: The path of the trigger (can contain forward slashes) in: path @@ -19456,98 +20005,111 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19585,7 +20147,7 @@ paths: type: array items: type: object - properties: &ref_189 + properties: &ref_193 workspace_id: type: string id: @@ -19662,23 +20224,23 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 raw_flow: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19703,7 +20265,7 @@ paths: type: boolean worker: type: string - required: &ref_190 + required: &ref_194 - id - created_by - duration_ms @@ -19747,7 +20309,7 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: &ref_178 + properties: &ref_182 id: type: string format: uuid @@ -19842,7 +20404,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19885,7 +20447,7 @@ paths: status: type: string description: Actual job status from database - required: &ref_179 + required: &ref_183 - id - created_by - created_at @@ -19913,8 +20475,8 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 responses: '200': description: Successfully imported completed jobs @@ -19951,7 +20513,7 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: &ref_180 + properties: &ref_184 id: type: string format: uuid @@ -20041,7 +20603,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -20082,7 +20644,7 @@ paths: suspend_until: type: string format: date-time - required: &ref_181 + required: &ref_185 - id - created_by - created_at @@ -20110,8 +20672,8 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 responses: '200': description: Successfully imported queued jobs @@ -20166,123 +20728,123 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: per_page description: number of items to return for a given page (default 30, max 100) in: query @@ -20294,7 +20856,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -20315,6 +20877,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -20349,11 +20924,11 @@ paths: schema: type: array items: - oneOf: &ref_193 + oneOf: &ref_197 - allOf: - type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 - type: object properties: type: @@ -20362,15 +20937,15 @@ paths: - CompletedJob - allOf: - type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 - type: object properties: type: type: string enum: - QueuedJob - discriminator: &ref_194 + discriminator: &ref_198 propertyName: type /jobs/db_clock: get: @@ -20438,7 +21013,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: no_logs in: query schema: @@ -20461,8 +21036,8 @@ paths: content: application/json: schema: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 /w/{workspace}/jobs_u/get_root_job_id/{id}: get: summary: get root job id @@ -20477,7 +21052,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: get root job id @@ -20500,7 +21075,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: remove_ansi_warnings in: query schema: @@ -20526,7 +21101,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concatenated logs of all flow steps @@ -20548,7 +21123,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: completed job logs tail @@ -20570,7 +21145,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job args @@ -20621,7 +21196,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20668,11 +21243,11 @@ paths: type: string flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 /w/{workspace}/jobs_u/getupdate_sse/{id}: get: summary: get job updates via server-sent events @@ -20687,7 +21262,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20760,7 +21335,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: flow debug info details @@ -20781,7 +21356,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20789,8 +21364,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/completed/get_result/{id}: get: summary: get completed job result @@ -20805,7 +21380,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: suspended_job in: query schema: @@ -20842,10 +21417,10 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: get_started in: query - schema: &ref_313 + schema: &ref_318 type: boolean responses: '200': @@ -20879,7 +21454,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job timing details @@ -20912,7 +21487,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20920,8 +21495,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/queue/cancel/{id}: post: summary: cancel queued or running job @@ -20936,7 +21511,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21000,7 +21575,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21062,7 +21637,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: scheduled for timestamp @@ -21084,7 +21659,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21115,7 +21690,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21165,7 +21740,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21226,7 +21801,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21414,7 +21989,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -21422,7 +21997,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: resume_id in: path required: true @@ -21457,7 +22032,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21499,7 +22074,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21531,7 +22106,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21557,7 +22132,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: required: true content: @@ -21585,7 +22160,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21620,7 +22195,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21662,7 +22237,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21686,8 +22261,8 @@ paths: type: object properties: job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 approvers: type: array items: @@ -21762,7 +22337,7 @@ paths: application/json: schema: type: object - properties: &ref_436 + properties: &ref_438 path: type: string description: >- @@ -21855,7 +22430,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: &ref_197 + properties: &ref_201 constant: type: object description: Retry with constant delay between attempts @@ -21890,8 +22465,8 @@ paths: retry_if: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 no_flow_overlap: type: boolean description: >- @@ -21944,7 +22519,7 @@ paths: type: array items: type: string - required: &ref_437 + required: &ref_439 - path - schedule - timezone @@ -21988,7 +22563,7 @@ paths: application/json: schema: type: object - properties: &ref_438 + properties: &ref_440 schedule: type: string description: >- @@ -22063,7 +22638,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 no_flow_overlap: type: boolean description: >- @@ -22119,7 +22694,7 @@ paths: type: array items: type: string - required: &ref_439 + required: &ref_441 - schedule - timezone - args @@ -22208,189 +22783,257 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: schedule deleted content: application/json: schema: - type: object - properties: &ref_198 - path: - type: string - description: >- - The unique Windmill path for this schedule. Must be of the - form `u//` or `f//`. - edited_by: - type: string - description: Username of the last person who edited this schedule - edited_at: - type: string - format: date-time - description: Timestamp of the last edit - schedule: - type: string - description: >- - Cron expression with 6 fields (seconds, minutes, hours, - day of month, month, day of week). Example '0 0 12 * * *' - for daily at noon - timezone: - type: string - description: >- - IANA timezone for the schedule (e.g., 'UTC', - 'Europe/Paris', 'America/New_York') - enabled: - type: boolean - description: >- - Whether the schedule is currently active and will trigger - jobs - script_path: - type: string - description: Path to the script or flow to execute when triggered - is_flow: - type: boolean - description: >- - True if script_path points to a flow, false if it points - to a script - args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - extra_perms: - type: object - additionalProperties: - type: boolean - description: Additional permissions for this schedule - email: - type: string - description: >- - Email of the user who owns this schedule, used for - permissioned_as - permissioned_as: - type: string - description: >- - The user or group this schedule runs as (e.g., 'u/admin' - or 'g/mygroup') - error: - type: string - nullable: true - description: Last error message if the schedule failed to trigger - on_failure: - type: string - nullable: true - description: >- - Path to a script or flow to run when the scheduled job - fails - on_failure_times: - type: number - nullable: true - description: >- - Number of consecutive failures before the on_failure - handler is triggered (default 1) - on_failure_exact: - type: boolean - nullable: true - description: >- - If true, trigger on_failure handler only on exactly N - failures, not on every failure after N - on_failure_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_recovery: - type: string - nullable: true - description: >- - Path to a script or flow to run when the schedule recovers - after failures - on_recovery_times: - type: number - nullable: true - description: >- - Number of consecutive successes before the on_recovery - handler is triggered (default 1) - on_recovery_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_success: - type: string - nullable: true - description: >- - Path to a script or flow to run after each successful - execution - on_success_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - ws_error_handler_muted: - type: boolean - description: >- - If true, the workspace-level error handler will not be - triggered for this schedule's failures - retry: - nullable: true - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - summary: - type: string - nullable: true - description: Short summary describing the purpose of this schedule - description: - type: string - nullable: true - description: Detailed description of what this schedule does - no_flow_overlap: - type: boolean - description: >- - If true, skip this schedule's execution if the previous - run is still in progress (prevents concurrent runs) - tag: - type: string - nullable: true - description: Worker tag to route jobs to specific worker groups - paused_until: - type: string - format: date-time - nullable: true - description: >- - ISO 8601 datetime until which the schedule is paused. - Schedule resumes automatically after this time - cron_version: - type: string - nullable: true - description: >- - Cron parser version. Use 'v2' for extended syntax with - additional features - dynamic_skip: - type: string - nullable: true - description: >- - Path to a script that validates scheduled datetimes. - Receives scheduled_for datetime and returns boolean to - skip (true) or run (false) - labels: - type: array - items: - type: string - default: [] - required: &ref_199 - - path - - edited_by - - edited_at - - schedule - - script_path - - timezone - - extra_perms - - is_flow - - enabled - - email - - permissioned_as + allOf: + - type: object + properties: &ref_202 + path: + type: string + description: >- + The unique Windmill path for this schedule. Must be of + the form `u//` or `f//`. + edited_by: + type: string + description: Username of the last person who edited this schedule + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + schedule: + type: string + description: >- + Cron expression with 6 fields (seconds, minutes, + hours, day of month, month, day of week). Example '0 0 + 12 * * *' for daily at noon + timezone: + type: string + description: >- + IANA timezone for the schedule (e.g., 'UTC', + 'Europe/Paris', 'America/New_York') + enabled: + type: boolean + description: >- + Whether the schedule is currently active and will + trigger jobs + script_path: + type: string + description: Path to the script or flow to execute when triggered + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + extra_perms: + type: object + additionalProperties: + type: boolean + description: Additional permissions for this schedule + email: + type: string + description: >- + Email of the user who owns this schedule, used for + permissioned_as + permissioned_as: + type: string + description: >- + The user or group this schedule runs as (e.g., + 'u/admin' or 'g/mygroup') + error: + type: string + nullable: true + description: Last error message if the schedule failed to trigger + on_failure: + type: string + nullable: true + description: >- + Path to a script or flow to run when the scheduled job + fails + on_failure_times: + type: number + nullable: true + description: >- + Number of consecutive failures before the on_failure + handler is triggered (default 1) + on_failure_exact: + type: boolean + nullable: true + description: >- + If true, trigger on_failure handler only on exactly N + failures, not on every failure after N + on_failure_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_recovery: + type: string + nullable: true + description: >- + Path to a script or flow to run when the schedule + recovers after failures + on_recovery_times: + type: number + nullable: true + description: >- + Number of consecutive successes before the on_recovery + handler is triggered (default 1) + on_recovery_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_success: + type: string + nullable: true + description: >- + Path to a script or flow to run after each successful + execution + on_success_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + ws_error_handler_muted: + type: boolean + description: >- + If true, the workspace-level error handler will not be + triggered for this schedule's failures + retry: + nullable: true + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + summary: + type: string + nullable: true + description: Short summary describing the purpose of this schedule + description: + type: string + nullable: true + description: Detailed description of what this schedule does + no_flow_overlap: + type: boolean + description: >- + If true, skip this schedule's execution if the + previous run is still in progress (prevents concurrent + runs) + tag: + type: string + nullable: true + description: Worker tag to route jobs to specific worker groups + paused_until: + type: string + format: date-time + nullable: true + description: >- + ISO 8601 datetime until which the schedule is paused. + Schedule resumes automatically after this time + cron_version: + type: string + nullable: true + description: >- + Cron parser version. Use 'v2' for extended syntax with + additional features + dynamic_skip: + type: string + nullable: true + description: >- + Path to a script that validates scheduled datetimes. + Receives scheduled_for datetime and returns boolean to + skip (true) or run (false) + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + schedule at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_203 + - path + - edited_by + - edited_at + - schedule + - script_path + - timezone + - extra_perms + - is_flow + - enabled + - email + - permissioned_as + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/schedules/exists/{path}: get: summary: does schedule exists @@ -22438,7 +23081,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: path description: filter by path (script path) in: query @@ -22482,6 +23125,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: schedule list @@ -22491,8 +23143,8 @@ paths: type: array items: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 /w/{workspace}/schedules/list_with_jobs: get: summary: list schedules with last 20 jobs @@ -22520,10 +23172,10 @@ paths: schema: type: array items: - allOf: &ref_435 + allOf: &ref_437 - type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 - type: object properties: jobs: @@ -22601,10 +23253,10 @@ paths: application/json: schema: type: object - properties: &ref_201 + properties: &ref_205 info: type: object - properties: &ref_445 + properties: &ref_447 title: type: string version: @@ -22633,28 +23285,28 @@ paths: type: string required: - name - required: &ref_446 + required: &ref_448 - title - version url: type: string openapi_spec_format: type: string - enum: &ref_440 + enum: &ref_442 - yaml - json http_route_filters: type: array items: type: object - properties: &ref_441 + properties: &ref_443 folder_regex: type: string path_regex: type: string route_path_regex: type: string - required: &ref_442 + required: &ref_444 - folder_regex - path_regex - route_path_regex @@ -22662,7 +23314,7 @@ paths: type: array items: type: object - properties: &ref_443 + properties: &ref_445 user_or_folder_regex: type: string enum: @@ -22675,8 +23327,8 @@ paths: type: string runnable_kind: type: string - enum: *ref_200 - required: &ref_444 + enum: *ref_204 + required: &ref_446 - user_or_folder_regex - user_or_folder_regex_value - path @@ -22705,7 +23357,7 @@ paths: application/json: schema: type: object - properties: *ref_201 + properties: *ref_205 responses: '200': description: Downloaded OpenAPI spec @@ -22734,7 +23386,7 @@ paths: type: array items: type: object - properties: &ref_202 + properties: &ref_206 path: type: string description: >- @@ -22788,7 +23440,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: &ref_204 + enum: &ref_208 - get - post - put @@ -22810,7 +23462,7 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: &ref_205 + enum: &ref_209 - sync - async - sync_sse @@ -22820,7 +23472,7 @@ paths: 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: &ref_206 + enum: &ref_210 - none - windmill - api_key @@ -22838,7 +23490,7 @@ paths: mode: description: job trigger mode type: string - enum: &ref_207 + enum: &ref_211 - enabled - disabled - suspended @@ -22859,7 +23511,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -22875,7 +23527,7 @@ paths: type: array items: type: string - required: &ref_203 + required: &ref_207 - path - script_path - route_path @@ -22908,8 +23560,8 @@ paths: application/json: schema: type: object - properties: *ref_202 - required: *ref_203 + properties: *ref_206 + required: *ref_207 responses: '201': description: http trigger created @@ -22939,7 +23591,7 @@ paths: application/json: schema: type: object - properties: &ref_447 + properties: &ref_449 path: type: string description: >- @@ -22999,7 +23651,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_204 + enum: *ref_208 is_async: type: boolean description: Deprecated, use request_type instead @@ -23009,14 +23661,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_205 + enum: *ref_209 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_206 + enum: *ref_210 is_static_website: type: boolean description: >- @@ -23040,7 +23692,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23056,7 +23708,7 @@ paths: type: array items: type: string - required: &ref_448 + required: &ref_450 - path - script_path - is_flow @@ -23108,167 +23760,239 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: http trigger deleted content: application/json: schema: - allOf: &ref_208 - - type: object + allOf: + - allOf: &ref_213 + - type: object + properties: &ref_219 + path: + type: string + description: >- + The unique Windmill path for this trigger. Must be + of the form `u//` or + `f//`. This is the trigger object + path, not the HTTP route path. + script_path: + type: string + description: >- + Path to the script or flow to execute when + triggered + permissioned_as: + type: string + description: >- + The user or group this trigger runs as + (permissioned_as) + extra_perms: + type: object + description: Additional permissions for this trigger + additionalProperties: + type: boolean + workspace_id: + type: string + description: The workspace this trigger belongs to + edited_by: + type: string + description: >- + Username of the last person who edited this + trigger + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + mode: + description: job trigger mode + type: string + enum: *ref_211 + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + trigger at the same path. Set by list endpoints + when + + `include_draft_only=true` synthesizes the row from + the + + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at + this path + + (over a deployed row or a synthesized draft-only + row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_220 + - path + - script_path + - permissioned_as + - extra_perms + - workspace_id + - edited_by + - edited_at + - is_flow + - mode + type: object properties: &ref_214 - path: + route_path: type: string description: >- - The unique Windmill path for this trigger. Must be of - the form `u//` or `f//`. - This is the trigger object path, not the HTTP route - path. - script_path: - type: string - description: Path to the script or flow to execute when triggered - permissioned_as: - type: string - description: >- - The user or group this trigger runs as - (permissioned_as) - extra_perms: + The URL route path that will trigger this endpoint + (e.g., 'api/myendpoint'). Must NOT start with a /. + static_asset_config: type: object - description: Additional permissions for this trigger - additionalProperties: - type: boolean - workspace_id: + nullable: true + description: >- + Configuration for serving static assets (s3 bucket, + storage path, filename) + properties: + s3: + type: string + description: S3 bucket path for static assets + storage: + type: string + description: Storage path for static assets + filename: + type: string + description: Filename for the static asset + required: + - s3 + http_method: + description: >- + HTTP method (get, post, put, delete, patch) that + triggers this endpoint type: string - description: The workspace this trigger belongs to - edited_by: + enum: *ref_208 + authentication_resource_path: type: string - description: Username of the last person who edited this trigger - edited_at: + nullable: true + description: >- + Path to the resource containing authentication + configuration (for api_key, basic_http, custom_script, + signature methods) + summary: type: string - format: date-time - description: Timestamp of the last edit - is_flow: + nullable: true + description: Short summary describing the purpose of this trigger + description: + type: string + nullable: true + description: Detailed description of what this trigger does + request_type: + description: >- + How the request is handled - 'sync' waits for result, + 'async' returns job ID immediately, 'sync_sse' streams + results via Server-Sent Events + type: string + enum: *ref_209 + authentication_method: + description: >- + How requests are authenticated - 'none' (public), + 'windmill' (Windmill token), 'api_key', 'basic_http', + 'custom_script', 'signature' + type: string + enum: *ref_210 + is_static_website: type: boolean description: >- - True if script_path points to a flow, false if it - points to a script - mode: - description: job trigger mode + If true, serves static files from S3/storage instead + of running a script + workspaced_route: + type: boolean + description: >- + If true, the route includes the workspace ID in the + path + wrap_body: + type: boolean + description: If true, wraps the request body in a 'body' parameter + raw_string: + type: boolean + description: >- + If true, passes the request body as a raw string + instead of parsing as JSON + error_handler_path: type: string - enum: *ref_207 - labels: - type: array - items: - type: string - default: [] + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 required: &ref_215 - - path - - script_path - - permissioned_as - - extra_perms - - workspace_id - - edited_by - - edited_at - - is_flow - - mode - type: object - properties: &ref_209 - route_path: - type: string - description: >- - The URL route path that will trigger this endpoint (e.g., - 'api/myendpoint'). Must NOT start with a /. - static_asset_config: - type: object - nullable: true - description: >- - Configuration for serving static assets (s3 bucket, - storage path, filename) - properties: - s3: - type: string - description: S3 bucket path for static assets - storage: - type: string - description: Storage path for static assets - filename: - type: string - description: Filename for the static asset - required: - - s3 - http_method: - description: >- - HTTP method (get, post, put, delete, patch) that triggers - this endpoint - type: string - enum: *ref_204 - authentication_resource_path: - type: string - nullable: true - description: >- - Path to the resource containing authentication - configuration (for api_key, basic_http, custom_script, - signature methods) - summary: - type: string - nullable: true - description: Short summary describing the purpose of this trigger - description: - type: string - nullable: true - description: Detailed description of what this trigger does - request_type: - description: >- - How the request is handled - 'sync' waits for result, - 'async' returns job ID immediately, 'sync_sse' streams - results via Server-Sent Events - type: string - enum: *ref_205 - authentication_method: - description: >- - How requests are authenticated - 'none' (public), - 'windmill' (Windmill token), 'api_key', 'basic_http', - 'custom_script', 'signature' - type: string - enum: *ref_206 - is_static_website: - type: boolean - description: >- - If true, serves static files from S3/storage instead of - running a script - workspaced_route: - type: boolean - description: If true, the route includes the workspace ID in the path - wrap_body: - type: boolean - description: If true, wraps the request body in a 'body' parameter - raw_string: - type: boolean - description: >- - If true, passes the request body as a raw string instead - of parsing as JSON - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_210 - - route_path - - request_type - - authentication_method - - http_method - - is_static_website - - workspaced_route - - wrap_body - - raw_string + - route_path + - request_type + - authentication_method + - http_method + - is_static_website + - workspaced_route + - wrap_body + - raw_string + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/http_triggers/list: get: summary: list http triggers @@ -23307,6 +24031,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: http trigger list @@ -23315,10 +24049,10 @@ paths: schema: type: array items: - allOf: *ref_208 + allOf: *ref_213 type: object - properties: *ref_209 - required: *ref_210 + properties: *ref_214 + required: *ref_215 /w/{workspace}/http_triggers/exists/{path}: get: summary: does http trigger exists @@ -23364,7 +24098,7 @@ paths: type: string http_method: type: string - enum: *ref_204 + enum: *ref_208 trigger_path: type: string workspaced_route: @@ -23404,7 +24138,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -23437,7 +24171,7 @@ paths: application/json: schema: type: object - properties: &ref_449 + properties: &ref_451 path: type: string description: >- @@ -23462,7 +24196,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 filters: type: array description: >- @@ -23493,7 +24227,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: &ref_211 + anyOf: &ref_216 - type: object properties: raw_message: @@ -23536,7 +24270,7 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: &ref_212 + properties: &ref_217 interval_secs: type: integer minimum: 1 @@ -23553,7 +24287,7 @@ paths: Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message. - required: &ref_213 + required: &ref_218 - interval_secs - message error_handler_path: @@ -23566,7 +24300,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23582,7 +24316,7 @@ paths: type: array items: type: string - required: &ref_450 + required: &ref_452 - path - script_path - url @@ -23619,7 +24353,7 @@ paths: application/json: schema: type: object - properties: &ref_451 + properties: &ref_453 url: type: string description: >- @@ -23671,7 +24405,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_211 + anyOf: *ref_216 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -23689,8 +24423,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_212 - required: *ref_213 + properties: *ref_217 + required: *ref_218 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23701,7 +24435,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23717,7 +24451,7 @@ paths: type: array items: type: string - required: &ref_452 + required: &ref_454 - path - script_path - url @@ -23769,103 +24503,149 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: websocket trigger deleted content: application/json: schema: - allOf: &ref_216 + allOf: + - allOf: &ref_221 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_222 + url: + type: string + description: >- + The WebSocket URL to connect to (can be a static URL + or computed by a runnable) + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + filters: + type: array + description: >- + Array of key-value filters to match incoming messages + (only matching messages trigger the script) + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + initial_messages: + type: array + nullable: true + description: >- + Messages to send immediately after connecting (can be + raw strings or computed by runnables) + items: + anyOf: *ref_216 + url_runnable_args: + description: The arguments to pass to the script or flow + nullable: true + type: object + additionalProperties: true + can_return_message: + type: boolean + description: >- + If true, the script can return a message to send back + through the WebSocket + can_return_error_result: + type: boolean + description: >- + If true, error results are sent back through the + WebSocket + heartbeat: + nullable: true + description: Optional periodic heartbeat message configuration + type: object + properties: *ref_217 + required: *ref_218 + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_223 + - url + - filters + - can_return_message + - can_return_error_result - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_217 - url: - type: string - description: >- - The WebSocket URL to connect to (can be a static URL or - computed by a runnable) - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - filters: - type: array - description: >- - Array of key-value filters to match incoming messages - (only matching messages trigger the script) - items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - initial_messages: - type: array - nullable: true - description: >- - Messages to send immediately after connecting (can be raw - strings or computed by runnables) - items: - anyOf: *ref_211 - url_runnable_args: - description: The arguments to pass to the script or flow - nullable: true - type: object - additionalProperties: true - can_return_message: - type: boolean - description: >- - If true, the script can return a message to send back - through the WebSocket - can_return_error_result: - type: boolean - description: If true, error results are sent back through the WebSocket - heartbeat: - nullable: true - description: Optional periodic heartbeat message configuration - type: object - properties: *ref_212 - required: *ref_213 - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_218 - - url - - filters - - can_return_message - - can_return_error_result + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/websocket_triggers/list: get: summary: list websocket triggers @@ -23904,6 +24684,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: websocket trigger list @@ -23912,10 +24702,10 @@ paths: schema: type: array items: - allOf: *ref_216 + allOf: *ref_221 type: object - properties: *ref_217 - required: *ref_218 + properties: *ref_222 + required: *ref_223 /w/{workspace}/websocket_triggers/exists/{path}: get: summary: does websocket trigger exists @@ -23964,7 +24754,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24034,7 +24824,7 @@ paths: application/json: schema: type: object - properties: &ref_484 + properties: &ref_486 path: type: string description: >- @@ -24103,7 +24893,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24114,7 +24904,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24130,7 +24920,7 @@ paths: type: array items: type: string - required: &ref_485 + required: &ref_487 - path - script_path - is_flow @@ -24167,7 +24957,7 @@ paths: application/json: schema: type: object - properties: &ref_486 + properties: &ref_488 kafka_resource_path: type: string description: >- @@ -24243,7 +25033,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24259,7 +25049,7 @@ paths: type: array items: type: string - required: &ref_487 + required: &ref_489 - path - script_path - kafka_resource_path @@ -24311,98 +25101,143 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: kafka trigger deleted content: application/json: schema: - allOf: &ref_219 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_220 - kafka_resource_path: - type: string - description: >- - Path to the Kafka resource containing connection - configuration - group_id: - type: string - description: Kafka consumer group ID for this trigger - topics: - type: array - items: - type: string - description: Array of Kafka topic names to subscribe to - filters: - type: array - items: - type: object - properties: - key: + allOf: + - allOf: &ref_224 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_225 + kafka_resource_path: + type: string + description: >- + Path to the Kafka resource containing connection + configuration + group_id: + type: string + description: Kafka consumer group ID for this trigger + topics: + type: array + items: type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - auto_offset_reset: - type: string - enum: - - latest - - earliest - default: latest - description: >- - Initial offset behavior when consumer group has no - committed offset. 'latest' starts from new messages only, - 'earliest' starts from the beginning. - auto_commit: - type: boolean - default: true - description: >- - When true (default), offsets are committed automatically - after receiving each message. When false, you must - manually commit offsets using the commit_offsets endpoint. - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_221 - - kafka_resource_path - - group_id - - topics - - filters + description: Array of Kafka topic names to subscribe to + filters: + type: array + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + auto_offset_reset: + type: string + enum: + - latest + - earliest + default: latest + description: >- + Initial offset behavior when consumer group has no + committed offset. 'latest' starts from new messages + only, 'earliest' starts from the beginning. + auto_commit: + type: boolean + default: true + description: >- + When true (default), offsets are committed + automatically after receiving each message. When + false, you must manually commit offsets using the + commit_offsets endpoint. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_226 + - kafka_resource_path + - group_id + - topics + - filters + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/kafka_triggers/list: get: summary: list kafka triggers @@ -24441,6 +25276,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: kafka trigger list @@ -24449,10 +25294,10 @@ paths: schema: type: array items: - allOf: *ref_219 + allOf: *ref_224 type: object - properties: *ref_220 - required: *ref_221 + properties: *ref_225 + required: *ref_226 /w/{workspace}/kafka_triggers/exists/{path}: get: summary: does kafka trigger exists @@ -24501,7 +25346,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24620,7 +25465,7 @@ paths: application/json: schema: type: object - properties: &ref_488 + properties: &ref_490 path: type: string description: >- @@ -24663,7 +25508,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24674,7 +25519,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24690,7 +25535,7 @@ paths: type: array items: type: string - required: &ref_489 + required: &ref_491 - path - script_path - is_flow @@ -24726,7 +25571,7 @@ paths: application/json: schema: type: object - properties: &ref_490 + properties: &ref_492 nats_resource_path: type: string description: >- @@ -24776,7 +25621,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24792,7 +25637,7 @@ paths: type: array items: type: string - required: &ref_491 + required: &ref_493 - path - script_path - nats_resource_path @@ -24843,72 +25688,117 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: nats trigger deleted content: application/json: schema: - allOf: &ref_222 + allOf: + - allOf: &ref_227 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_228 + nats_resource_path: + type: string + description: >- + Path to the NATS resource containing connection + configuration + use_jetstream: + type: boolean + description: >- + If true, uses NATS JetStream for durable message + delivery + stream_name: + type: string + nullable: true + description: >- + JetStream stream name (required when use_jetstream is + true) + consumer_name: + type: string + nullable: true + description: >- + JetStream consumer name (required when use_jetstream + is true) + subjects: + type: array + items: + type: string + description: Array of NATS subjects to subscribe to + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_229 + - nats_resource_path + - use_jetstream + - subjects - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_223 - nats_resource_path: - type: string - description: >- - Path to the NATS resource containing connection - configuration - use_jetstream: - type: boolean - description: If true, uses NATS JetStream for durable message delivery - stream_name: - type: string - nullable: true - description: >- - JetStream stream name (required when use_jetstream is - true) - consumer_name: - type: string - nullable: true - description: >- - JetStream consumer name (required when use_jetstream is - true) - subjects: - type: array - items: - type: string - description: Array of NATS subjects to subscribe to - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_224 - - nats_resource_path - - use_jetstream - - subjects + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/nats_triggers/list: get: summary: list nats triggers @@ -24947,6 +25837,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: nats trigger list @@ -24955,10 +25855,10 @@ paths: schema: type: array items: - allOf: *ref_222 + allOf: *ref_227 type: object - properties: *ref_223 - required: *ref_224 + properties: *ref_228 + required: *ref_229 /w/{workspace}/nats_triggers/exists/{path}: get: summary: does nats trigger exists @@ -25007,7 +25907,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25070,7 +25970,7 @@ paths: application/json: schema: type: object - properties: &ref_471 + properties: &ref_473 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25079,7 +25979,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: &ref_225 + enum: &ref_230 - oidc - credentials aws_resource_path: @@ -25114,7 +26014,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25125,7 +26025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25141,7 +26041,7 @@ paths: type: array items: type: string - required: &ref_472 + required: &ref_474 - queue_url - aws_resource_path - path @@ -25177,7 +26077,7 @@ paths: application/json: schema: type: object - properties: &ref_473 + properties: &ref_475 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25186,7 +26086,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_225 + enum: *ref_230 aws_resource_path: type: string description: >- @@ -25219,7 +26119,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25230,7 +26130,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25246,7 +26146,7 @@ paths: type: array items: type: string - required: &ref_474 + required: &ref_476 - queue_url - aws_resource_path - path @@ -25298,69 +26198,112 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: sqs trigger deleted content: application/json: schema: - allOf: &ref_226 + allOf: + - allOf: &ref_231 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_232 + queue_url: + type: string + description: The full URL of the AWS SQS queue to poll for messages + aws_auth_resource_type: + description: >- + Authentication type - 'credentials' for access + key/secret, 'oidc' for OpenID Connect + type: string + enum: *ref_230 + aws_resource_path: + type: string + description: >- + Path to the AWS resource containing credentials or + OIDC configuration + message_attributes: + type: array + nullable: true + items: + type: string + description: >- + Array of SQS message attribute names to include with + each message + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_233 + - queue_url + - aws_resource_path + - aws_auth_resource_type - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_227 - queue_url: - type: string - description: The full URL of the AWS SQS queue to poll for messages - aws_auth_resource_type: - description: >- - Authentication type - 'credentials' for access key/secret, - 'oidc' for OpenID Connect - type: string - enum: *ref_225 - aws_resource_path: - type: string - description: >- - Path to the AWS resource containing credentials or OIDC - configuration - message_attributes: - type: array - nullable: true - items: - type: string - description: >- - Array of SQS message attribute names to include with each - message - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_228 - - queue_url - - aws_resource_path - - aws_auth_resource_type + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/sqs_triggers/list: get: summary: list sqs triggers @@ -25399,6 +26342,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: sqs trigger list @@ -25407,10 +26360,10 @@ paths: schema: type: array items: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 /w/{workspace}/sqs_triggers/exists/{path}: get: summary: does sqs trigger exists @@ -25459,7 +26412,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25524,17 +26477,17 @@ paths: type: array items: type: object - properties: &ref_576 + properties: &ref_577 service_name: type: string - enum: &ref_229 + enum: &ref_234 - nextcloud - google - github oauth_data: nullable: true type: object - properties: &ref_230 + properties: &ref_235 client_id: type: string description: The OAuth client ID for the workspace @@ -25549,7 +26502,7 @@ paths: type: string format: uri description: The OAuth redirect URI - required: &ref_231 + required: &ref_236 - client_id - client_secret - base_url @@ -25558,7 +26511,7 @@ paths: type: string nullable: true description: Path to the resource storing the OAuth token - required: &ref_577 + required: &ref_578 - service_name /w/{workspace}/native_triggers/integrations/{service_name}/exists: get: @@ -25576,7 +26529,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: integration exists @@ -25600,7 +26553,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger service required: true @@ -25608,8 +26561,8 @@ paths: application/json: schema: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 responses: '201': description: native trigger service created @@ -25633,7 +26586,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25641,10 +26594,10 @@ paths: application/json: schema: type: object - properties: &ref_232 + properties: &ref_237 redirect_uri: type: string - required: &ref_233 + required: &ref_238 - redirect_uri responses: '200': @@ -25669,7 +26622,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: whether instance sharing is available @@ -25693,7 +26646,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25701,8 +26654,8 @@ paths: application/json: schema: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 responses: '200': description: authorization URL using instance credentials @@ -25726,7 +26679,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: native trigger service deleted @@ -25750,7 +26703,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: OAuth callback data required: true @@ -25799,7 +26752,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger configuration required: true @@ -25808,7 +26761,7 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: &ref_234 + properties: &ref_239 script_path: type: string description: The path to the script or flow that will be triggered @@ -25825,7 +26778,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_235 + required: &ref_240 - script_path - is_flow - service_config @@ -25837,13 +26790,13 @@ paths: schema: type: object description: Response returned when a native trigger is created - properties: &ref_579 + properties: &ref_580 external_id: type: string description: >- The external ID of the created trigger from the external service - required: &ref_580 + required: &ref_581 - external_id /w/{workspace}/native_triggers/{service_name}/update/{external_id}: post: @@ -25866,7 +26819,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25881,8 +26834,8 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 responses: '200': description: native trigger updated @@ -25911,7 +26864,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25928,7 +26881,7 @@ paths: description: >- Full trigger response containing both Windmill data and external service data - properties: &ref_574 + properties: &ref_575 external_id: type: string description: The unique identifier from the external service @@ -25937,7 +26890,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -25964,7 +26917,7 @@ paths: type: object description: Configuration data from the external service additionalProperties: true - required: &ref_575 + required: &ref_576 - external_id - workspace_id - service_name @@ -25993,7 +26946,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26024,7 +26977,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: page description: which page to return (start at 1, default 1) in: query @@ -26049,6 +27002,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: native triggers list @@ -26059,7 +27022,7 @@ paths: items: type: object description: A native trigger stored in Windmill - properties: &ref_572 + properties: &ref_573 external_id: type: string description: The unique identifier from the external service @@ -26068,7 +27031,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -26091,7 +27054,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_573 + required: &ref_574 - external_id - workspace_id - service_name @@ -26115,7 +27078,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26145,7 +27108,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: sync completed successfully @@ -26170,7 +27133,7 @@ paths: type: array items: type: object - properties: &ref_581 + properties: &ref_582 id: type: string name: @@ -26181,7 +27144,7 @@ paths: type: string path: type: string - required: &ref_582 + required: &ref_583 - id - name - path @@ -26206,7 +27169,7 @@ paths: type: array items: type: object - properties: &ref_583 + properties: &ref_584 id: type: string summary: @@ -26214,7 +27177,7 @@ paths: primary: type: boolean default: false - required: &ref_584 + required: &ref_585 - id - summary /w/{workspace}/native_triggers/google/drive/files: @@ -26257,12 +27220,12 @@ paths: application/json: schema: type: object - properties: &ref_587 + properties: &ref_588 files: type: array items: type: object - properties: &ref_585 + properties: &ref_586 id: type: string name: @@ -26272,13 +27235,13 @@ paths: is_folder: type: boolean default: false - required: &ref_586 + required: &ref_587 - id - name - mime_type next_page_token: type: string - required: &ref_588 + required: &ref_589 - files /w/{workspace}/native_triggers/google/drive/shared_drives: get: @@ -26301,12 +27264,12 @@ paths: type: array items: type: object - properties: &ref_589 + properties: &ref_590 id: type: string name: type: string - required: &ref_590 + required: &ref_591 - id - name /w/{workspace}/native_triggers/github/repos: @@ -26330,7 +27293,7 @@ paths: type: array items: type: object - properties: &ref_591 + properties: &ref_592 full_name: type: string name: @@ -26339,7 +27302,7 @@ paths: type: string private: type: boolean - required: &ref_592 + required: &ref_593 - full_name - name - owner @@ -26356,7 +27319,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: workspace_id in: path required: true @@ -26405,7 +27368,7 @@ paths: application/json: schema: type: object - properties: &ref_454 + properties: &ref_456 mqtt_resource_path: type: string description: >- @@ -26415,16 +27378,16 @@ paths: type: array items: type: object - properties: &ref_236 + properties: &ref_241 qos: type: string - enum: &ref_453 + enum: &ref_455 - qos0 - qos1 - qos2 topic: type: string - required: &ref_237 + required: &ref_242 - qos - topic description: >- @@ -26438,7 +27401,7 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: &ref_238 + properties: &ref_243 clean_session: type: boolean v5_config: @@ -26447,7 +27410,7 @@ paths: MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: &ref_239 + properties: &ref_244 clean_start: type: boolean topic_alias_maximum: @@ -26458,7 +27421,7 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: &ref_240 + enum: &ref_245 - v3 - v5 path: @@ -26480,7 +27443,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26491,7 +27454,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26507,7 +27470,7 @@ paths: type: array items: type: string - required: &ref_455 + required: &ref_457 - path - script_path - is_flow @@ -26542,7 +27505,7 @@ paths: application/json: schema: type: object - properties: &ref_456 + properties: &ref_458 mqtt_resource_path: type: string description: >- @@ -26552,8 +27515,8 @@ paths: type: array items: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -26565,19 +27528,19 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_238 + properties: *ref_243 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_239 + properties: *ref_244 client_version: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_240 + enum: *ref_245 path: type: string description: >- @@ -26597,7 +27560,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26608,7 +27571,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26624,7 +27587,7 @@ paths: type: array items: type: string - required: &ref_457 + required: &ref_459 - path - script_path - is_flow @@ -26675,81 +27638,124 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: mqtt trigger deleted content: application/json: schema: - allOf: &ref_241 + allOf: + - allOf: &ref_246 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_247 + mqtt_resource_path: + type: string + description: >- + Path to the MQTT resource containing broker connection + configuration + subscribe_topics: + type: array + items: + type: object + properties: *ref_241 + required: *ref_242 + description: >- + Array of MQTT topics to subscribe to, each with topic + name and QoS level + v3_config: + nullable: true + description: MQTT v3 specific configuration (clean_session) + type: object + properties: *ref_243 + v5_config: + nullable: true + description: >- + MQTT v5 specific configuration (clean_start, + topic_alias_maximum, session_expiry_interval) + type: object + properties: *ref_244 + client_id: + type: string + nullable: true + description: MQTT client ID for this connection + client_version: + nullable: true + description: MQTT protocol version ('v3' or 'v5') + type: string + enum: *ref_245 + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_248 + - subscribe_topics + - mqtt_resource_path - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_242 - mqtt_resource_path: - type: string - description: >- - Path to the MQTT resource containing broker connection - configuration - subscribe_topics: - type: array - items: - type: object - properties: *ref_236 - required: *ref_237 - description: >- - Array of MQTT topics to subscribe to, each with topic name - and QoS level - v3_config: - nullable: true - description: MQTT v3 specific configuration (clean_session) - type: object - properties: *ref_238 - v5_config: - nullable: true - description: >- - MQTT v5 specific configuration (clean_start, - topic_alias_maximum, session_expiry_interval) - type: object - properties: *ref_239 - client_id: - type: string - nullable: true - description: MQTT client ID for this connection - client_version: - nullable: true - description: MQTT protocol version ('v3' or 'v5') - type: string - enum: *ref_240 - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_243 - - subscribe_topics - - mqtt_resource_path + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/mqtt_triggers/list: get: summary: list mqtt triggers @@ -26788,6 +27794,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: mqtt trigger list @@ -26796,10 +27812,10 @@ paths: schema: type: array items: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 /w/{workspace}/mqtt_triggers/exists/{path}: get: summary: does mqtt trigger exists @@ -26848,7 +27864,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -26912,7 +27928,7 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: &ref_244 + properties: &ref_249 gcp_resource_path: type: string description: >- @@ -26920,7 +27936,7 @@ paths: credentials for authentication. subscription_mode: type: string - enum: &ref_249 + enum: &ref_254 - existing - create_update description: >- @@ -26938,7 +27954,7 @@ paths: description: Base URL for push delivery endpoint. delivery_type: type: string - enum: &ref_246 + enum: &ref_251 - push - pull description: >- @@ -26949,7 +27965,7 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: &ref_247 + properties: &ref_252 audience: type: string description: >- @@ -26960,7 +27976,7 @@ paths: description: >- If true, push messages will include OIDC authentication tokens. - required: &ref_248 + required: &ref_253 - authenticate - base_endpoint path: @@ -26982,7 +27998,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 auto_acknowledge_msg: type: boolean description: >- @@ -27009,7 +28025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -27025,7 +28041,7 @@ paths: type: array items: type: string - required: &ref_245 + required: &ref_250 - path - script_path - is_flow @@ -27062,8 +28078,8 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 responses: '200': description: gcp trigger updated @@ -27108,83 +28124,127 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: gcp trigger deleted content: application/json: schema: - allOf: &ref_250 + allOf: + - allOf: &ref_255 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + description: >- + A Google Cloud Pub/Sub trigger that executes a script or + flow when messages are received. + properties: &ref_256 + gcp_resource_path: + type: string + description: >- + Path to the GCP resource containing service account + credentials for authentication. + topic_id: + type: string + description: Google Cloud Pub/Sub topic ID to subscribe to. + subscription_id: + type: string + description: Google Cloud Pub/Sub subscription ID. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal use). + delivery_type: + type: string + enum: *ref_251 + description: >- + Delivery mode for messages. 'push' for HTTP push + delivery where messages are sent to a webhook + endpoint, 'pull' for polling where the trigger + actively fetches messages. + delivery_config: + nullable: true + type: object + description: Configuration for push delivery mode. + properties: *ref_252 + required: *ref_253 + subscription_mode: + type: string + enum: *ref_254 + description: >- + The mode of subscription. 'existing' means using an + existing GCP subscription, while 'create_update' + involves creating or updating a new subscription. + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal use). + error: + type: string + description: Last error message if the trigger failed. + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails. + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_257 + - gcp_resource_path + - topic_id + - subscription_id + - delivery_type + - subscription_mode - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - A Google Cloud Pub/Sub trigger that executes a script or flow - when messages are received. - properties: &ref_251 - gcp_resource_path: - type: string - description: >- - Path to the GCP resource containing service account - credentials for authentication. - topic_id: - type: string - description: Google Cloud Pub/Sub topic ID to subscribe to. - subscription_id: - type: string - description: Google Cloud Pub/Sub subscription ID. - server_id: - type: string - description: >- - ID of the server currently handling this trigger (internal - use). - delivery_type: - type: string - enum: *ref_246 - description: >- - Delivery mode for messages. 'push' for HTTP push delivery - where messages are sent to a webhook endpoint, 'pull' for - polling where the trigger actively fetches messages. - delivery_config: - nullable: true - type: object - description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 - subscription_mode: - type: string - enum: *ref_249 - description: >- - The mode of subscription. 'existing' means using an - existing GCP subscription, while 'create_update' involves - creating or updating a new subscription. - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal use). - error: - type: string - description: Last error message if the trigger failed. - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails. - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_252 - - gcp_resource_path - - topic_id - - subscription_id - - delivery_type - - subscription_mode + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/gcp_triggers/list: get: summary: list gcp triggers @@ -27223,6 +28283,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: gcp trigger list @@ -27231,13 +28301,13 @@ paths: schema: type: array items: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 /w/{workspace}/gcp_triggers/exists/{path}: get: summary: does gcp trigger exists @@ -27286,7 +28356,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27353,10 +28423,10 @@ paths: application/json: schema: type: object - properties: &ref_460 + properties: &ref_462 subscription_id: type: string - required: &ref_461 + required: &ref_463 - subscription_id responses: '200': @@ -27411,10 +28481,10 @@ paths: application/json: schema: type: object - properties: &ref_458 + properties: &ref_460 topic_id: type: string - required: &ref_459 + required: &ref_461 - topic_id responses: '200': @@ -27443,12 +28513,12 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: &ref_253 + properties: &ref_258 azure_resource_path: type: string azure_mode: type: string - enum: &ref_255 + enum: &ref_260 - basic_push - namespace_push - namespace_pull @@ -27480,7 +28550,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string error_handler_args: @@ -27490,7 +28560,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string preserve_permissioned_as: @@ -27499,7 +28569,7 @@ paths: type: array items: type: string - required: &ref_254 + required: &ref_259 - path - script_path - is_flow @@ -27536,8 +28606,8 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 responses: '200': description: azure trigger updated @@ -27582,65 +28652,110 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: azure trigger content: application/json: schema: - allOf: &ref_256 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - An Azure Event Grid trigger that executes a script or flow - when events arrive. - properties: &ref_257 - azure_resource_path: - type: string - azure_mode: - type: string - enum: *ref_255 - description: Azure Event Grid trigger mode. - scope_resource_id: - type: string + allOf: + - allOf: &ref_261 + - type: object + properties: *ref_219 + required: *ref_220 + type: object description: >- - ARM resource ID of the topic (basic) or namespace - (namespace modes). - topic_name: - type: string - nullable: true - description: Topic name within the namespace (namespace modes only). - subscription_name: - type: string - event_type_filters: - type: array - items: - type: string - nullable: true - server_id: - type: string - last_server_ping: - type: string - format: date-time - error: - type: string - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_258 - - azure_resource_path - - azure_mode - - scope_resource_id - - subscription_name + An Azure Event Grid trigger that executes a script or flow + when events arrive. + properties: &ref_262 + azure_resource_path: + type: string + azure_mode: + type: string + enum: *ref_260 + description: Azure Event Grid trigger mode. + scope_resource_id: + type: string + description: >- + ARM resource ID of the topic (basic) or namespace + (namespace modes). + topic_name: + type: string + nullable: true + description: >- + Topic name within the namespace (namespace modes + only). + subscription_name: + type: string + event_type_filters: + type: array + items: + type: string + nullable: true + server_id: + type: string + last_server_ping: + type: string + format: date-time + error: + type: string + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_263 + - azure_resource_path + - azure_mode + - scope_resource_id + - subscription_name + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/azure_triggers/list: get: summary: list azure triggers @@ -27673,6 +28788,16 @@ paths: in: query schema: type: string + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: azure trigger list @@ -27681,13 +28806,13 @@ paths: schema: type: array items: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 /w/{workspace}/azure_triggers/exists/{path}: get: summary: check whether an azure trigger exists @@ -27735,7 +28860,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27767,10 +28892,10 @@ paths: application/json: schema: type: object - properties: &ref_464 + properties: &ref_466 azure_resource_path: type: string - required: &ref_465 + required: &ref_467 - azure_resource_path responses: '200': @@ -27800,10 +28925,10 @@ paths: application/json: schema: type: object - properties: &ref_466 + properties: &ref_468 scope_resource_id: type: string - required: &ref_467 + required: &ref_469 - scope_resource_id responses: '200': @@ -27835,12 +28960,12 @@ paths: application/json: schema: type: object - properties: &ref_468 + properties: &ref_470 scope_resource_id: type: string topic_name: type: string - required: &ref_469 + required: &ref_471 - scope_resource_id - topic_name responses: @@ -27873,10 +28998,10 @@ paths: application/json: schema: type: object - properties: &ref_462 + properties: &ref_464 azure_mode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -27885,7 +29010,7 @@ paths: nullable: true subscription_name: type: string - required: &ref_463 + required: &ref_465 - azure_mode - scope_resource_id - subscription_name @@ -27921,7 +29046,7 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: &ref_259 + properties: &ref_264 id: type: string name: @@ -27930,7 +29055,7 @@ paths: type: string type: type: string - required: &ref_260 + required: &ref_265 - id - name - type @@ -27961,8 +29086,8 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 /w/{workspace}/postgres_triggers/postgres/version/{path}: get: summary: get postgres version @@ -28025,19 +29150,19 @@ paths: application/json: schema: type: object - properties: &ref_478 + properties: &ref_480 postgres_resource_path: type: string relations: type: array items: type: object - properties: &ref_262 + properties: &ref_267 schema_name: type: string table_to_track: type: array - items: &ref_476 + items: &ref_478 type: object properties: table_name: @@ -28050,14 +29175,14 @@ paths: type: string required: - table_name - required: &ref_263 + required: &ref_268 - schema_name - table_to_track language: type: string - enum: &ref_477 + enum: &ref_479 - Typescript - required: &ref_479 + required: &ref_481 - postgres_resource_path - relations - language @@ -28082,7 +29207,7 @@ paths: - name: id in: path required: true - schema: &ref_304 + schema: &ref_309 type: string responses: '200': @@ -28115,7 +29240,7 @@ paths: type: array items: type: object - properties: &ref_475 + properties: &ref_477 slot_name: type: string active: @@ -28142,7 +29267,7 @@ paths: application/json: schema: type: object - properties: &ref_261 + properties: &ref_266 name: type: string responses: @@ -28174,7 +29299,7 @@ paths: application/json: schema: type: object - properties: *ref_261 + properties: *ref_266 responses: '200': description: postgres replication slot deleted @@ -28225,7 +29350,7 @@ paths: in: path required: true description: The name of the publication - schema: &ref_264 + schema: &ref_269 type: string responses: '200': @@ -28234,18 +29359,18 @@ paths: application/json: schema: type: object - properties: &ref_265 + properties: &ref_270 table_to_track: type: array items: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 transaction_to_track: type: array items: type: string - required: &ref_266 + required: &ref_271 - transaction_to_track /w/{workspace}/postgres_triggers/publication/create/{publication}/{path}: post: @@ -28266,7 +29391,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: new publication for postgres required: true @@ -28274,8 +29399,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication created @@ -28302,7 +29427,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: update publication for postgres required: true @@ -28310,8 +29435,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication updated @@ -28338,7 +29463,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 responses: '200': description: postgres publication deleted @@ -28364,7 +29489,7 @@ paths: application/json: schema: type: object - properties: &ref_480 + properties: &ref_482 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28392,7 +29517,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28403,8 +29528,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28415,7 +29540,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28431,7 +29556,7 @@ paths: type: array items: type: string - required: &ref_481 + required: &ref_483 - path - script_path - is_flow @@ -28466,7 +29591,7 @@ paths: application/json: schema: type: object - properties: &ref_482 + properties: &ref_484 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28494,7 +29619,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28505,8 +29630,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28517,7 +29642,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28533,7 +29658,7 @@ paths: type: array items: type: string - required: &ref_483 + required: &ref_485 - path - script_path - is_flow @@ -28585,60 +29710,103 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: get postgres trigger content: application/json: schema: - allOf: &ref_267 + allOf: + - allOf: &ref_272 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_273 + postgres_resource_path: + type: string + description: >- + Path to the PostgreSQL resource containing connection + configuration + publication_name: + type: string + description: >- + Name of the PostgreSQL publication to subscribe to for + change data capture + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + replication_slot_name: + type: string + description: Name of the PostgreSQL logical replication slot to use + error: + type: string + description: Last error message if the trigger failed + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_274 + - postgres_resource_path + - replication_slot_name + - publication_name - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_268 - postgres_resource_path: - type: string - description: >- - Path to the PostgreSQL resource containing connection - configuration - publication_name: - type: string - description: >- - Name of the PostgreSQL publication to subscribe to for - change data capture - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - replication_slot_name: - type: string - description: Name of the PostgreSQL logical replication slot to use - error: - type: string - description: Last error message if the trigger failed - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_269 - - postgres_resource_path - - replication_slot_name - - publication_name + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/postgres_triggers/list: get: summary: list postgres triggers @@ -28677,6 +29845,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: postgres trigger list @@ -28685,10 +29863,10 @@ paths: schema: type: array items: - allOf: *ref_267 + allOf: *ref_272 type: object - properties: *ref_268 - required: *ref_269 + properties: *ref_273 + required: *ref_274 /w/{workspace}/postgres_triggers/exists/{path}: get: summary: does postgres trigger exists @@ -28737,7 +29915,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -28800,7 +29978,7 @@ paths: application/json: schema: type: object - properties: &ref_492 + properties: &ref_494 path: type: string script_path: @@ -28820,11 +29998,11 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 permissioned_as: type: string description: >- @@ -28840,7 +30018,7 @@ paths: type: array items: type: string - required: &ref_493 + required: &ref_495 - path - script_path - local_part @@ -28874,7 +30052,7 @@ paths: application/json: schema: type: object - properties: &ref_494 + properties: &ref_496 path: type: string script_path: @@ -28894,7 +30072,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28910,7 +30088,7 @@ paths: type: array items: type: string - required: &ref_495 + required: &ref_497 - path - script_path - is_flow @@ -28958,34 +30136,77 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: email trigger retrieved content: application/json: schema: - allOf: &ref_270 + allOf: + - allOf: &ref_275 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_276 + local_part: + type: string + workspaced_local_part: + type: boolean + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_277 + - local_part - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_271 - local_part: - type: string - workspaced_local_part: - type: boolean - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_272 - - local_part + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/email_triggers/list: get: summary: list email triggers @@ -29024,6 +30245,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: email trigger list @@ -29032,10 +30263,10 @@ paths: schema: type: array items: - allOf: *ref_270 + allOf: *ref_275 type: object - properties: *ref_271 - required: *ref_272 + properties: *ref_276 + required: *ref_277 /w/{workspace}/email_triggers/exists/{path}: get: summary: does email trigger exists @@ -29117,7 +30348,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -29147,9 +30378,9 @@ paths: type: array items: type: object - required: &ref_496 + required: &ref_498 - name - properties: &ref_497 + properties: &ref_499 name: type: string summary: @@ -29179,9 +30410,9 @@ paths: type: array items: type: object - required: &ref_274 + required: &ref_279 - name - properties: &ref_275 + properties: &ref_280 name: type: string summary: @@ -29200,14 +30431,14 @@ paths: type: array items: type: object - properties: &ref_498 + properties: &ref_500 workspace_id: type: string workspace_name: type: string role: type: string - required: &ref_499 + required: &ref_501 - name /groups/get/{name}: get: @@ -29219,7 +30450,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group @@ -29227,8 +30458,8 @@ paths: application/json: schema: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 /groups/create: post: summary: create instance group @@ -29266,7 +30497,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update instance group required: true @@ -29302,7 +30533,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group deleted @@ -29320,7 +30551,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to add to instance group required: true @@ -29350,7 +30581,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to remove from instance group required: true @@ -29385,7 +30616,7 @@ paths: type: array items: type: object - properties: &ref_276 + properties: &ref_281 name: type: string summary: @@ -29406,7 +30637,7 @@ paths: enum: - superadmin - devops - required: &ref_277 + required: &ref_282 - name /groups/overwrite: post: @@ -29423,8 +30654,8 @@ paths: type: array items: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 responses: '200': description: success message @@ -29460,7 +30691,7 @@ paths: type: array items: type: object - properties: &ref_278 + properties: &ref_283 name: type: string summary: @@ -29473,7 +30704,7 @@ paths: type: object additionalProperties: type: boolean - required: &ref_279 + required: &ref_284 - name /w/{workspace}/groups/listnames: get: @@ -29546,7 +30777,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: updated group required: true @@ -29578,7 +30809,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group deleted @@ -29600,7 +30831,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group @@ -29608,8 +30839,8 @@ paths: application/json: schema: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 /w/{workspace}/groups/adduser/{name}: post: summary: add user to group @@ -29624,7 +30855,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29656,7 +30887,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29688,7 +30919,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -29747,7 +30978,7 @@ paths: type: array items: type: object - properties: &ref_281 + properties: &ref_286 name: type: string owners: @@ -29773,7 +31004,7 @@ paths: (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: &ref_280 + items: &ref_285 type: object required: - path_glob @@ -29794,7 +31025,15 @@ paths: permissioned as. Must be `u/`, `g/`, or an email that exists in this workspace. - required: &ref_282 + labels: + type: array + items: + type: string + description: > + Labels set on the folder. Items inside the folder + inherit them, exposed as `inherited_labels` on scripts + and flows and stamped into job labels at run time. + required: &ref_287 - name - owners - extra_perms @@ -29861,7 +31100,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string required: - name responses: @@ -29885,7 +31128,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update folder required: true @@ -29911,7 +31154,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string responses: '200': description: folder updated @@ -29933,7 +31180,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder deleted @@ -29955,7 +31202,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -29963,8 +31210,8 @@ paths: application/json: schema: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 /w/{workspace}/folders/exists/{name}: get: summary: exists folder @@ -29979,7 +31226,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder exists @@ -30001,7 +31248,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -30043,7 +31290,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: owner user to folder required: true @@ -30077,7 +31324,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added owner to folder required: true @@ -30113,7 +31360,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -30177,7 +31424,7 @@ paths: type: array items: type: object - properties: &ref_500 + properties: &ref_502 worker: type: string worker_instance: @@ -30223,7 +31470,7 @@ paths: type: string native_mode: type: boolean - required: &ref_501 + required: &ref_503 - worker - worker_instance - ping_at @@ -30388,7 +31635,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: a config @@ -30397,12 +31644,12 @@ paths: schema: type: object nullable: true - properties: &ref_384 + properties: &ref_389 alerts: type: array items: type: object - properties: &ref_382 + properties: &ref_387 name: type: string tags_to_monitor: @@ -30415,7 +31662,7 @@ paths: type: integer alert_time_threshold_seconds: type: integer - required: &ref_383 + required: &ref_388 - name - tags_to_monitor - jobs_num_threshold @@ -30431,7 +31678,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: worker group required: true @@ -30454,7 +31701,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: Delete config @@ -30477,12 +31724,12 @@ paths: type: array items: type: object - properties: &ref_547 + properties: &ref_548 name: type: string config: type: object - required: &ref_548 + required: &ref_549 - name /configs/list_autoscaling_events/{worker_group}: get: @@ -30513,7 +31760,7 @@ paths: type: array items: type: object - properties: &ref_551 + properties: &ref_552 id: type: integer format: int64 @@ -30580,7 +31827,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 required: - workspace_id - language @@ -30606,7 +31853,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_dep_names: type: array items: @@ -30946,7 +32193,7 @@ paths: properties: trigger_kind: type: string - enum: &ref_283 + enum: &ref_288 - webhook - http - websocket @@ -30992,11 +32239,11 @@ paths: required: true schema: type: string - enum: *ref_283 + enum: *ref_288 - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31018,7 +32265,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31032,17 +32279,17 @@ paths: type: array items: type: object - properties: &ref_552 + properties: &ref_553 trigger_config: {} trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 error: type: string last_server_ping: type: string format: date-time - required: &ref_553 + required: &ref_554 - trigger_kind /w/{workspace}/capture/list/{runnable_kind}/{path}: get: @@ -31058,7 +32305,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31067,7 +32314,7 @@ paths: in: query schema: type: string - enum: *ref_283 + enum: *ref_288 - name: page description: which page to return (start at 1, default 1) in: query @@ -31085,10 +32332,10 @@ paths: type: array items: type: object - properties: &ref_284 + properties: &ref_289 trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 main_args: {} preprocessor_args: {} id: @@ -31096,7 +32343,7 @@ paths: created_at: type: string format: date-time - required: &ref_285 + required: &ref_290 - trigger_kind - main_args - preprocessor_args @@ -31116,7 +32363,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31161,8 +32408,8 @@ paths: application/json: schema: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 delete: summary: delete a capture operationId: deleteCapture @@ -31254,13 +32501,13 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: &ref_286 + schema: &ref_291 type: string - name: runnable_type in: query - schema: &ref_287 + schema: &ref_292 type: string - enum: &ref_392 + enum: &ref_398 - ScriptHash - ScriptPath - FlowPath @@ -31277,7 +32524,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: include_preview in: query schema: @@ -31291,7 +32538,7 @@ paths: type: array items: type: object - properties: &ref_288 + properties: &ref_293 id: type: string name: @@ -31305,7 +32552,7 @@ paths: type: boolean success: type: boolean - required: &ref_289 + required: &ref_294 - id - name - args @@ -31355,10 +32602,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 - name: page description: which page to return (start at 1, default 1) in: query @@ -31376,8 +32623,8 @@ paths: type: array items: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 /w/{workspace}/inputs/create: post: summary: Create an Input for future use in a script or flow @@ -31391,10 +32638,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 requestBody: description: Input required: true @@ -31402,12 +32649,12 @@ paths: application/json: schema: type: object - properties: &ref_388 + properties: &ref_394 name: type: string args: type: object - required: &ref_389 + required: &ref_395 - name - args - created_by @@ -31437,14 +32684,14 @@ paths: application/json: schema: type: object - properties: &ref_390 + properties: &ref_396 id: type: string name: type: string is_public: type: boolean - required: &ref_391 + required: &ref_397 - id - name - is_public @@ -31470,7 +32717,7 @@ paths: - name: input in: path required: true - schema: &ref_312 + schema: &ref_317 type: string responses: '200': @@ -31503,7 +32750,7 @@ paths: properties: s3_resource: type: object - properties: &ref_290 + properties: &ref_295 bucket: type: string region: @@ -31518,7 +32765,7 @@ paths: type: string pathStyle: type: boolean - required: &ref_291 + required: &ref_296 - bucket - region - endPoint @@ -31596,8 +32843,8 @@ paths: properties: s3_resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 responses: '200': description: Connection settings @@ -31618,10 +32865,10 @@ paths: type: boolean client_kwargs: type: object - properties: &ref_292 + properties: &ref_297 region_name: type: string - required: &ref_293 + required: &ref_298 - region_name required: - endpoint_url @@ -31676,8 +32923,8 @@ paths: type: boolean client_kwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 required: - endpoint_url - use_ssl @@ -31735,8 +32982,8 @@ paths: application/json: schema: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 /w/{workspace}/job_helpers/test_connection: get: summary: Test connection to the workspace object storage @@ -31800,10 +33047,10 @@ paths: type: array items: type: object - properties: &ref_294 + properties: &ref_299 s3: type: string - required: &ref_295 + required: &ref_300 - s3 restricted_access: type: boolean @@ -31836,7 +33083,7 @@ paths: application/json: schema: type: object - properties: &ref_298 + properties: &ref_303 mime_type: type: string size_in_bytes: @@ -31900,7 +33147,7 @@ paths: application/json: schema: type: object - properties: &ref_296 + properties: &ref_301 msg: type: string content: @@ -31912,7 +33159,7 @@ paths: - Csv - Parquet - Unknown - required: &ref_297 + required: &ref_302 - content_type /w/{workspace}/job_helpers/list_git_repo_files: get: @@ -31960,8 +33207,8 @@ paths: type: array items: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 restricted_access: type: boolean required: @@ -32020,8 +33267,8 @@ paths: application/json: schema: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 /w/{workspace}/job_helpers/load_git_repo_file_metadata: get: summary: >- @@ -32052,7 +33299,7 @@ paths: application/json: schema: type: object - properties: *ref_298 + properties: *ref_303 /w/{workspace}/job_helpers/check_s3_folder_exists: get: summary: Check if S3 path exists and is a folder @@ -32503,7 +33750,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32532,46 +33779,46 @@ paths: type: array items: type: object - properties: &ref_533 + properties: &ref_534 id: type: string name: type: string - required: &ref_534 + required: &ref_535 - id scalar_metrics: type: array items: type: object - properties: &ref_535 + properties: &ref_536 metric_id: type: string value: type: number - required: &ref_536 + required: &ref_537 - id - value timeseries_metrics: type: array items: type: object - properties: &ref_537 + properties: &ref_538 metric_id: type: string values: type: array items: type: object - properties: &ref_539 + properties: &ref_540 timestamp: type: string format: date-time value: type: number - required: &ref_540 + required: &ref_541 - timestamp - value - required: &ref_538 + required: &ref_539 - id - values /w/{workspace}/job_metrics/set_progress/{id}: @@ -32588,7 +33835,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32622,7 +33869,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job progress between 0 and 99 @@ -32640,11 +33887,11 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 - name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 - name: with_error in: query required: false @@ -32716,12 +33963,12 @@ paths: type: array items: type: object - properties: &ref_541 + properties: &ref_542 concurrency_key: type: string total_running: type: number - required: &ref_542 + required: &ref_543 - concurrency_key - total_running /concurrency_groups/prune/{concurrency_id}: @@ -32734,7 +33981,7 @@ paths: - name: concurrency_id in: path required: true - schema: &ref_314 + schema: &ref_319 type: string responses: '200': @@ -32754,7 +34001,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concurrency key for given job @@ -32790,104 +34037,104 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: page description: which page to return (start at 1, default 1) in: query @@ -32903,7 +34150,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -32924,6 +34171,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -32943,17 +34203,17 @@ paths: application/json: schema: type: object - properties: &ref_543 + properties: &ref_544 jobs: type: array items: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 obscured_jobs: type: array items: type: object - properties: &ref_393 + properties: &ref_399 typ: type: string started_at: @@ -32966,7 +34226,7 @@ paths: Obscured jobs omitted for security because of too specific filtering type: boolean - required: &ref_544 + required: &ref_545 - jobs - obscured_jobs /srch/w/{workspace}/index/search/job: @@ -33010,7 +34270,7 @@ paths: type: array items: type: object - properties: &ref_549 + properties: &ref_550 dancer: type: string hit_count: @@ -33089,7 +34349,7 @@ paths: type: array items: type: object - properties: &ref_550 + properties: &ref_551 dancer: type: string /srch/index/search/count_service_logs: @@ -33364,7 +34624,7 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 usages: type: array items: @@ -33377,13 +34637,13 @@ paths: type: string kind: type: string - enum: &ref_303 + enum: &ref_308 - script - flow - job access_type: type: string - enum: &ref_302 + enum: &ref_307 - r - w - rw @@ -33393,7 +34653,7 @@ paths: description: The columns used (for tables) additionalProperties: type: string - enum: *ref_302 + enum: *ref_307 nullable: true created_at: type: string @@ -33466,7 +34726,7 @@ paths: type: string kind: type: string - enum: *ref_303 + enum: *ref_308 responses: '200': description: all assets used by the given usage paths, in the same order @@ -33486,10 +34746,10 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 access_type: type: string - enum: *ref_302 + enum: *ref_307 nullable: true /w/{workspace}/assets/list_favorites: get: @@ -33537,13 +34797,13 @@ paths: type: array items: type: object - required: &ref_563 + required: &ref_564 - name - size_bytes - file_count - created_at - created_by - properties: &ref_564 + properties: &ref_565 name: type: string size_bytes: @@ -33658,13 +34918,13 @@ paths: type: array items: type: object - required: &ref_376 + required: &ref_381 - name - description - instructions - path - method - properties: &ref_377 + properties: &ref_382 name: type: string description: The tool name/operation ID @@ -33797,11 +35057,30 @@ components: in: cookie name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the deployed + payload. + schema: *ref_77 + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 Id: name: id in: path required: true - schema: *ref_304 + schema: *ref_309 Key: name: key in: path @@ -33817,7 +35096,7 @@ components: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 VersionId: name: version in: path @@ -33828,7 +35107,7 @@ components: name: token in: path required: true - schema: *ref_305 + schema: *ref_310 AccountId: name: id in: path @@ -33843,17 +35122,17 @@ components: name: path in: path required: true - schema: *ref_92 + schema: *ref_97 ScriptHash: name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 JobId: name: id in: path required: true - schema: *ref_172 + schema: *ref_176 Path: name: path in: path @@ -33863,22 +35142,22 @@ components: name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 PathId: name: id in: path required: true - schema: *ref_79 + schema: *ref_84 PathVersion: name: version in: path required: true - schema: *ref_306 + schema: *ref_311 Name: name: name in: path required: true - schema: *ref_273 + schema: *ref_278 Page: name: page description: which page to return (start at 1, default 1) @@ -33897,12 +35176,12 @@ components: '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 OrderDesc: name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 CreatedBy: name: created_by description: >- @@ -33910,7 +35189,7 @@ components: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 Label: name: label description: >- @@ -33918,7 +35197,7 @@ components: 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 Worker: name: worker description: >- @@ -33926,26 +35205,26 @@ components: 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 ParentJob: name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 WorkerTag: name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 CacheTtl: name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 NewJobId: name: job_id description: >- @@ -33953,7 +35232,7 @@ components: randomly using the ULID scheme. If a job id already exists in the queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 IncludeHeader: name: include_header description: > @@ -33963,19 +35242,19 @@ components: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 QueueLimit: name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 SkipPreprocessor: name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 Payload: name: payload description: > @@ -33984,7 +35263,7 @@ components: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 ScriptStartPath: name: script_path_start description: >- @@ -33992,12 +35271,12 @@ components: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 SchedulePath: name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 TriggerPath: name: trigger_path description: >- @@ -34005,7 +35284,7 @@ components: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: *ref_307 + schema: *ref_312 ScriptExactPath: name: script_path_exact description: >- @@ -34013,87 +35292,87 @@ components: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 ScriptExactHash: name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 CreatedBefore: name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 CreatedAfter: name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 StartedBefore: name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 StartedAfter: name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 Before: name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 CompletedBefore: name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 CompletedAfter: name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 CreatedAfterQueue: name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 CreatedBeforeQueue: name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 Success: name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 ScheduledForBeforeNow: name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 Suspended: name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 Running: name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 AllowWildcards: name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 ArgsFilter: name: args description: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 Tag: name: tag description: >- @@ -34101,37 +35380,37 @@ components: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 ResultFilter: name: result description: filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 After: name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 Username: name: username description: filter on exact username of user in: query - schema: *ref_308 + schema: *ref_313 Operation: name: operation description: filter on exact or prefix name of operation in: query - schema: *ref_309 + schema: *ref_314 ResourceName: name: resource description: filter on exact or prefix name of resource in: query - schema: *ref_310 + schema: *ref_315 ActionKind: name: action_kind description: filter on type of operation in: query - schema: *ref_311 + schema: *ref_316 JobKinds: name: job_kinds description: >- @@ -34139,53 +35418,76 @@ components: 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 RunnableId: name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 RunnableTypeQuery: name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 InputId: name: input in: path required: true - schema: *ref_312 + schema: *ref_317 GetStarted: name: get_started in: query - schema: *ref_313 + schema: *ref_318 ConcurrencyId: name: concurrency_id in: path required: true - schema: *ref_314 + schema: *ref_319 RunnableKind: name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 schemas: + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 OpenFlow: type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 Retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 StopAfterIf: type: object description: Early termination condition for a module @@ -34206,6 +35508,13 @@ components: with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised. + error_include_result: + type: boolean + description: >- + When stopping with an error (error_message set), embed the stopping + step's own result inside the raised error object (as error.result) + instead of discarding it. The top-level result stays { error }. + Defaults to false. required: - expr FlowModule: @@ -34222,18 +35531,18 @@ components: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -34252,8 +35561,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: Cache duration in seconds for this step's results @@ -34264,8 +35573,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -34308,8 +35617,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: If true, the user who started the flow cannot approve @@ -34328,7 +35637,7 @@ components: retry: description: Retry configuration for failed module executions type: object - properties: *ref_315 + properties: *ref_320 debouncing: description: Debounce configuration for this step (EE only) type: object @@ -34361,8 +35670,8 @@ components: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 StaticTransform: type: object description: >- @@ -34419,7 +35728,7 @@ components: kind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 resource: type: string description: >- @@ -34439,16 +35748,16 @@ components: oneOf: - type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 - type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 - type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 discriminator: propertyName: kind mapping: @@ -34465,62 +35774,62 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 - type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 - type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 - type: object description: >- Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 - type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 - type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 - type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 - type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 - type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 discriminator: propertyName: type mapping: @@ -34550,8 +35859,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 content: type: string description: The script source code. Should export a 'main' function @@ -34667,8 +35976,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the script in the workspace (e.g., 'f/scripts/send_email') @@ -34705,8 +36014,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the flow in the workspace (e.g., 'f/flows/process_user') @@ -34734,15 +36043,15 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 iterator: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -34762,8 +36071,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34786,8 +36095,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -34807,8 +36116,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34846,8 +36155,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -34857,8 +36166,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: @@ -34896,8 +36205,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -34926,16 +36235,16 @@ components: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 output_type: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -34947,8 +36256,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax. @@ -34958,8 +36267,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. Optional. @@ -34969,8 +36278,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -34980,16 +36289,16 @@ components: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 output_schema: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape. @@ -35006,8 +36315,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -35021,8 +36330,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -35035,8 +36344,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: | Float. Controls randomness/creativity of responses. Range: 0.0 to 2.0 (provider-dependent) @@ -35049,8 +36358,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -35070,12 +36379,17 @@ components: description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. If not set, the AI agent + step runs on the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -35106,8 +36420,8 @@ components: - type FlowStatus: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 FlowStatusModule: type: object properties: @@ -35329,8 +36643,8 @@ components: - type CiTestResult: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 HealthStatusResponse: type: object description: Health status response (cached with 5s TTL) @@ -35344,75 +36658,75 @@ components: HealthChecks: type: object description: Detailed health checks - required: *ref_347 - properties: *ref_348 + required: *ref_352 + properties: *ref_353 DatabaseHealth: type: object description: Database health status - required: *ref_349 - properties: *ref_350 + required: *ref_354 + properties: *ref_355 PoolStats: type: object description: Database connection pool statistics - required: *ref_351 - properties: *ref_352 + required: *ref_356 + properties: *ref_357 WorkersHealth: type: object description: Workers health status - required: *ref_353 - properties: *ref_354 + required: *ref_358 + properties: *ref_359 QueueHealth: type: object description: Job queue status - required: *ref_355 - properties: *ref_356 + required: *ref_360 + properties: *ref_361 ReadinessHealth: type: object description: Server readiness status - required: *ref_357 - properties: *ref_358 + required: *ref_362 + properties: *ref_363 AutoInviteConfig: type: object description: Configuration for auto-inviting users to the workspace - properties: *ref_359 + properties: *ref_364 ErrorHandlerConfig: type: object description: Configuration for the workspace error handler - properties: *ref_360 + properties: *ref_365 SuccessHandlerConfig: type: object description: Configuration for the workspace success handler - properties: *ref_361 + properties: *ref_366 EditErrorHandler: description: >- Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_362 + oneOf: *ref_367 EditErrorHandlerNew: type: object description: New grouped format for editing error handler - properties: *ref_363 + properties: *ref_368 EditErrorHandlerLegacy: type: object description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: *ref_364 + properties: *ref_369 EditSuccessHandler: description: >- Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_365 + oneOf: *ref_370 EditSuccessHandlerNew: type: object description: New grouped format for editing success handler - properties: *ref_366 + properties: *ref_371 EditSuccessHandlerLegacy: type: object description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: *ref_367 + properties: *ref_372 VaultSettings: type: object required: *ref_27 @@ -35427,28 +36741,28 @@ components: properties: *ref_34 SecretMigrationFailure: type: object - required: *ref_368 - properties: *ref_369 + required: *ref_373 + properties: *ref_374 SecretMigrationReport: type: object required: *ref_29 properties: *ref_30 JwksResponse: type: object - required: *ref_370 - properties: *ref_371 + required: *ref_375 + properties: *ref_376 FlowConversation: type: object - required: *ref_372 - properties: *ref_373 + required: *ref_377 + properties: *ref_378 FlowConversationMessage: type: object - required: *ref_374 - properties: *ref_375 + required: *ref_379 + properties: *ref_380 EndpointTool: type: object - required: *ref_376 - properties: *ref_377 + required: *ref_381 + properties: *ref_382 AIProvider: type: string enum: *ref_51 @@ -35461,114 +36775,112 @@ components: required: *ref_44 AIProviderConfig: type: object - properties: *ref_378 - required: *ref_379 + properties: *ref_383 + required: *ref_384 AIConfig: type: object properties: *ref_50 InstanceAIProviderSummary: type: object - properties: *ref_380 - required: *ref_381 + properties: *ref_385 + required: *ref_386 InstanceAISummary: type: object properties: *ref_52 required: *ref_53 Alert: type: object - properties: *ref_382 - required: *ref_383 + properties: *ref_387 + required: *ref_388 Configs: type: object nullable: true - properties: *ref_384 + properties: *ref_389 WorkspaceDependencies: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 NewWorkspaceDependencies: type: object - properties: *ref_385 - required: *ref_386 + properties: *ref_390 + required: *ref_391 Script: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 NewScript: type: object - properties: *ref_104 - required: *ref_105 - NewScriptWithDraft: - allOf: *ref_387 + properties: *ref_392 + required: *ref_393 ScriptHistory: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 ScriptArgs: type: object description: The arguments to pass to the script or flow additionalProperties: true Input: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 CreateInput: type: object - properties: *ref_388 - required: *ref_389 + properties: *ref_394 + required: *ref_395 UpdateInput: type: object - properties: *ref_390 - required: *ref_391 + properties: *ref_396 + required: *ref_397 RunnableType: type: string - enum: *ref_392 + enum: *ref_398 QueuedJob: type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 CompletedJob: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 ExportableCompletedJob: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 ExportableQueuedJob: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 ObscuredJob: type: object - properties: *ref_393 + properties: *ref_399 Job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 User: type: object properties: *ref_35 required: *ref_36 UserSource: type: object - properties: *ref_394 - required: *ref_395 + properties: *ref_400 + required: *ref_401 UserUsage: type: object - properties: *ref_396 + properties: *ref_402 Login: type: object - properties: *ref_397 - required: *ref_398 + properties: *ref_403 + required: *ref_404 PasswordResetResponse: type: object properties: *ref_7 required: *ref_8 EditWorkspaceUser: type: object - properties: *ref_399 + properties: *ref_405 OffboardAffectedPaths: type: object properties: *ref_11 @@ -35578,64 +36890,64 @@ components: required: *ref_13 OffboardTokenInfo: type: object - properties: *ref_400 - required: *ref_401 + properties: *ref_406 + required: *ref_407 OffboardRequest: type: object - properties: *ref_402 - required: *ref_403 + properties: *ref_408 + required: *ref_409 OffboardResponse: type: object properties: *ref_14 OffboardSummary: type: object - properties: *ref_404 - required: *ref_405 + properties: *ref_410 + required: *ref_411 GlobalOffboardPreview: type: object - properties: *ref_406 - required: *ref_407 + properties: *ref_412 + required: *ref_413 WorkspaceOffboardPreview: type: object - properties: *ref_408 - required: *ref_409 + properties: *ref_414 + required: *ref_415 GlobalOffboardRequest: type: object - properties: *ref_410 + properties: *ref_416 WorkspaceReassignment: type: object - properties: *ref_411 - required: *ref_412 + properties: *ref_417 + required: *ref_418 TruncatedToken: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 ExternalJwtToken: type: object - properties: *ref_413 - required: *ref_414 + properties: *ref_419 + required: *ref_420 NewToken: type: object - properties: *ref_415 + properties: *ref_421 NewTokenImpersonate: type: object - properties: *ref_416 - required: *ref_417 + properties: *ref_422 + required: *ref_423 ListableVariable: type: object properties: *ref_61 required: *ref_62 ContextualVariable: type: object - properties: *ref_418 - required: *ref_419 + properties: *ref_424 + required: *ref_425 CreateVariable: type: object - properties: *ref_420 - required: *ref_421 + properties: *ref_426 + required: *ref_427 EditVariable: type: object - properties: *ref_422 + properties: *ref_428 AuditLog: type: object properties: *ref_5 @@ -35772,63 +37084,99 @@ components: - has_preprocessor ScriptLang: type: string - enum: *ref_94 + enum: *ref_99 ScriptModule: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 Preview: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 PreviewInline: type: object - properties: *ref_423 - required: *ref_424 + properties: *ref_429 + required: *ref_430 InlineScriptArgs: type: object - properties: *ref_140 + properties: *ref_144 WorkflowTask: type: object - properties: *ref_425 - required: *ref_426 + properties: *ref_431 + required: *ref_432 WorkflowStatusRecord: type: object additionalProperties: type: object - properties: *ref_177 + properties: *ref_181 WorkflowStatus: type: object - properties: *ref_177 + properties: *ref_181 CreateResource: type: object - properties: *ref_427 - required: *ref_428 + properties: *ref_433 + required: *ref_434 EditResource: type: object - properties: *ref_429 + properties: *ref_435 Resource: type: object - properties: *ref_430 - required: *ref_431 + properties: + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. + ws_specific: + type: boolean + required: + - path + - resource_type + - is_oauth ListableResource: type: object - properties: *ref_432 - required: *ref_433 + properties: *ref_80 + required: *ref_81 ResourceType: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 EditResourceType: type: object - properties: *ref_434 + properties: *ref_436 Schedule: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 ScheduleWJobs: - allOf: *ref_435 + allOf: *ref_437 ErrorHandler: type: string enum: @@ -35837,122 +37185,122 @@ components: - teams - email NewSchedule: - type: object - properties: *ref_436 - required: *ref_437 - EditSchedule: type: object properties: *ref_438 required: *ref_439 + EditSchedule: + type: object + properties: *ref_440 + required: *ref_441 JobTriggerKind: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 TriggerMode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 TriggerExtraProperty: type: object - properties: *ref_214 - required: *ref_215 + properties: *ref_219 + required: *ref_220 AuthenticationMethod: type: string - enum: *ref_206 + enum: *ref_210 RunnableKind: type: string - enum: *ref_200 + enum: *ref_204 OpenapiSpecFormat: type: string - enum: *ref_440 + enum: *ref_442 OpenapiHttpRouteFilters: - type: object - properties: *ref_441 - required: *ref_442 - WebhookFilters: type: object properties: *ref_443 required: *ref_444 - OpenapiV3Info: + WebhookFilters: type: object properties: *ref_445 required: *ref_446 - GenerateOpenapiSpec: - type: object - properties: *ref_201 - HttpMethod: - type: string - enum: *ref_204 - HttpRequestType: - type: string - enum: *ref_205 - HttpTrigger: - allOf: *ref_208 - type: object - properties: *ref_209 - required: *ref_210 - NewHttpTrigger: - type: object - properties: *ref_202 - required: *ref_203 - EditHttpTrigger: + OpenapiV3Info: type: object properties: *ref_447 required: *ref_448 - TriggersCount: + GenerateOpenapiSpec: type: object - properties: *ref_125 - WebsocketHeartbeat: + properties: *ref_205 + HttpMethod: + type: string + enum: *ref_208 + HttpRequestType: + type: string + enum: *ref_209 + HttpTrigger: + allOf: *ref_213 type: object - properties: *ref_212 - required: *ref_213 - WebsocketTrigger: - allOf: *ref_216 + properties: *ref_214 + required: *ref_215 + NewHttpTrigger: type: object - properties: *ref_217 - required: *ref_218 - NewWebsocketTrigger: + properties: *ref_206 + required: *ref_207 + EditHttpTrigger: type: object properties: *ref_449 required: *ref_450 - EditWebsocketTrigger: + TriggersCount: + type: object + properties: *ref_129 + WebsocketHeartbeat: + type: object + properties: *ref_217 + required: *ref_218 + WebsocketTrigger: + allOf: *ref_221 + type: object + properties: *ref_222 + required: *ref_223 + NewWebsocketTrigger: type: object properties: *ref_451 required: *ref_452 + EditWebsocketTrigger: + type: object + properties: *ref_453 + required: *ref_454 WebsocketTriggerInitialMessage: - anyOf: *ref_211 + anyOf: *ref_216 MqttQoS: type: string - enum: *ref_453 + enum: *ref_455 MqttV3Config: type: object - properties: *ref_238 + properties: *ref_243 MqttV5Config: type: object - properties: *ref_239 + properties: *ref_244 MqttSubscribeTopic: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 MqttClientVersion: type: string - enum: *ref_240 + enum: *ref_245 MqttTrigger: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 NewMqttTrigger: - type: object - properties: *ref_454 - required: *ref_455 - EditMqttTrigger: type: object properties: *ref_456 required: *ref_457 + EditMqttTrigger: + type: object + properties: *ref_458 + required: *ref_459 DeliveryType: type: string - enum: *ref_246 + enum: *ref_251 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger @@ -35960,19 +37308,19 @@ components: PushConfig: type: object description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 + properties: *ref_252 + required: *ref_253 GcpTrigger: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 SubscriptionMode: type: string - enum: *ref_249 + enum: *ref_254 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new @@ -35980,68 +37328,68 @@ components: GcpTriggerData: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 GetAllTopicSubscription: - type: object - properties: *ref_458 - required: *ref_459 - DeleteGcpSubscription: type: object properties: *ref_460 required: *ref_461 + DeleteGcpSubscription: + type: object + properties: *ref_462 + required: *ref_463 AzureMode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. AzureArmResource: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 AzureDeleteSubscription: type: object - properties: *ref_462 - required: *ref_463 + properties: *ref_464 + required: *ref_465 AzureTrigger: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 AzureTriggerData: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 TestAzureConnection: - type: object - properties: *ref_464 - required: *ref_465 - AzureListTopics: type: object properties: *ref_466 required: *ref_467 - AzureListSubscriptions: + AzureListTopics: type: object properties: *ref_468 required: *ref_469 + AzureListSubscriptions: + type: object + properties: *ref_470 + required: *ref_471 AwsAuthResourceType: type: string - enum: *ref_225 + enum: *ref_230 SqsTrigger: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 LoggedWizardStatus: type: string enum: *ref_21 CustomInstanceDbLogs: type: object - properties: *ref_470 + properties: *ref_472 CustomInstanceDbTag: type: string enum: *ref_22 @@ -36050,109 +37398,109 @@ components: required: *ref_23 properties: *ref_24 NewSqsTrigger: - type: object - properties: *ref_471 - required: *ref_472 - EditSqsTrigger: type: object properties: *ref_473 required: *ref_474 - Slot: - type: object - properties: *ref_261 - SlotList: + EditSqsTrigger: type: object properties: *ref_475 + required: *ref_476 + Slot: + type: object + properties: *ref_266 + SlotList: + type: object + properties: *ref_477 PublicationData: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 TableToTrack: type: array - items: *ref_476 + items: *ref_478 Relations: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 Language: type: string - enum: *ref_477 + enum: *ref_479 TemplateScript: - type: object - properties: *ref_478 - required: *ref_479 - PostgresTrigger: - allOf: *ref_267 - type: object - properties: *ref_268 - required: *ref_269 - NewPostgresTrigger: type: object properties: *ref_480 required: *ref_481 - EditPostgresTrigger: + PostgresTrigger: + allOf: *ref_272 + type: object + properties: *ref_273 + required: *ref_274 + NewPostgresTrigger: type: object properties: *ref_482 required: *ref_483 - KafkaTrigger: - allOf: *ref_219 - type: object - properties: *ref_220 - required: *ref_221 - NewKafkaTrigger: + EditPostgresTrigger: type: object properties: *ref_484 required: *ref_485 - EditKafkaTrigger: + KafkaTrigger: + allOf: *ref_224 + type: object + properties: *ref_225 + required: *ref_226 + NewKafkaTrigger: type: object properties: *ref_486 required: *ref_487 - NatsTrigger: - allOf: *ref_222 - type: object - properties: *ref_223 - required: *ref_224 - NewNatsTrigger: + EditKafkaTrigger: type: object properties: *ref_488 required: *ref_489 - EditNatsTrigger: + NatsTrigger: + allOf: *ref_227 + type: object + properties: *ref_228 + required: *ref_229 + NewNatsTrigger: type: object properties: *ref_490 required: *ref_491 - EmailTrigger: - allOf: *ref_270 - type: object - properties: *ref_271 - required: *ref_272 - NewEmailTrigger: + EditNatsTrigger: type: object properties: *ref_492 required: *ref_493 - EditEmailTrigger: + EmailTrigger: + allOf: *ref_275 + type: object + properties: *ref_276 + required: *ref_277 + NewEmailTrigger: type: object properties: *ref_494 required: *ref_495 + EditEmailTrigger: + type: object + properties: *ref_496 + required: *ref_497 Group: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 InstanceGroup: type: object - required: *ref_496 - properties: *ref_497 + required: *ref_498 + properties: *ref_499 InstanceGroupWithWorkspaces: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 WorkspaceInfo: type: object - properties: *ref_498 - required: *ref_499 + properties: *ref_500 + required: *ref_501 Folder: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 FolderDefaultPermissionedAs: description: > Ordered list of rules applied at create-time when admins or @@ -36160,19 +37508,19 @@ components: `path_glob` matches the item path (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 WorkerPing: - type: object - properties: *ref_500 - required: *ref_501 - UserWorkspaceList: type: object properties: *ref_502 required: *ref_503 - CreateWorkspace: + UserWorkspaceList: type: object properties: *ref_504 required: *ref_505 + CreateWorkspace: + type: object + properties: *ref_506 + required: *ref_507 CreateWorkspaceFork: type: object properties: *ref_19 @@ -36183,15 +37531,15 @@ components: required: *ref_16 DependencyMap: type: object - properties: *ref_506 + properties: *ref_508 DependencyDependent: - type: object - properties: *ref_507 - required: *ref_508 - DependentsAmount: type: object properties: *ref_509 required: *ref_510 + DependentsAmount: + type: object + properties: *ref_511 + required: *ref_512 WorkspaceInvite: type: object properties: *ref_41 @@ -36201,56 +37549,54 @@ components: properties: *ref_39 required: *ref_40 Flow: - allOf: *ref_124 + allOf: *ref_128 ExtraPerms: type: object - additionalProperties: *ref_511 + additionalProperties: *ref_513 FlowMetadata: - type: object - properties: *ref_512 - required: *ref_513 - OpenFlowWPath: - allOf: *ref_126 - FlowPreview: - type: object - properties: *ref_152 - required: *ref_153 - RestartedFrom: - type: object - properties: *ref_151 - Policy: - type: object - properties: *ref_127 - ListableApp: type: object properties: *ref_514 required: *ref_515 - ScopeDefinition: + OpenFlowWPath: + allOf: *ref_130 + FlowPreview: + type: object + properties: *ref_156 + required: *ref_157 + RestartedFrom: + type: object + properties: *ref_155 + Policy: + type: object + properties: *ref_131 + ListableApp: type: object properties: *ref_516 required: *ref_517 - ScopeDomain: + ScopeDefinition: type: object properties: *ref_518 required: *ref_519 - ListableRawApp: + ScopeDomain: type: object properties: *ref_520 required: *ref_521 + ListableRawApp: + type: object + properties: *ref_522 + required: *ref_523 AppWithLastVersion: type: object - properties: *ref_128 - required: *ref_129 - AppWithLastVersionWDraft: - allOf: *ref_522 + properties: *ref_132 + required: *ref_133 AppHistory: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 FlowVersion: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 SlackToken: type: object properties: @@ -36274,11 +37620,11 @@ components: required: *ref_75 HubScriptKind: type: string - enum: *ref_93 + enum: *ref_98 PolarsClientKwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 LargeFileStorage: type: object properties: *ref_45 @@ -36292,35 +37638,35 @@ components: properties: *ref_47 DataTableSchema: type: object - required: *ref_523 - properties: *ref_524 + required: *ref_524 + properties: *ref_525 DataTableTables: type: object - required: *ref_525 - properties: *ref_526 + required: *ref_526 + properties: *ref_527 DataTableTableSchema: type: object - required: *ref_527 - properties: *ref_528 + required: *ref_528 + properties: *ref_529 DynamicInputData: type: object - properties: *ref_529 - required: *ref_530 + properties: *ref_530 + required: *ref_531 WindmillLargeFile: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 WindmillFileMetadata: type: object - properties: *ref_298 + properties: *ref_303 WindmillFilePreview: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 S3Resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 WorkspaceGitSyncSettings: type: object properties: *ref_56 @@ -36332,48 +37678,48 @@ components: properties: *ref_59 S3PermissionRule: type: object - properties: *ref_531 - required: *ref_532 + properties: *ref_532 + required: *ref_533 GitRepositorySettings: type: object properties: *ref_57 required: *ref_58 MetricMetadata: type: object - properties: *ref_533 - required: *ref_534 + properties: *ref_534 + required: *ref_535 ScalarMetric: type: object - properties: *ref_535 - required: *ref_536 + properties: *ref_536 + required: *ref_537 TimeseriesMetric: type: object - properties: *ref_537 - required: *ref_538 + properties: *ref_538 + required: *ref_539 MetricDataPoint: type: object - properties: *ref_539 - required: *ref_540 + properties: *ref_540 + required: *ref_541 RawScriptForDependencies: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 ConcurrencyGroup: type: object - properties: *ref_541 - required: *ref_542 + properties: *ref_542 + required: *ref_543 ExtendedJobs: type: object - properties: *ref_543 - required: *ref_544 + properties: *ref_544 + required: *ref_545 ExportedUser: type: object properties: *ref_9 required: *ref_10 GlobalSetting: type: object - properties: *ref_545 - required: *ref_546 + properties: *ref_546 + required: *ref_547 InstanceConfig: type: object description: >- @@ -36382,35 +37728,35 @@ components: properties: *ref_26 Config: type: object - properties: *ref_547 - required: *ref_548 + properties: *ref_548 + required: *ref_549 ExportedInstanceGroup: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 JobSearchHit: type: object - properties: *ref_549 + properties: *ref_550 LogSearchHit: type: object - properties: *ref_550 + properties: *ref_551 AutoscalingEvent: type: object - properties: *ref_551 + properties: *ref_552 CriticalAlert: type: object properties: *ref_63 CaptureTriggerKind: type: string - enum: *ref_283 + enum: *ref_288 Capture: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 CaptureConfig: type: object - properties: *ref_552 - required: *ref_553 + properties: *ref_553 + required: *ref_554 OperatorSettings: nullable: true type: object @@ -36418,16 +37764,16 @@ components: properties: *ref_38 WorkspaceComparison: type: object - required: *ref_554 - properties: *ref_555 + required: *ref_555 + properties: *ref_556 WorkspaceItemDiff: type: object - required: *ref_556 - properties: *ref_557 + required: *ref_557 + properties: *ref_558 CompareSummary: type: object - required: *ref_558 - properties: *ref_559 + required: *ref_559 + properties: *ref_560 TeamInfo: type: object required: @@ -36448,12 +37794,12 @@ components: description: List of channels within the team items: type: object - required: &ref_560 + required: &ref_561 - channel_id - channel_name - tenant_id - service_url - properties: &ref_561 + properties: &ref_562 channel_id: type: string description: The unique identifier of the channel @@ -36473,11 +37819,11 @@ components: https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/ ChannelInfo: type: object - required: *ref_560 - properties: *ref_561 + required: *ref_561 + properties: *ref_562 GithubInstallations: type: array - items: *ref_562 + items: *ref_563 WorkspaceGithubInstallation: type: object properties: @@ -36490,8 +37836,8 @@ components: - installation_id S3Object: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 TeamsChannel: type: object required: @@ -36518,14 +37864,14 @@ components: minLength: 1 AssetUsageKind: type: string - enum: *ref_303 + enum: *ref_308 AssetUsageAccessType: type: string - enum: *ref_302 + enum: *ref_307 nullable: true AssetKind: type: string - enum: *ref_301 + enum: *ref_306 Asset: type: object properties: @@ -36533,26 +37879,26 @@ components: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 required: - path - kind Volume: type: object - required: *ref_563 - properties: *ref_564 + required: *ref_564 + properties: *ref_565 ProtectionRuleset: type: object description: A workspace protection rule defining restrictions and bypass permissions - required: *ref_565 - properties: *ref_566 + required: *ref_566 + properties: *ref_567 ProtectionRules: type: array description: Configuration of protection restrictions items: *ref_64 ProtectionRuleKind: type: string - enum: *ref_567 + enum: *ref_568 RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -36563,12 +37909,12 @@ components: items: *ref_66 DeploymentRequestEligibleDeployer: type: object - required: *ref_568 - properties: *ref_569 + required: *ref_569 + properties: *ref_570 DeploymentRequestAssignee: type: object - required: *ref_570 - properties: *ref_571 + required: *ref_571 + properties: *ref_572 DeploymentRequestComment: type: object required: *ref_69 @@ -36583,27 +37929,27 @@ components: required: *ref_72 NativeServiceName: type: string - enum: *ref_229 + enum: *ref_234 NativeTrigger: type: object description: A native trigger stored in Windmill - properties: *ref_572 - required: *ref_573 + properties: *ref_573 + required: *ref_574 NativeTriggerWithExternal: type: object description: >- Full trigger response containing both Windmill data and external service data - properties: *ref_574 - required: *ref_575 + properties: *ref_575 + required: *ref_576 WorkspaceIntegrations: type: object - properties: *ref_576 - required: *ref_577 + properties: *ref_577 + required: *ref_578 WorkspaceOAuthConfig: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 WebhookEvent: type: object properties: @@ -36614,7 +37960,7 @@ components: request_type: type: string description: The type of webhook request (define possible values here) - enum: &ref_578 + enum: &ref_579 - async - sync required: @@ -36623,21 +37969,21 @@ components: WebhookRequestType: type: string description: The type of webhook request (define possible values here) - enum: *ref_578 + enum: *ref_579 RedirectUri: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 NativeTriggerData: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 CreateTriggerResponse: type: object description: Response returned when a native trigger is created - properties: *ref_579 - required: *ref_580 + properties: *ref_580 + required: *ref_581 SyncResult: type: object properties: @@ -36661,35 +38007,35 @@ components: - total_windmill NextCloudEventType: type: object - properties: *ref_581 - required: *ref_582 + properties: *ref_582 + required: *ref_583 GoogleCalendarEntry: type: object - properties: *ref_583 - required: *ref_584 + properties: *ref_584 + required: *ref_585 GoogleDriveFile: type: object - properties: *ref_585 - required: *ref_586 + properties: *ref_586 + required: *ref_587 GoogleDriveFilesResponse: type: object - properties: *ref_587 - required: *ref_588 + properties: *ref_588 + required: *ref_589 SharedDriveEntry: type: object - properties: *ref_589 - required: *ref_590 + properties: *ref_590 + required: *ref_591 GithubRepoEntry: type: object - properties: *ref_591 - required: *ref_592 + properties: *ref_592 + required: *ref_593 schemas-StaticTransform: type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 schemas-JavascriptTransform: type: object description: >- @@ -36697,48 +38043,48 @@ components: results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 schemas-AiTransform: type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 + properties: *ref_91 + required: *ref_92 schemas-InputTransform: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 schemas-RawScript: type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 schemas-PathScript: type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 schemas-PathFlow: type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 schemas-FlowModule: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 schemas-ForloopFlow: type: object description: >- @@ -36746,160 +38092,160 @@ components: 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 schemas-WhileloopFlow: type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 schemas-BranchOne: type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 schemas-BranchAll: type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 schemas-Identity: type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 AIProviderKind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 schemas-ProviderConfig: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: *ref_593 - required: *ref_594 + properties: *ref_594 + required: *ref_595 StaticProviderTransform: type: object description: Static provider configuration passed directly to the AI agent - properties: *ref_595 - required: *ref_596 + properties: *ref_596 + required: *ref_597 ProviderTransform: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 MemoryOff: type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 MemoryAuto: type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 MemoryMessage: type: object description: A single message in conversation history - properties: *ref_597 - required: *ref_598 + properties: *ref_598 + required: *ref_599 MemoryManual: type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 schemas-MemoryConfig: description: Conversation memory configuration - oneOf: *ref_599 - discriminator: *ref_600 + oneOf: *ref_600 + discriminator: *ref_601 StaticMemoryTransform: type: object description: Static memory configuration passed directly to the AI agent - properties: *ref_601 - required: *ref_602 + properties: *ref_602 + required: *ref_603 MemoryTransform: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 schemas-FlowModuleValue: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 FlowModuleTool: description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: *ref_603 + allOf: *ref_604 McpToolValue: type: object description: >- Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: *ref_604 - required: *ref_605 + properties: *ref_605 + required: *ref_606 WebsearchToolValue: type: object description: >- A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: *ref_606 - required: *ref_607 + properties: *ref_607 + required: *ref_608 ToolValue: description: >- The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: *ref_608 - discriminator: *ref_609 + oneOf: *ref_609 + discriminator: *ref_610 AgentTool: type: object description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 schemas-AiAgent: type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 schemas-StopAfterIf: type: object description: Early termination condition for a module - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 RetryIf: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 schemas-Retry: type: object description: Retry configuration for failed module executions - properties: *ref_315 + properties: *ref_320 schemas-FlowNote: type: object description: A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 FlowGroup: type: object description: >- @@ -36908,16 +38254,16 @@ components: flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 + properties: *ref_151 + required: *ref_152 schemas-FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_610 - required: *ref_611 + properties: *ref_611 + required: *ref_612 schemas-FlowStatusModule: type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2263d05d7d..f621b3be80 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.719.0 + version: 1.726.1 title: Windmill API contact: @@ -5355,13 +5355,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: variable content: application/json: schema: - $ref: "#/components/schemas/ListableVariable" + allOf: + - $ref: "#/components/schemas/ListableVariable" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/variables/get_value/{path}: get: @@ -5445,6 +5448,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: "200": description: variable list @@ -6678,13 +6690,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: resource content: application/json: schema: - $ref: "#/components/schemas/Resource" + allOf: + - $ref: "#/components/schemas/ListableResource" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/resources/get_value_interpolated/{path}: get: @@ -6825,6 +6840,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: resource list @@ -7813,7 +7837,40 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: @@ -7833,43 +7890,49 @@ paths: items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: list every draft the current user has in this workspace, across all kinds + operationId: listDrafts tags: - draft parameters: - $ref: "#/components/parameters/WorkspaceId" - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: ["flow", "script", "app"] - value: {} - required: - - path - - typ - - enum responses: - "201": - description: draft created + "200": + description: the user's drafts content: - text/plain: + application/json: schema: - type: string + type: array + items: + type: object + properties: + kind: + $ref: "#/components/schemas/UserDraftItemKind" + path: + type: string + summary: + type: string + description: Best-effort, read from the draft JSON's `summary` field when the editor shape carries one. + draft_path: + type: string + description: User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`). + draft_only: + type: boolean + description: No deployed counterpart exists at this path — the draft is the whole item. + legacy_draft: + type: boolean + description: The listed draft is a legacy workspace-level row (email NULL) predating the per-user drafts migration. Only true when no per-user draft exists at this path. + created_at: + type: string + format: date-time + required: [kind, path, draft_only, legacy_draft, created_at] - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: fetch a single draft's content by workspace username (or the legacy workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -7878,19 +7941,79 @@ paths: in: path required: true schema: - type: string - enum: - - script - - flow - - app + $ref: "#/components/schemas/UserDraftItemKind" - $ref: "#/components/parameters/ScriptPath" + - name: username + in: query + required: false + description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row. + schema: + type: string responses: "200": - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: [value, created_at] + "404": + description: no draft for that owner at that path + + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + $ref: "#/components/schemas/UserDraftItemKind" + - $ref: "#/components/parameters/ScriptPath" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + nullable: true + description: Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: Server timestamp of the client's last known sync for this draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + legacy: + type: boolean + description: Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page. + responses: + "200": + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [saved, conflict] + current_timestamp: + type: string + format: date-time + required: [status, current_timestamp] /w/{workspace}/scripts/create: post: @@ -7900,9 +8023,10 @@ paths: Creates a new version of an existing script when called with the same path and the current `parent_hash`. operationId: createScript x-mcp-tool: true - x-mcp-instructions: "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed." + x-mcp-instructions: "To create a NEW script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language, and leave parent_hash unset. For TypeScript, use 'bun' unless deno-specific APIs are needed. To UPDATE an existing script, do NOT delete and recreate it: call this tool with the same path and set parent_hash to the script's current hash, which you can read from the `hash` field returned by getScriptByPath. This creates a new version while preserving the script's history." x-mcp-tool-include-fields: - path + - parent_hash - content - language - summary @@ -8269,13 +8393,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: script details content: application/json: schema: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/scripts/get_triggers_count/{path}: get: @@ -8313,23 +8440,6 @@ paths: items: $ref: "#/components/schemas/TruncatedToken" - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: script details - content: - application/json: - schema: - $ref: "#/components/schemas/NewScriptWithDraft" - /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -9505,10 +9615,40 @@ paths: - $ref: "#/components/schemas/Flow" - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: @@ -9638,13 +9778,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: flow details content: application/json: schema: - $ref: "#/components/schemas/Flow" + allOf: + - $ref: "#/components/schemas/Flow" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/flows/deployment_status/p/{path}: get: @@ -9732,32 +9875,6 @@ paths: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: flow details with draft - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Flow" - - type: object - properties: - draft: - $ref: "#/components/schemas/Flow" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -9805,8 +9922,6 @@ paths: - $ref: "#/components/schemas/OpenFlowWPath" - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -10068,6 +10183,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: All raw apps @@ -10341,8 +10457,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10396,8 +10510,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10459,13 +10571,24 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: "200": description: app details content: application/json: schema: - $ref: "#/components/schemas/AppWithLastVersion" + allOf: + - $ref: "#/components/schemas/AppWithLastVersion" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/apps/get/lite/{path}: get: @@ -10484,23 +10607,6 @@ paths: schema: $ref: "#/components/schemas/AppWithLastVersion" - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: app details with draft - content: - application/json: - schema: - $ref: "#/components/schemas/AppWithLastVersionWDraft" - /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -13726,13 +13832,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: schedule deleted content: application/json: schema: - $ref: "#/components/schemas/Schedule" + allOf: + - $ref: "#/components/schemas/Schedule" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/schedules/exists/{path}: get: @@ -13804,6 +13913,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: schedule list @@ -14015,13 +14133,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: http trigger deleted content: application/json: schema: - $ref: "#/components/schemas/HttpTrigger" + allOf: + - $ref: "#/components/schemas/HttpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/http_triggers/list: get: @@ -14053,6 +14174,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: http trigger list @@ -14221,13 +14343,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: websocket trigger deleted content: application/json: schema: - $ref: "#/components/schemas/WebsocketTrigger" + allOf: + - $ref: "#/components/schemas/WebsocketTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/websocket_triggers/list: get: @@ -14259,6 +14384,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: websocket trigger list @@ -14426,13 +14552,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: kafka trigger deleted content: application/json: schema: - $ref: "#/components/schemas/KafkaTrigger" + allOf: + - $ref: "#/components/schemas/KafkaTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/kafka_triggers/list: get: @@ -14464,6 +14593,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: kafka trigger list @@ -14672,13 +14802,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: nats trigger deleted content: application/json: schema: - $ref: "#/components/schemas/NatsTrigger" + allOf: + - $ref: "#/components/schemas/NatsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/nats_triggers/list: get: @@ -14710,6 +14843,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: nats trigger list @@ -14872,13 +15006,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: sqs trigger deleted content: application/json: schema: - $ref: "#/components/schemas/SqsTrigger" + allOf: + - $ref: "#/components/schemas/SqsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/sqs_triggers/list: get: @@ -14910,6 +15047,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: sqs trigger list @@ -15365,6 +15503,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: native triggers list @@ -15665,13 +15804,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: mqtt trigger deleted content: application/json: schema: - $ref: "#/components/schemas/MqttTrigger" + allOf: + - $ref: "#/components/schemas/MqttTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/mqtt_triggers/list: get: @@ -15703,6 +15845,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: mqtt trigger list @@ -15865,13 +16008,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: gcp trigger deleted content: application/json: schema: - $ref: "#/components/schemas/GcpTrigger" + allOf: + - $ref: "#/components/schemas/GcpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/gcp_triggers/list: get: @@ -15903,6 +16049,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: gcp trigger list @@ -16132,13 +16279,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: azure trigger content: application/json: schema: - $ref: "#/components/schemas/AzureTrigger" + allOf: + - $ref: "#/components/schemas/AzureTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/azure_triggers/list: get: @@ -16163,6 +16313,7 @@ paths: in: query schema: type: string + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: azure trigger list @@ -16675,13 +16826,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: get postgres trigger content: application/json: schema: - $ref: "#/components/schemas/PostgresTrigger" + allOf: + - $ref: "#/components/schemas/PostgresTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/postgres_triggers/list: get: @@ -16713,6 +16867,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: postgres trigger list @@ -16875,13 +17030,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: email trigger retrieved content: application/json: schema: - $ref: "#/components/schemas/EmailTrigger" + allOf: + - $ref: "#/components/schemas/EmailTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/email_triggers/list: get: @@ -16913,6 +17071,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: email trigger list @@ -17516,6 +17675,10 @@ paths: type: boolean default_permissioned_as: $ref: '#/components/schemas/FolderDefaultPermissionedAs' + labels: + type: array + items: + type: string required: - name responses: @@ -17554,6 +17717,10 @@ paths: type: boolean default_permissioned_as: $ref: '#/components/schemas/FolderDefaultPermissionedAs' + labels: + type: array + items: + type: string responses: "200": description: folder updated @@ -20773,6 +20940,25 @@ components: name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: When true, overlay the authed user's draft (if any) onto the deployed payload. + schema: + type: boolean + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: + type: boolean Id: name: id in: path @@ -21202,6 +21388,81 @@ components: # NOTE: Not so many generators and validators support this format: # $ref: "../../openflow.openapi.yaml#/components/schemas" # This is why it is better to inline each of schemas for better compat + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: | + Other workspace users (and the legacy NULL-email row, if any) + with a saved draft at the same path. Populated only on the + authed user's "get by path" responses for kinds the editor + surfaces a fork banner for (script, flow, app, raw_app). + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: | + Workspace username of the draft owner. `null` represents + the legacy workspace-level (NULL-email) row. Emails never + leave the server. + required: [is_draft] + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: + - script + - flow + - app + - raw_app + - resource + - variable + - trigger_schedule + - trigger_webhook + - trigger_default_email + - trigger_email + - trigger_http + - trigger_websocket + - trigger_postgres + - trigger_kafka + - trigger_nats + - trigger_mqtt + - trigger_sqs + - trigger_gcp + - trigger_azure + - trigger_poll + - trigger_cli + - trigger_nextcloud + - trigger_google + - trigger_github # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- OpenFlow: @@ -21839,6 +22100,8 @@ components: type: array items: type: string + web_search_enabled: + type: boolean required: - resource_path - models @@ -22024,8 +22287,6 @@ components: type: boolean tag: type: string - has_draft: - type: boolean draft_only: type: boolean envs: @@ -22086,6 +22347,13 @@ components: items: type: string default: [] + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. required: - hash @@ -22131,8 +22399,6 @@ components: enum: [script, failure, trigger, command, approval, preprocessor] tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -22225,22 +22491,6 @@ components: - content - language - NewScriptWithDraft: - allOf: - - $ref: "#/components/schemas/NewScript" - - type: object - properties: - draft: - $ref: "#/components/schemas/NewScript" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - hash: - type: string - required: - - hash - ScriptHistory: type: object properties: @@ -23301,6 +23551,13 @@ components: type: array items: type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. ws_specific: type: boolean edited_at: @@ -23308,6 +23565,17 @@ components: format: date-time edited_by: type: string + draft_only: + description: | + True when this row is a per-user draft with no deployed + variable at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - workspace_id - path @@ -23845,6 +24113,13 @@ components: type: array items: type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. ws_specific: type: boolean required: @@ -23889,8 +24164,26 @@ components: type: array items: type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. ws_specific: type: boolean + draft_only: + description: | + True when this row is a per-user draft with no deployed + resource at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - resource_type @@ -24047,6 +24340,24 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + schedule at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. required: - path - edited_by @@ -24362,6 +24673,19 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + trigger at the same path. Set by list endpoints when + `include_draft_only=true` synthesizes the row from the + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - script_path @@ -26670,6 +26994,14 @@ components: format: date-time default_permissioned_as: $ref: '#/components/schemas/FolderDefaultPermissionedAs' + labels: + type: array + items: + type: string + description: > + Labels set on the folder. Items inside the folder inherit them, + exposed as `inherited_labels` on scripts and flows and stamped + into job labels at run time. required: - name - owners @@ -27029,6 +27361,13 @@ components: items: type: string default: [] + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. required: - path - edited_by @@ -27183,6 +27522,41 @@ components: items: type: string default: [] + is_draft: + type: boolean + description: | + True when the authed user has a draft for this app — either no + deployed row exists at this path (draft-only) or the user has + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not yet deployed. + Sourced from the draft JSON's `draft_path` field (the editor + only writes it when the typed path differs from the deployed + one). Lets the home list render the meaningful name instead of + the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and the legacy + NULL-email row if any) who have a per-user draft at this + path. Drives the home page's user-avatar circles inside the + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. required: - id - workspace_id @@ -27252,6 +27626,13 @@ components: items: type: string default: [] + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. required: - workspace_id - path @@ -27317,19 +27698,6 @@ components: - raw_app - AppWithLastVersionWDraft: - allOf: - - $ref: "#/components/schemas/AppWithLastVersion" - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - AppHistory: type: object properties: @@ -28458,6 +28826,7 @@ components: - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers + - RestrictAnonymousAppDeployment RuleBypasserGroups: type: array description: Groups that can bypass this ruleset diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index fb92d28cb3..6195fe5644 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -168,9 +168,6 @@ struct AIStandardResource { /// Platform (standard or google_vertex_ai) #[serde(default)] platform: AIPlatform, - /// Enable 1M context window for Anthropic - #[serde(alias = "enable_1M_context", default)] - enable_1m_context: bool, /// Custom HTTP headers to include in AI requests #[serde(default)] headers: HashMap, @@ -263,7 +260,6 @@ async fn resolve_provider_credentials( aws_secret_access_key, aws_session_token, platform: resource.platform, - enable_1m_context: resource.enable_1m_context, custom_headers: resource.headers, }) } @@ -288,7 +284,6 @@ async fn resolve_provider_credentials( aws_secret_access_key: None, aws_session_token: None, platform: AIPlatform::Standard, - enable_1m_context: false, custom_headers: HashMap::new(), }) } @@ -548,7 +543,6 @@ async fn global_proxy( aws_secret_access_key: None, aws_session_token: None, platform: AIPlatform::Standard, - enable_1m_context: false, custom_headers: HashMap::new(), }; @@ -958,11 +952,30 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, platform: AIPlatform::Standard, - enable_1m_context: false, custom_headers: HashMap::new(), } } + #[test] + fn ai_standard_resource_ignores_legacy_enable_1m_context_keys() { + // Resources created before the field was removed still carry the legacy key + // (lowercase `enable_1m_context` or the frontend alias `enable_1M_context`). + // The struct has no `deny_unknown_fields`, so both must be silently ignored. + let json = r#"{ + "base_url": "https://api.anthropic.com", + "enable_1m_context": true, + "enable_1M_context": true + }"#; + + let resource: AIStandardResource = + serde_json::from_str(json).expect("legacy resource must still deserialize"); + + assert_eq!( + resource.base_url.as_deref(), + Some("https://api.anthropic.com") + ); + } + #[test] fn invalidates_all_cached_providers_for_workspace() { let _guard = TEST_LOCK.lock().unwrap(); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index de55cb87ff..3cb2a1aef6 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -12,7 +12,7 @@ use crate::{ db::{ApiAuthed, DB}, jobs::RunJobQuery, users::{require_owner_of_path, require_path_read_access_for_preview, OptAuthed}, - utils::{check_scopes, WithStarredInfoQuery}, + utils::{build_scope_path_predicate, check_scopes}, webhook_util::{WebhookMessage, WebhookShared}, HTTP_CLIENT, }; @@ -58,6 +58,7 @@ use windmill_common::{ get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, JobPayload, RawCode, }, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, @@ -65,7 +66,9 @@ use windmill_common::{ }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, worker::{to_raw_value, CLOUD_HOSTED}, - workspaces::{check_deploy_rules, RuleCheckResult}, + workspaces::{ + check_deploy_rules, check_user_against_rule, ProtectionRuleKind, RuleCheckResult, + }, HUB_BASE_URL, }; #[cfg(feature = "parquet")] @@ -88,7 +91,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router { .route("/list_search", get(list_search_apps)) .route("/get/p/{*path}", get(get_app)) .route("/get/lite/{*path}", get(get_app_lite)) - .route("/get/draft/{*path}", get(get_app_w_draft)) .route("/secret_of/{*path}", get(get_secret_id)) .route( "/secret_of_latest_version/{*path}", @@ -153,7 +155,9 @@ pub struct ListableApp { pub execution_mode: String, pub starred: bool, pub edited_at: Option>, - pub has_draft: bool, + /// `Some(true)` only on rows synthesised from the `draft` table; `None` for + /// deployed rows. See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[sqlx(default)] @@ -163,6 +167,24 @@ pub struct ListableApp { pub raw_app: bool, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this app (draft-only or layered + /// over the deployed row). See ListableScript in windmill-types/src/scripts.rs. + #[serde(default, skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, + /// Labels inherited from the parent folder, computed at read time. + #[sqlx(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, } fn is_false(b: &bool) -> bool { @@ -208,20 +230,6 @@ pub struct AppWithLastVersionAndStarred { pub starred: Option, } -#[derive(Serialize, Deserialize, FromRow)] -pub struct AppWithLastVersionAndDraft { - #[sqlx(flatten)] - #[serde(flatten)] - pub app: AppWithLastVersion, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, -} - #[derive(Serialize)] pub struct AppHistory { pub app_id: i64, @@ -300,7 +308,6 @@ pub struct CreateApp { pub summary: String, pub value: sqlx::types::Json>, pub policy: Policy, - pub draft_only: Option, pub deployment_message: Option, pub custom_path: Option, pub preserve_on_behalf_of: Option, @@ -348,6 +355,8 @@ async fn list_search_apps( let n = 3; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "apps", "read"); + let rows = sqlx::query_as::<_, SearchApp>( "SELECT path, app_version.value from app LEFT JOIN app_version ON app_version.id = versions[array_upper(versions, 1)] WHERE workspace_id = $1 LIMIT $2", ) @@ -356,6 +365,7 @@ async fn list_search_apps( .fetch_all(&mut *tx) .await? .into_iter() + .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -364,6 +374,7 @@ async fn list_search_apps( async fn list_apps( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -381,10 +392,16 @@ async fn list_apps( "app_version.created_at as edited_at", "app.extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "app_version.raw_app", "app.labels", + "draft.path IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ = 'app') as draft_users", + "folder_labels(app.workspace_id, app.path) as inherited_labels", ]) .left() .join("favorite") @@ -393,15 +410,19 @@ async fn list_apps( .bind(&authed.username), ) .left() + // `app`/`raw_app` are separate draft kinds over one `app` table — match either + // for `is_draft`. DISTINCT in the subquery: a path with both kinds for the same + // user would otherwise fan the deployed row into two identical entries. + .join( + "(SELECT DISTINCT path, workspace_id FROM draft WHERE typ IN ('app', 'raw_app') AND email = ?) draft" + .bind(&authed.email), + ) + .on("draft.path = app.path AND draft.workspace_id = app.workspace_id") + .left() .join("app_version") .on( "app_version.id = versions[array_upper(versions, 1)]" ) - .left() - .join("draft") - .on( - "draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'" - ) .order_desc("favorite.path IS NOT NULL") .order_by("app_version.created_at", true) .and_where("app.workspace_id = ?".bind(&w_id)) @@ -421,13 +442,13 @@ async fn list_apps( sqlb.and_where_eq("app.path", "?".bind(path_exact)); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("app.draft_only IS NOT TRUE"); - } - if let Some(label) = &lq.label { for l in label.split(',') { - sqlb.and_where("app.labels @> ARRAY[?]".bind(&l.trim())); + sqlb.and_where( + "(app.labels @> ARRAY[?] OR folder_labels(app.workspace_id, app.path) @> ARRAY[?])" + .bind(&l.trim()) + .bind(&l.trim()), + ); } } @@ -440,12 +461,90 @@ async fn list_apps( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as::<_, ListableApp>(&sql) + let mut rows = sqlx::query_as::<_, ListableApp>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + // Append the authed user's `app`/`raw_app` drafts at paths with no deployed app; + // see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + { + // DISTINCT ON (path), newest first: collapse a path holding both `app` and + // `raw_app` drafts to one row (the home list keyed by `type/path` would crash + // on duplicates). `(email IS NULL)` last keeps the owned row over the legacy one. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at, + typ::text as "typ!" + FROM draft + WHERE workspace_id = $1 + AND typ IN ('app', 'raw_app') + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM app a + WHERE a.workspace_id = draft.workspace_id + AND a.path = draft.path + ) + ORDER BY path, (email IS NULL), created_at DESC"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // App/raw-app drafts are the bare editor value with no `path`, so the editor + // writes a separate `draft_path` only when it differs from deployed; see flows.rs. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableApp { + id: 0, + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + version: 0, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + execution_mode: String::new(), + starred: false, + edited_at: Some(row.created_at), + draft_only: Some(true), + deployment_msg: None, + raw_app: row.typ == "raw_app", + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + + let allowed = build_scope_path_predicate(&authed, "apps", "read"); + rows.retain(|r| allowed(&r.path)); + Ok(Json(rows)) } @@ -567,12 +666,25 @@ async fn get_raw_app_data( // Ok(Json(version)) // } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetAppQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, + /// Picks the draft kind for a draft-only lookup (`/apps_raw/...` → true). + /// Ignored when a deployed row exists — its own `raw_app` column wins. + #[serde(default)] + raw_app: Option, +} + async fn get_app( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("apps:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -586,9 +698,9 @@ async fn get_app( JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] LEFT JOIN favorite - ON favorite.favorite_kind = 'app' - AND favorite.workspace_id = app.workspace_id - AND favorite.path = app.path + ON favorite.favorite_kind = 'app' + AND favorite.workspace_id = app.workspace_id + AND favorite.path = app.path AND favorite.usr = $3 WHERE app.path = $1 AND app.workspace_id = $2", ) @@ -612,8 +724,27 @@ async fn get_app( }; tx.commit().await?; - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + // Draft kind comes from the deployed row's `raw_app` flag, or for a draft-only + // path from the caller's `raw_app` query param. + let kind = match &app_o { + Some(app) if app.app.raw_app => UserDraftItemKind::RawApp, + Some(_) => UserDraftItemKind::App, + None if query.raw_app.unwrap_or(false) => UserDraftItemKind::RawApp, + None => UserDraftItemKind::App, + }; + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + kind, + path, + query.get_draft, + app_o, + || windmill_common::error::Error::NotFound(format!("App not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn get_app_lite( @@ -644,55 +775,6 @@ async fn get_app_lite( Ok(Json(app)) } -async fn get_app_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("apps:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>( - r#" - SELECT - app.id, - app.path, - app.summary, - app.versions, - app.policy, - app.custom_path, - app.extra_perms, - app_version.value, - app_version.created_at, - app_version.created_by, - app.draft_only, - draft.value AS "draft", - draft.created_at AS "draft_created_at", - app_version.raw_app, - app.labels - FROM app - INNER JOIN app_version - ON app_version.id = app.versions[array_upper(app.versions, 1)] - LEFT JOIN draft - ON app.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'app' - WHERE app.path = $1 - AND app.workspace_id = $2 - "#, - ) - .bind(path.to_owned()) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) -} - async fn get_app_history( authed: ApiAuthed, Extension(user_db): Extension, @@ -1348,26 +1430,42 @@ async fn create_app_internal<'a>( )); } } + if matches!(app.policy.execution_mode, ExecutionMode::Anonymous) { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + w_id, + &ProtectionRuleKind::RestrictAnonymousAppDeployment, + &authed.username, + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !app.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", &app.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; } let id = sqlx::query_scalar!( "INSERT INTO app - (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) - VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", + (workspace_id, path, summary, policy, versions, custom_path, labels) + VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", w_id, app.path, app.summary, json!(app.policy), - app.draft_only, app.custom_path .as_ref() .map(|s| if s.is_empty() { None } else { Some(s) }) @@ -1573,15 +1671,16 @@ async fn delete_app( .await?; let trash_drafts: Vec = sqlx::query_scalar( - "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", ) .bind(path) .bind(&w_id) .fetch_all(&mut *tx) .await?; + // Both `app` and `raw_app` draft kinds back the same `app` table. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", path, &w_id ) @@ -1802,7 +1901,6 @@ async fn update_app_internal<'a>( sqlb.and_where_eq("path", "?".bind(&path)); sqlb.and_where_eq("workspace_id", "?".bind(&w_id)); - sqlb.set("draft_only", "NULL"); if let Some(npath) = &ns.path { if npath != path { require_owner_of_path(&authed, path)?; @@ -1866,6 +1964,37 @@ async fn update_app_internal<'a>( } if let Some(mut npolicy) = ns.policy { + if matches!(npolicy.execution_mode, ExecutionMode::Anonymous) && !authed.is_admin { + // Restricted users may keep deploying an app that is already + // public, but flipping an app to anonymous (public) access is + // gated by the RestrictAnonymousAppDeployment protection rule. + // FOR UPDATE locks the row until this transaction's policy + // UPDATE commits, so a concurrent admin downgrade cannot be + // silently overwritten by a stale redeploy keeping anonymous. + let already_anonymous = sqlx::query_scalar!( + "SELECT policy->>'execution_mode' = 'anonymous' FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE", + path, + w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten() + .unwrap_or(false); + if !already_anonymous { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + w_id, + &ProtectionRuleKind::RestrictAnonymousAppDeployment, + &authed.username, + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + } + } let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false) && windmill_common::can_preserve_on_behalf_of(&authed) && npolicy.on_behalf_of.is_some(); @@ -1958,12 +2087,15 @@ async fn update_app_internal<'a>( } }; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row) — see create_app_internal. if !ns.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -2277,6 +2409,15 @@ async fn execute_component( &policy }; + // Caller-supplied inline code (`raw_code`), with or without an + // `app_script` id. Its resolved `rawscript/` key must be present + // in the policy triggerables below — it must never resolve via the + // Viewer default fallback. Without `id` the caller supplies the code + // verbatim; with `id` it selects any `app_script` row by number (no + // app/workspace scoping), so both let a caller run code the publisher + // never pinned for this app. + let is_inline_raw_code = payload.raw_code.is_some(); + // Compute the path for the triggerables map: // - flow: `flow/` // - script: `script/` @@ -2314,7 +2455,15 @@ async fn execute_component( .get(path) // start with `path` in case we can avoid the next` format!`. .or_else(|| triggerables_v2.get(&format!("{}:{}", payload.component, &path))) .or(match policy.execution_mode { - ExecutionMode::Viewer => Some(&policy_triggerables_default), + // A Viewer app may invoke any deployed `script`/`flow` it + // references (resolved as the caller), but caller-supplied + // inline `raw_code` must match a publisher-pinned + // `rawscript/` entry — otherwise an unauthorized caller + // (e.g. an operator, barred from `/jobs/run/preview`) could + // run code the publisher never pinned for this app. + ExecutionMode::Viewer if !is_inline_raw_code => { + Some(&policy_triggerables_default) + } _ => None, }) .ok_or_else(|| Error::BadRequest(format!("Path {path} forbidden by policy")))?; @@ -3208,18 +3357,6 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> { Ok((permissioned_as, email)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return crate::users::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2", - "app", - ) - .await; -} - async fn exists_app( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b0cd5f61b0..c6146fb47a 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -6,133 +6,537 @@ * LICENSE-AGPL for a copy of the license. */ -use crate::{ - db::{ApiAuthed, DB}, - users::{maybe_refresh_folders, require_owner_of_path}, -}; +use crate::db::{ApiAuthed, DB}; use axum::{ extract::{Extension, Path}, - routing::{delete, post}, + routing::{get, post}, Json, Router, }; -use hyper::StatusCode; use serde::{Deserialize, Serialize}; -use windmill_common::{db::UserDB, error::Result, utils::StripPath}; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + user_drafts::{UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, + variables::{build_crypt, encrypt}, +}; pub fn workspaced_service() -> Router { Router::new() - .route("/create", post(create_draft)) - .route("/delete/{kind}/{*path}", delete(delete_draft)) + .route("/list", get(list_drafts)) + .route("/get/{kind}/{*path}", get(get_draft_for_user)) + .route("/update/{kind}/{*path}", post(update_draft)) } -#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)] -#[sqlx(type_name = "DRAFT_TYPE", rename_all = "lowercase")] -#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] -pub enum DraftType { - Script, - Flow, - App, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct Draft { +#[derive(Serialize, sqlx::FromRow)] +pub struct DraftListItem { + pub kind: UserDraftItemKind, pub path: String, - pub value: sqlx::types::Json>, - pub typ: DraftType, + /// Best-effort, read from the draft JSON's `summary` field when present. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// User-typed friendly path read from the draft JSON's `draft_path` (set by + /// the editors when it differs from the storage path, e.g. a never-deployed + /// item parked at `u/{user}/draft_{uuid}`). `None` when absent. Lets the + /// review page show the friendly name instead of the storage path, like the + /// home-page list endpoints. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// No deployed counterpart exists at this path — the draft is the whole + /// item. Kinds without a per-path backing table report `true`. + pub draft_only: bool, + /// The listed row is a legacy workspace-level draft (`email IS NULL`), + /// predating the per-user drafts migration. Only `true` when no per-user + /// row exists at this (path, kind) — the DISTINCT ON prefers an owned row. + pub legacy_draft: bool, + pub created_at: chrono::DateTime, } -pub async fn require_writer_of_path( - authed: &ApiAuthed, - path: &str, - w_id: &str, - db: DB, - kind: &DraftType, -) -> Result<()> { - if authed.is_admin { - return Ok(()); - } else if require_owner_of_path(authed, path).is_ok() { - return Ok(()); +/// Every draft the authed user has in this workspace, across all kinds — the +/// single source for the "Review & deploy drafts" page and the home-page +/// draft-count banner. One query over `draft`; `draft_only` is computed per +/// kind against the deployed table. +async fn list_drafts( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result>> { + // Operators have no drafts of their own (they can't write any, see + // `require_can_write_path`), so this list is always empty for them. They + // can still READ some collaborators' drafts via `/drafts/get`. + if authed.is_operator { + return Ok(Json(vec![])); + } + let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query()) + .bind(&w_id) + .bind(&authed.email) + .fetch_all(&db) + .await?; + Ok(Json(rows)) +} + +/// Build the `list_drafts` SQL, generating the `draft_only` CASE from +/// `deployed_table()` (shared single source — can't drift from the access +/// check). Table names come from the closed enum, never user input. Kinds +/// with no path-keyed table get no arm and fall to `ELSE true`. +/// `$1` = workspace_id, `$2` = email. +fn list_drafts_query() -> String { + let mut case = String::from("CASE d.typ::text\n"); + for kind in UserDraftItemKind::ALL { + let Some(table) = kind.deployed_table() else { + continue; + }; + // `script` rows are soft-deleted — a deleted script counts as "not + // deployed". No other backing table has a `deleted` flag. + let extra = if matches!(kind, UserDraftItemKind::Script) { + " AND t.deleted = false" + } else { + "" + }; + case.push_str(&format!( + " WHEN '{}' THEN NOT EXISTS(SELECT 1 FROM {} t WHERE t.workspace_id = d.workspace_id AND t.path = d.path{})\n", + kind.as_str(), + table, + extra + )); + } + case.push_str(" ELSE true\nEND"); + // `(d.email = $2 OR d.email IS NULL)` lists the user's own drafts AND the + // legacy NULL-email rows; `DISTINCT ON (d.path, d.typ)` with `email IS NULL` + // last collapses a (path, kind) that has both to the owned row. + format!( + r#"SELECT DISTINCT ON (d.path, d.typ) + d.path, + d.typ AS kind, + d.created_at, + d.value ->> 'summary' AS summary, + -- Friendly typed path, by kind (mirrors the home-page list + -- endpoints): scripts bind the Path widget to `script.path`, + -- so it round-trips through the draft JSON's own `path`; + -- flows/apps/raw-apps carry a separate `draft_path`. NULLIF + -- drops it when empty or equal to the storage path. + NULLIF( + NULLIF( + CASE WHEN d.typ::text = 'script' + THEN d.value ->> 'path' + ELSE d.value ->> 'draft_path' END, + ''), + d.path + ) AS draft_path, + (d.email IS NULL) AS legacy_draft, + {case} AS draft_only + FROM draft d + WHERE d.workspace_id = $1 AND (d.email = $2 OR d.email IS NULL) + ORDER BY d.path, d.typ, (d.email IS NULL)"# + ) +} + +#[derive(Deserialize, Debug)] +pub struct SaveDraftRequest { + /// Draft content to save. `null` (or omitted) signals a delete — the + /// row is removed under the same conflict rules as an upsert. + #[serde(default)] + pub value: Option>>, + /// Client's last known sync timestamp. When present and `force` is false, + /// the save is rejected if the server's `created_at` is more recent + /// (another writer moved the row forward). Omit on a first save. + #[serde(default)] + pub last_sync: Option>, + /// Skip the conflict check and unconditionally overwrite the server + /// copy. Use after the client has resolved the conflict locally. + #[serde(default)] + pub force: bool, + /// Delete-only: target the legacy workspace-level row (`email IS NULL`) + /// rather than the authed user's row. An upsert ignores it (always writes + /// the user's own row). Lets the review page discard a legacy draft, which + /// the email-scoped delete otherwise can't reach. + #[serde(default)] + pub legacy: bool, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum SaveDraftStatus { + Saved, + Conflict, +} + +#[derive(Serialize, Debug)] +pub struct SaveDraftResponse { + pub status: SaveDraftStatus, + /// On `saved`: when the change was applied (client remembers it as the + /// next `last_sync`). On `conflict`: the existing row's `created_at`. + pub current_timestamp: chrono::DateTime, +} + +/// Apply the current user's draft at (workspace, kind, path): non-null `value` +/// upserts, `null` (or omitted) deletes. Either way, when the existing row is +/// newer than `last_sync` (and `force` is false) the op is skipped and the +/// response is `status = conflict` + the server's current timestamp. +async fn update_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + Json(req): Json, +) -> Result> { + let email = &authed.email; + let path = path.to_path(); + require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; + + let applied_at = if let Some(value) = &req.value { + // Secret variable values must never sit in `draft.value` in plaintext + // (see `encrypt_secret_variable_value`). + let serialized = if kind == UserDraftItemKind::Variable { + encrypt_secret_variable_value(&db, &w_id, value.0.get()).await? + } else { + serde_json::to_string(value).unwrap() + }; + // Upsert. The conflict check rides on the DO UPDATE WHERE clause — + // when the row is newer than `last_sync`, RETURNING yields nothing. + sqlx::query_scalar!( + r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at) + VALUES ($1, $2, $3, $4, $5::text::json, now()) + ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL + DO UPDATE SET value = EXCLUDED.value, created_at = now() + WHERE $7::bool = true + OR $6::timestamptz IS NULL + OR draft.created_at <= $6::timestamptz + RETURNING created_at"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + serialized, + req.last_sync, + req.force, + ) + .fetch_optional(&db) + .await? } else { - match kind { - DraftType::Script => crate::scripts::require_is_writer(authed, path, w_id, db).await, - DraftType::Flow => crate::flows::require_is_writer(authed, path, w_id, db).await, - DraftType::App => crate::apps::require_is_writer(authed, path, w_id, db).await, + // Delete, same conflict rule in the WHERE clause. Returns NULL when + // the row was too new (conflict) OR already absent (idempotent) — + // disambiguated below. `legacy` ($7) retargets to the NULL-email row. + sqlx::query_scalar!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END) + AND path = $3 + AND typ = $4 + AND ($6::bool = true + OR $5::timestamptz IS NULL + OR created_at <= $5::timestamptz) + RETURNING now() as "now!""#, + &w_id, + email, + path, + kind as UserDraftItemKind, + req.last_sync, + req.force, + req.legacy, + ) + .fetch_optional(&db) + .await? + }; + + if let Some(ts) = applied_at { + return Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: ts, + })); + } + + // No row affected: either the row was newer than `last_sync` (conflict), + // or it was a delete with no row present (idempotent ok). Distinguished + // by re-reading. + let existing = sqlx::query_scalar!( + r#"SELECT created_at FROM draft + WHERE workspace_id = $1 + AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END) + AND path = $3 AND typ = $4"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + req.legacy, + ) + .fetch_optional(&db) + .await?; + + match existing { + Some(ts) => Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Conflict, + current_timestamp: ts, + })), + // Delete + nothing-was-there ⇒ report success with server's NOW(). + None => { + let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#) + .fetch_one(&db) + .await?; + Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: now, + })) } } } -async fn create_draft( +/// For variable-kind drafts with `variable.is_secret == true`, encrypt +/// `variable.value` with the workspace crypt key and mark it +/// `$encrypted:` so the secret never persists in plaintext at rest. +/// Already-marked values pass through untouched. Unexpected/malformed shapes +/// pass through unchanged — the draft store is schema-less by design. +async fn encrypt_secret_variable_value(db: &DB, w_id: &str, raw: &str) -> Result { + let Ok(mut v) = serde_json::from_str::(raw) else { + return Ok(raw.to_string()); + }; + let is_secret = v + .get("variable") + .and_then(|x| x.get("is_secret")) + .and_then(|x| x.as_bool()) + .unwrap_or(false); + if is_secret { + if let Some(serde_json::Value::String(s)) = + v.get_mut("variable").and_then(|x| x.get_mut("value")) + { + if !s.is_empty() && !s.starts_with(ENCRYPTED_DRAFT_PREFIX) { + let mc = build_crypt(db, w_id).await?; + *s = format!("{ENCRYPTED_DRAFT_PREFIX}{}", encrypt(&mc, s)); + } + } + } + Ok(v.to_string()) +} + +#[derive(Deserialize, Debug)] +pub struct GetDraftQuery { + /// Workspace username of the draft owner. Omit to fetch the legacy + /// NULL-email row, if any. Resolved to an email server-side — emails are + /// not part of the public draft API. + pub username: Option, +} + +#[derive(Serialize, Debug)] +pub struct DraftForUser { + pub value: sqlx::types::Json>, + pub created_at: chrono::DateTime, +} + +/// Fetch a specific user's (or the legacy NULL row's) draft content at a path. +/// Backs the "other users' drafts" banner in editors. The owner is identified +/// by workspace username so emails never reach the client. +async fn get_draft_for_user( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, - Path(w_id): Path, - Json(draft): Json, -) -> Result<(StatusCode, String)> { - let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await; + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + axum::extract::Query(query): axum::extract::Query, +) -> Result> { + let path = path.to_path(); + // Drawer kinds keep drafts private to their owner (see + // `shares_drafts_across_users`) — also what blocks reading another user's + // secret-variable `$encrypted:` ciphertext. + if !kind.shares_drafts_across_users() { + return Err(Error::NotFound( + "drafts for this item kind are private to their owner".to_string(), + )); + } + require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; - let mut tx = user_db.begin(&authed).await?; + // Username -> email, scoped to the workspace. None signals "fetch the + // legacy NULL-email row" (distinct from a username with no draft, which + // 404s below). + let owner_email: Option = if let Some(username) = &query.username { + let email = sqlx::query_scalar!( + r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#, + &w_id, + username, + ) + .fetch_optional(&db) + .await?; + match email { + Some(e) => Some(e), + // The `admins` workspace has no `usr` rows (username IS the email + // there), so accept it as the owner email directly. + None if w_id == "admins" => Some(username.clone()), + None => { + return Err(Error::NotFound(format!( + "no user with username {username} in workspace" + ))) + } + } + } else { + None + }; - require_writer_of_path(&authed, &draft.path, &w_id, db, &draft.typ).await?; - - sqlx::query!( - "INSERT INTO draft - (workspace_id, path, value, typ) - VALUES ($1, $2, $3::text::json, $4) - ON CONFLICT (workspace_id, path, typ) - DO UPDATE SET value = EXCLUDED.value, created_at = now()", + let row = sqlx::query_as!( + DraftForUser, + r#"SELECT value as "value!: sqlx::types::Json>", created_at + FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND email IS NOT DISTINCT FROM $4"#, &w_id, - draft.path, - //to preserve key orders - serde_json::to_string(&draft.value).unwrap(), - draft.typ as DraftType, + path, + kind as UserDraftItemKind, + owner_email, ) - .execute(&mut *tx) + .fetch_optional(&db) .await?; - tx.commit().await?; - - Ok((StatusCode::CREATED, format!("draft {} created", draft.path))) + row.map(Json).ok_or_else(|| { + Error::NotFound(format!( + "no draft for {} at {path}", + query.username.as_deref().unwrap_or("") + )) + }) } -async fn delete_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3", - path.to_path(), - kind as DraftType, - w_id - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - - Ok(format!("deleted draft")) +/// The deployed table RLS resolves item-level `extra_perms` against. +/// Delegates to `UserDraftItemKind::deployed_table()` (the shared single +/// source); `None` kinds fall through to the path-only access check. +fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { + kind.deployed_table() } -// async fn get_draft( -// authed: ApiAuthed, -// Extension(user_db): Extension, -// Path((w_id, path)): Path<(String, StripPath)>, -// ) -> JsonResult { -// let path = path.to_path(); -// let mut tx = user_db.begin(&authed).await?; +/// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are +/// rejected outright. Two layers: +/// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable +/// `f/`) — mirror what RLS reads from the same JWT claims, and are the +/// ENTIRE check for draft-only paths (no deployed row for RLS to use). +/// 2. An RLS write-probe on the deployed row (`SELECT ... FOR UPDATE`) for +/// what the path can't answer, above all item-level extra_perms grants. +async fn require_can_write_path( + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + // Operators are read-only and never WRITE drafts. Read access is + // deliberately asymmetric: `require_can_read_path` has no operator block, + // so an operator can still READ a draft they can read via `/drafts/get`, + // mirroring their read access to deployed content. Intended. + if authed.is_operator { + return Err(Error::NotAuthorized( + "operators cannot save drafts".to_string(), + )); + } + // Cheap claim-based namespace checks first: they evaluate the same JWT + // claims RLS reads, so the outcome matches the policies while sparing the + // autosave hot path a DB round-trip. They are also the ENTIRE check for + // draft-only paths (no deployed row for RLS) — without them any member + // could plant a draft in another user's `u/` namespace, surfaced to every + // reader of the path. `require_owner_of_path` covers admin / `u/{own}` / + // folder owner; group membership and the folder WRITE bit are layered on. + if windmill_api_auth::require_owner_of_path(authed, path).is_ok() { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 3 { + match parts[0] { + "g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()), + "f" => { + let folder = parts[1]; + let has_write = |a: &ApiAuthed| { + a.folders + .iter() + .any(|(name, write, owner)| name == folder && (*write || *owner)) + }; + if has_write(authed) { + return Ok(()); + } + let refreshed = + windmill_api_auth::maybe_refresh_folders(path, w_id, authed.clone(), db).await; + if has_write(&refreshed) { + return Ok(()); + } + } + _ => {} + } + } + // Defer to RLS for what the path can't answer (item-level extra_perms + // grants). Postgres applies UPDATE policies to rows locked via `SELECT + // ... FOR UPDATE`, so a returned row means the canonical write policies + // would let this user UPDATE it — no rule re-implemented here. Draft-only + // paths have no row, so the namespace rules above were the whole check. + if let Some(table) = kind.deployed_table() { + // `table` is from the closed enum, never user input. LIMIT 1 keeps the + // probe to one row lock — `script` has a row per version at the path, + // and locking the whole history would serialize against deploys. + let query = format!( + "SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1 FOR UPDATE" + ); + let mut tx = user_db.clone().begin(authed).await?; + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotAuthorized(format!( + "you don't have write permission on {path}" + ))) +} -// let script_o = sqlx::query_as!( -// Draft, -// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#, -// path, -// w_id -// ) -// .fetch_optional(&mut *tx) -// .await?; -// tx.commit().await?; - -// let draft = not_found_if_none(script_o, "draft", path)?; -// Ok(Json(draft)) -// } +/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers: +/// 1. admin → always. +/// 2. Path-prefix match against own `u/{username}` or any folder in +/// `authed.folders` (the precomputed read set, with groups + direct +/// grants already factored in). +/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level +/// extra_perms grants that bypass folder/owner checks. +/// Both "not readable" and "doesn't exist" return 404 — don't leak existence. +/// +/// Operators are deliberately NOT rejected here (unlike +/// `require_can_write_path`): read-only users keep their read access to +/// deployed content, so an operator can view a collaborator's draft for the +/// cross-user kinds they can already read, while never writing one. Drawer +/// kinds never reach this (`get_draft_for_user` rejects them up front). +async fn require_can_read_path( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 2 { + match parts[0] { + "u" if parts[1] == authed.username => return Ok(()), + "f" => { + let folder = parts[1]; + if authed.folders.iter().any(|(name, _, _)| name == folder) { + return Ok(()); + } + } + _ => {} + } + } + if let Some(table) = table_for_kind(kind) { + let mut tx = user_db.clone().begin(authed).await?; + let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1"); + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotFound(format!("no draft visible at {path}"))) +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index b327476c86..a9e307fc6e 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -34,8 +34,8 @@ use windmill_common::db::UserDbWithAuthed; use windmill_common::error::JsonResult; use windmill_common::flow_status::{JobResult, RestartedFrom}; use windmill_common::jobs::{ - format_completed_job_result, format_result, is_valid_entrypoint_name, DynamicInput, - ENTRYPOINT_OVERRIDE, + format_completed_job_result, format_result, is_safe_log_file_path, is_valid_entrypoint_name, + DynamicInput, ENTRYPOINT_OVERRIDE, }; #[cfg(feature = "run_inline")] use windmill_common::jobs::{ @@ -1237,6 +1237,59 @@ async fn require_job_update_read_access( require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await } +/// Whether a validated approval token should grant the job-read bypass. The token alone +/// is sufficient unless the current approval step has `user_auth_required`, in which case +/// only an authorized approver may read the job (and thus its args/flow inputs). +async fn approval_token_grants_view( + db: &DB, + w_id: &str, + flow_id: Uuid, + opt_authed: &Option, +) -> error::Result { + #[derive(sqlx::FromRow)] + struct FlowAuthRow { + script_path: Option, + email: String, + flow_status: Option, + } + let row = sqlx::query_as::<_, FlowAuthRow>( + "SELECT j.runnable_path as script_path, j.permissioned_as_email as email, s.flow_status + FROM v2_job j + LEFT JOIN v2_job_status s ON s.id = j.id + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(flow_id) + .bind(w_id) + .fetch_optional(db) + .await?; + let Some(row) = row else { + return Ok(false); + }; + + // approval_conditions are stored at the top of flow_status for both classic and WAC flows. + let approval_conditions = row + .flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions").cloned()) + .and_then(|v| serde_json::from_value::(v).ok()); + + let user_auth_required = approval_conditions + .as_ref() + .map(|ac| ac.user_auth_required) + .unwrap_or(false); + + if !user_auth_required { + return Ok(true); + } + + Ok(can_approve_step( + opt_authed, + &approval_conditions, + row.script_path.as_deref(), + row.email.as_str(), + )) +} + async fn get_job( OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, @@ -1255,17 +1308,31 @@ async fn get_job( // so the approval page can render job metadata without login. The approval // URL usually carries the flow id directly — try that first and only // resolve the parent flow if the direct check fails. - let has_valid_approval_token = if let Some(ref token) = approval_token { + let approved_flow_id: Option = if let Some(ref token) = approval_token { if validate_approval_token(&db, token, id, &w_id).await.is_ok() { - true + Some(id) } else if let Ok(flow_id) = get_flow_id_for_job(&db, id).await { - flow_id != id + if flow_id != id && validate_approval_token(&db, token, flow_id, &w_id) .await .is_ok() + { + Some(flow_id) + } else { + None + } } else { - false + None } + } else { + None + }; + + // A valid token grants the read bypass, but only to an authorized approver when the + // current approval step requires auth — otherwise the token (which lives in the shareable + // approval URL) would leak the flow inputs to anyone holding the link. + let has_valid_approval_token = if let Some(flow_id) = approved_flow_id { + approval_token_grants_view(&db, &w_id, flow_id, &opt_authed).await? } else { false }; @@ -1846,12 +1913,17 @@ async fn get_logs_from_disk( if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { for file_p in &file_index { - if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR)) - .await - .is_ok() - { + if !is_safe_log_file_path(file_p) { return None; } + let local_file = format!("{}/{file_p}", *WINDMILL_DIR); + // Defense in depth: refuse to read through a symlink so a planted + // symlink under the log directory cannot exfiltrate arbitrary files. + match tokio::fs::symlink_metadata(&local_file).await { + Ok(meta) if meta.file_type().is_symlink() => return None, + Ok(_) => {} + Err(_) => return None, + } } let logs = logs.to_string(); @@ -3133,6 +3205,43 @@ struct ApprovalInfo { approvers: Vec, } +/// Whether `opt_authed` is allowed to approve — and therefore view — this approval step. +/// Mirrors the authorization performed at the resume boundary: workspace admins and owners +/// of the runnable always qualify; otherwise the approval conditions (user_auth_required / +/// user_groups_required / self_approval_disabled) decide. When the step does not require auth, +/// an anonymous (token-only) caller qualifies. +fn can_approve_step( + opt_authed: &Option, + approval_conditions: &Option, + script_path: Option<&str>, + trigger_email: &str, +) -> bool { + match opt_authed { + Some(authed) => { + if authed.is_admin { + return true; + } + let is_owner = script_path + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + if is_owner { + return true; + } + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + ) + .is_ok() + } + // Not logged in — only acceptable when the step does not require auth. + None => !approval_conditions + .as_ref() + .map(|ac| ac.user_auth_required) + .unwrap_or(false), + } +} + async fn get_approval_info( OptAuthed(opt_authed): OptAuthed, Extension(db): Extension, @@ -3291,32 +3400,33 @@ async fn get_approval_info( .map(|ac| ac.user_auth_required) .unwrap_or(false); - // Determine if current user can approve - let can_approve = if let Some(ref authed) = opt_authed { - if authed.is_admin { - true - } else { - let is_owner = row - .script_path - .as_deref() - .map(|p| require_owner_of_path(authed, p).is_ok()) - .unwrap_or(false); - if is_owner { - true - } else { - let trigger_email = row.email.as_str(); - conditionally_require_authed_user( - Some(authed.clone()), - approval_conditions.clone(), - trigger_email, - ) - .is_ok() - } - } - } else { - // Not logged in — can approve only if no auth required - !user_auth_required - }; + // Determine if current user can approve this step. + let can_approve = can_approve_step( + &opt_authed, + &approval_conditions, + row.script_path.as_deref(), + row.email.as_str(), + ); + + // When the step requires auth, the approval details must be revealed only to authorized + // approvers — a valid token alone is not sufficient. Return a stripped response that lets + // the frontend render the sign-in / not-authorized state without leaking the form, + // description, prefilled args, or other approvers' identities. + let can_view = !user_auth_required || can_approve; + if !can_view { + return Ok(Json(ApprovalInfo { + flow_id: row.id, + form_schema: None, + description: None, + default_args: None, + enums: None, + approval_conditions, + can_approve: false, + user_auth_required, + hide_cancel: None, + approvers: vec![], + })); + } // Get existing approvers let approvers: Vec = sqlx::query_as::<_, (i32, Option)>( @@ -6211,8 +6321,14 @@ async fn run_inline_preview_script( Path(w_id): Path, Json(preview): Json, ) -> error::Result { - // Same arbitrary-code class as run_preview_script: a narrowly-scoped token - // must not be able to run request-supplied code through inline preview. + // Same arbitrary-code class as run_preview_script: operators are blocked from + // running request-supplied code, and a narrowly-scoped token must not escape + // its scope through inline preview. + if authed.is_operator { + return Err(error::Error::NotAuthorized( + "Operators cannot run preview jobs for security reasons".to_string(), + )); + } check_scopes(&authed, || format!("jobs:run"))?; if let Some(job_id) = job_id { register_potential_assets_on_inline_execution(job_id, &w_id, &preview); @@ -9216,3 +9332,90 @@ async fn get_otel_traces( Ok(Json(traces)) } + +#[cfg(test)] +mod approval_view_gate_tests { + use super::*; + + fn authed(username: &str, is_admin: bool, groups: Vec) -> ApiAuthed { + ApiAuthed { + email: format!("{username}@example.com"), + username: username.to_string(), + is_admin, + is_operator: false, + groups, + folders: vec![], + scopes: None, + username_override: None, + token_prefix: None, + read_only: false, + } + } + + fn conds(user_auth_required: bool, groups: Vec) -> ApprovalConditions { + ApprovalConditions { + user_auth_required, + user_groups_required: groups, + self_approval_disabled: false, + } + } + + // Mirrors the view gate applied in get_approval_info and get_job: + // details are revealed only when the step doesn't require auth OR the caller + // is an authorized approver. + fn can_view( + opt_authed: &Option, + approval_conditions: &Option, + script_path: Option<&str>, + trigger_email: &str, + ) -> bool { + let user_auth_required = approval_conditions + .as_ref() + .map(|c| c.user_auth_required) + .unwrap_or(false); + !user_auth_required + || can_approve_step(opt_authed, approval_conditions, script_path, trigger_email) + } + + #[test] + fn anonymous_cannot_view_when_auth_required() { + // The regression: an unauthenticated holder of the approval token must see nothing. + let c = Some(conds(true, vec![])); + assert!(!can_view(&None, &c, Some("f/team/flow"), "trigger@example.com")); + } + + #[test] + fn anonymous_can_view_when_no_auth_required() { + // Unchanged behaviour: token alone is sufficient when auth isn't required. + let c = Some(conds(false, vec![])); + assert!(can_view(&None, &c, Some("f/team/flow"), "trigger@example.com")); + // No approval conditions at all also allows token-only view. + assert!(can_view(&None, &None, Some("f/team/flow"), "trigger@example.com")); + } + + #[test] + fn admin_can_view_when_auth_required() { + let c = Some(conds(true, vec!["approvers".to_string()])); + let a = Some(authed("alice", true, vec![])); + assert!(can_view(&a, &c, Some("f/team/flow"), "trigger@example.com")); + } + + #[test] + fn owner_can_view_when_auth_required() { + let c = Some(conds(true, vec!["approvers".to_string()])); + let a = Some(authed("bob", false, vec![])); + // bob owns u/bob/flow regardless of group membership. + assert!(can_view(&a, &c, Some("u/bob/flow"), "trigger@example.com")); + } + + #[cfg(feature = "enterprise")] + #[test] + fn group_membership_decides_view_when_auth_required() { + let c = Some(conds(true, vec!["approvers".to_string()])); + let member = Some(authed("carol", false, vec!["approvers".to_string()])); + let outsider = Some(authed("dave", false, vec!["other".to_string()])); + // Use a non-owned folder path so ownership doesn't short-circuit the check. + assert!(can_view(&member, &c, Some("f/team/flow"), "trigger@example.com")); + assert!(!can_view(&outsider, &c, Some("f/team/flow"), "trigger@example.com")); + } +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 41872d0353..0f5f9c40ca 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -77,8 +77,8 @@ mod capture; mod concurrency_groups; mod db; mod db_health; - mod drafts; + #[cfg(feature = "private")] pub mod ee; pub mod ee_oss; @@ -553,8 +553,8 @@ pub async fn run_server( "/concurrency_groups", concurrency_groups::workspaced_service(), ) - .nest("/embeddings", embeddings::workspaced_service()) .nest("/drafts", drafts::workspaced_service()) + .nest("/embeddings", embeddings::workspaced_service()) .nest("/favorites", favorite::workspaced_service()) .nest("/flows", flows::workspaced_service()) .nest( @@ -1162,6 +1162,7 @@ async fn list_workspace_labels( UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL + UNION ALL SELECT labels FROM folder WHERE workspace_id = $1 AND labels IS NOT NULL ) t ORDER BY 1", &w_id ) diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index fc3b1164f5..2e9b5babe7 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -593,7 +593,7 @@ pub fn all_tools() -> Vec { name: Cow::Borrowed("createScript"), description: Cow::Borrowed("create script: Creates a new script when the path does not already exist. Creates a new version of an existing script when called with the same path and the current `parent_hash`"), - instructions: Cow::Borrowed("To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed."), + instructions: Cow::Borrowed("To create a NEW script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language, and leave parent_hash unset. For TypeScript, use 'bun' unless deno-specific APIs are needed. To UPDATE an existing script, do NOT delete and recreate it: call this tool with the same path and set parent_hash to the script's current hash, which you can read from the `hash` field returned by getScriptByPath. This creates a new version while preserving the script's history."), path: Cow::Borrowed("/w/{workspace}/scripts/create"), method: Cow::Borrowed("POST"), path_params_schema: None, @@ -604,6 +604,9 @@ Creates a new version of an existing script when called with the same path and t "path": { "type": "string" }, + "parent_hash": { + "type": "string" + }, "summary": { "type": "string" }, diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index c7a1b291dd..4073758ba9 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -41,7 +41,6 @@ pub async fn get_item_schema( sqlb.and_where("o.path = ?".bind(&path)); sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)); sqlb.and_where("o.archived = false"); - sqlb.and_where("o.draft_only IS NOT TRUE"); let sql = sqlb.sql().map_err(|e| { tracing::error!("failed to build sql: {}", e); ErrorData::internal_error(format!("failed to build sql: {}", e), None) @@ -147,8 +146,7 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen .bind(&authed.username)); } sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)) - .and_where("o.archived = false") - .and_where("o.draft_only IS NOT TRUE"); + .and_where("o.archived = false"); if item_type == "script" { sqlb.and_where("o.auto_kind IS NULL"); diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index c929a85a84..3d5342e3a5 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -847,8 +847,8 @@ async fn offboard_user_from_workspace<'c>( let flows_reassigned = sqlx::query_scalar!( r#"WITH inserted AS ( INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) - SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) + SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 56bfd62ce5..271e9497fe 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -70,9 +70,9 @@ async fn list_paths( let mut paths: Vec = sqlx::query_scalar!( r#" SELECT path AS "path!" FROM ( - (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000) + (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000) UNION - (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000) + (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000) UNION (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000) UNION diff --git a/backend/windmill-api/src/raw_apps.rs b/backend/windmill-api/src/raw_apps.rs index 2198ce9b26..d7bc3bc6c0 100644 --- a/backend/windmill-api/src/raw_apps.rs +++ b/backend/windmill-api/src/raw_apps.rs @@ -41,6 +41,10 @@ pub struct ListableApp { pub version: i32, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Labels inherited from the parent folder, computed at read time. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, } async fn list_apps( @@ -62,6 +66,7 @@ async fn list_apps( "app.version", "favorite.path IS NOT NULL as starred", "app.labels", + "folder_labels(app.workspace_id, app.path) as inherited_labels", ]) .left() .join("favorite") @@ -90,7 +95,11 @@ async fn list_apps( if let Some(label) = &lq.label { for l in label.split(',') { - sqlb.and_where("app.labels @> ARRAY[?]".bind(&l.trim())); + sqlb.and_where( + "(app.labels @> ARRAY[?] OR folder_labels(app.workspace_id, app.path) @> ARRAY[?])" + .bind(&l.trim()) + .bind(&l.trim()), + ); } } diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index d53998355b..0da987e675 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -457,8 +457,8 @@ async fn update_username_in_workpsace<'c>( // ---- flows ---- sqlx::query!( r#"INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) + SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index d63720d9a9..5f472c49d3 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -9,11 +9,12 @@ use axum::{body::Body, response::Response}; use serde::{Deserialize, Deserializer}; -pub use windmill_api_auth::{check_scopes, require_devops_role, require_super_admin}; +pub use windmill_api_auth::{ + build_scope_path_predicate, check_scopes, require_devops_role, require_super_admin, +}; #[cfg(feature = "private")] pub use windmill_common::usernames::generate_instance_wide_unique_username; -pub use windmill_common::utils::WithStarredInfoQuery; #[cfg(feature = "enterprise")] pub use windmill_alerting::{ diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 63b9e84863..dae1be0614 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -367,7 +367,6 @@ where "edited_by", "permissioned_as", "archived", - "has_draft", "error", "last_server_ping", "server_id", @@ -631,7 +630,7 @@ pub(crate) async fn tarball_workspace( Some(t) => Err(Error::BadRequest(format!("Invalid Archive Type {t}"))), }?; { - let folders = sqlx::query_as::<_, Folder>("SELECT name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as FROM folder WHERE workspace_id = $1") + let folders = sqlx::query_as::<_, Folder>("SELECT name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels FROM folder WHERE workspace_id = $1") .bind(&w_id) .fetch_all(&mut *tx) .await?; @@ -650,7 +649,6 @@ pub(crate) async fn tarball_workspace( { let scripts = sqlx::query_as::<_, Script>(&format!( "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false - AND (draft_only IS NULL OR draft_only = false) AND created_at = (select max(created_at) from script where path = o.path AND \ workspace_id = $1)", windmill_common::scripts::SCRIPT_COLUMNS, @@ -786,10 +784,10 @@ pub(crate) async fn tarball_workspace( { let flows = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.workspace_id = $1 AND flow.archived = false AND (flow.draft_only IS NULL OR flow.draft_only = false)", + WHERE flow.workspace_id = $1 AND flow.archived = false", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -838,8 +836,7 @@ pub(crate) async fn tarball_workspace( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, app.extra_perms, app_version.value, app_version.created_at, app_version.created_by, app_version.raw_app, app.labels from app, app_version - WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)] - AND (app.draft_only IS NULL OR app.draft_only = false)", + WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -918,7 +915,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::http::HttpTrigger; let handler = HttpTrigger; - let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let http_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -948,7 +945,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::websocket::WebsocketTrigger; let handler = WebsocketTrigger; - let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -978,7 +975,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::kafka::KafkaTrigger; let handler = KafkaTrigger; - let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1008,7 +1005,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::sqs::SqsTrigger; let handler = SqsTrigger; - let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1038,7 +1035,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::gcp::GcpTrigger; let handler = GcpTrigger; - let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1068,7 +1065,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::azure::AzureTrigger; let handler = AzureTrigger; - let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1098,7 +1095,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::nats::NatsTrigger; let handler = NatsTrigger; - let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1128,7 +1125,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::postgres::PostgresTrigger; let handler = PostgresTrigger; - let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1158,7 +1155,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::mqtt::MqttTrigger; let handler = MqttTrigger; - let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1188,7 +1185,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::email::EmailTrigger; let handler = EmailTrigger; - let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let email_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 88a7706b88..7e30547d9b 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -38,6 +38,7 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true serde_yml.workspace = true +erased-serde = "0.4" chrono.workspace = true chrono-tz.workspace = true hex.workspace = true diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 8192f186d0..732b9cdc30 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -63,6 +63,10 @@ pub const SANDBOX_IMAGE_CACHE_MAX_MB_SETTING: &str = "sandbox_image_cache_max_mb pub const SANDBOX_IMAGE_PULL_POLICY_SETTING: &str = "sandbox_image_pull_policy"; pub const SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING: &str = "sandbox_image_default_registry"; pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth"; +// Enables the `#ssh ` directive that reroutes bash execution to a +// remote host over SSH (enterprise feature). Off by default. See +// windmill-worker/src/ssh_executor_ee.rs. +pub const SSH_EXECUTION_SETTING: &str = "ssh_execution_enabled"; pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index fd05c03ec0..78f5a6ee5a 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -283,6 +283,19 @@ pub fn format_completed_job_result(mut cj: CompletedJob) -> CompletedJob { cj } +/// `log_file_index` is normally written by the worker as job-id-scoped relative +/// paths under the windmill log directory. Any code path that lets a request +/// control this value (e.g. job import) must reject entries that could escape +/// that directory, otherwise the log-reading endpoints become an arbitrary file +/// read primitive. Rejects path traversal (`..`) and absolute paths; on-disk +/// readers additionally refuse symlinks (see `get_logs_from_disk`). +pub fn is_safe_log_file_path(file_p: &str) -> bool { + !file_p.is_empty() + && !file_p.starts_with('/') + && !file_p.starts_with('\\') + && !file_p.split(['/', '\\']).any(|c| c == "..") +} + pub async fn get_logs_from_disk( log_offset: i32, logs: &str, @@ -291,12 +304,17 @@ pub async fn get_logs_from_disk( if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { for file_p in &file_index { - if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR)) - .await - .is_ok() - { + if !is_safe_log_file_path(file_p) { return None; } + let local_file = format!("{}/{file_p}", *WINDMILL_DIR); + // Defense in depth: refuse to read through a symlink so a planted + // symlink under the log directory cannot exfiltrate arbitrary files. + match tokio::fs::symlink_metadata(&local_file).await { + Ok(meta) if meta.file_type().is_symlink() => return None, + Ok(_) => {} + Err(_) => return None, + } } let logs = logs.to_string(); @@ -439,3 +457,30 @@ pub struct WorkerInternalServerInlineUtils { // The server cannot call the worker functions directly because they are independent crates pub static WORKER_INTERNAL_SERVER_INLINE_UTILS: OnceCell = OnceCell::new(); + +#[cfg(test)] +mod tests { + use super::is_safe_log_file_path; + + #[test] + fn safe_log_file_paths_are_accepted() { + // Legit worker-written entries are job-id-scoped relative paths. + assert!(is_safe_log_file_path( + "0190d3e2-0000-7000-8000-000000000000/0.txt" + )); + assert!(is_safe_log_file_path("logs/abc/chunk1.log")); + assert!(is_safe_log_file_path("file..with..dots.txt")); + } + + #[test] + fn traversal_and_absolute_paths_are_rejected() { + assert!(!is_safe_log_file_path("")); + assert!(!is_safe_log_file_path("../../../../etc/passwd")); + assert!(!is_safe_log_file_path("a/../../etc/passwd")); + assert!(!is_safe_log_file_path("..")); + assert!(!is_safe_log_file_path("/etc/passwd")); + assert!(!is_safe_log_file_path("/proc/self/environ")); + assert!(!is_safe_log_file_path("\\windows\\path")); + assert!(!is_safe_log_file_path("a\\..\\..\\b")); + } +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 7d74822807..3b73e29b82 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -103,6 +103,7 @@ pub mod teams_oss; pub mod tracing_init; pub mod trashbin; pub mod triggers; +pub mod user_drafts; pub mod usernames; pub mod users; pub mod utils; @@ -579,13 +580,22 @@ impl PgDatabase { Some(s) => s.to_string(), None => "prefer".to_string(), }; + // Encode host/dbname too: an unencoded '@', '/', '?' or '&' would otherwise + // reshape the parsed URI (inject libpq params / alter host). Bracketed IPv6 + // literals ([::1]) are passed through unencoded — percent-encoding their + // '['/']'/':' would stop them parsing as a host. + let host = if self.host.starts_with('[') && self.host.ends_with(']') { + self.host.clone() + } else { + urlencoding::encode(&self.host).into_owned() + }; format!( "postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}", user = urlencoding::encode(&self.user.as_deref().unwrap_or("postgres")), password = urlencoding::encode(&self.password.as_deref().unwrap_or("")), - host = &self.host, + host = host, port = self.port.unwrap_or(5432), - dbname = self.dbname, + dbname = urlencoding::encode(&self.dbname), sslmode = sslmode ) } diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 38cd444f29..0cbcb4a1c1 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -97,7 +97,6 @@ pub async fn prefetch_cached_script( language: script.language, kind: script.kind, tag: script.tag, - draft_only: script.draft_only, envs: script.envs, dedicated_worker: script.dedicated_worker, ws_error_handler_muted: script.ws_error_handler_muted, @@ -116,6 +115,7 @@ pub async fn prefetch_cached_script( assets: script.assets, modules: script.modules, labels: script.labels, + inherited_labels: script.inherited_labels, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( script.runnable_settings.concurrency_key, @@ -362,7 +362,6 @@ pub async fn clone_script<'c>( language: s.language, kind: Some(s.kind), tag: s.tag, - draft_only: s.draft_only, envs: s.envs, concurrency_settings: concurrency_settings.maybe_fallback( s.runnable_settings.concurrency_key, @@ -409,14 +408,14 @@ pub async fn clone_script<'c>( INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, \ created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs new file mode 100644 index 0000000000..e008ccc658 --- /dev/null +++ b/backend/windmill-common/src/user_drafts.rs @@ -0,0 +1,536 @@ +/* + * Author: Diego Imbert + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Shared types and helpers for the per-user `draft` table. Lives in +//! `windmill-common` so entity crates can use it without depending on the +//! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns. + +// `DraftUserRef` lives in `windmill-types` (where the list-endpoint row +// structs `ListableScript`/`ListableFlow` declare `Vec` and +// can't reach `windmill-common` without a cycle). Re-exported here so draft +// handlers keep a single import path. +pub use windmill_types::user_drafts::DraftUserRef; + +use crate::db::DB; +use crate::error::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Item kinds a user can have an autosaved draft on. Must stay in lockstep +/// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND` +/// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration). +/// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`). +#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserDraftItemKind { + Script, + Flow, + App, + RawApp, + Resource, + Variable, + TriggerSchedule, + TriggerWebhook, + TriggerDefaultEmail, + TriggerEmail, + TriggerHttp, + TriggerWebsocket, + TriggerPostgres, + TriggerKafka, + TriggerNats, + TriggerMqtt, + TriggerSqs, + TriggerGcp, + TriggerAzure, + TriggerPoll, + TriggerCli, + TriggerNextcloud, + TriggerGoogle, + TriggerGithub, +} + +impl UserDraftItemKind { + /// The snake_case wire/DB string, for interpolating into dynamically-built + /// SQL (`?::DRAFT_KIND` binds want a string). + pub fn as_str(&self) -> &'static str { + match self { + UserDraftItemKind::Script => "script", + UserDraftItemKind::Flow => "flow", + UserDraftItemKind::App => "app", + UserDraftItemKind::RawApp => "raw_app", + UserDraftItemKind::Resource => "resource", + UserDraftItemKind::Variable => "variable", + UserDraftItemKind::TriggerSchedule => "trigger_schedule", + UserDraftItemKind::TriggerWebhook => "trigger_webhook", + UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email", + UserDraftItemKind::TriggerEmail => "trigger_email", + UserDraftItemKind::TriggerHttp => "trigger_http", + UserDraftItemKind::TriggerWebsocket => "trigger_websocket", + UserDraftItemKind::TriggerPostgres => "trigger_postgres", + UserDraftItemKind::TriggerKafka => "trigger_kafka", + UserDraftItemKind::TriggerNats => "trigger_nats", + UserDraftItemKind::TriggerMqtt => "trigger_mqtt", + UserDraftItemKind::TriggerSqs => "trigger_sqs", + UserDraftItemKind::TriggerGcp => "trigger_gcp", + UserDraftItemKind::TriggerAzure => "trigger_azure", + UserDraftItemKind::TriggerPoll => "trigger_poll", + UserDraftItemKind::TriggerCli => "trigger_cli", + UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud", + UserDraftItemKind::TriggerGoogle => "trigger_google", + UserDraftItemKind::TriggerGithub => "trigger_github", + } + } + + /// Every variant, for code that must enumerate kinds (e.g. generating + /// the `draft_only` existence SQL). + pub const ALL: [UserDraftItemKind; 24] = [ + UserDraftItemKind::Script, + UserDraftItemKind::Flow, + UserDraftItemKind::App, + UserDraftItemKind::RawApp, + UserDraftItemKind::Resource, + UserDraftItemKind::Variable, + UserDraftItemKind::TriggerSchedule, + UserDraftItemKind::TriggerWebhook, + UserDraftItemKind::TriggerDefaultEmail, + UserDraftItemKind::TriggerEmail, + UserDraftItemKind::TriggerHttp, + UserDraftItemKind::TriggerWebsocket, + UserDraftItemKind::TriggerPostgres, + UserDraftItemKind::TriggerKafka, + UserDraftItemKind::TriggerNats, + UserDraftItemKind::TriggerMqtt, + UserDraftItemKind::TriggerSqs, + UserDraftItemKind::TriggerGcp, + UserDraftItemKind::TriggerAzure, + UserDraftItemKind::TriggerPoll, + UserDraftItemKind::TriggerCli, + UserDraftItemKind::TriggerNextcloud, + UserDraftItemKind::TriggerGoogle, + UserDraftItemKind::TriggerGithub, + ]; + + /// The deployed table backing this kind, keyed by `(workspace_id, path)`. + /// SINGLE SOURCE for both the draft access check (which table RLS resolves + /// item-level `extra_perms` against) and the `draft_only` existence check. + /// `None` for kinds with no per-path backing table (webhook is a property + /// of a script/flow row; native triggers are keyed by external_id, not + /// path) — callers treat that as "no deployed counterpart": `draft_only = + /// true` and a path-only access check. + pub fn deployed_table(&self) -> Option<&'static str> { + use UserDraftItemKind::*; + match self { + Script => Some("script"), + Flow => Some("flow"), + App | RawApp => Some("app"), + Resource => Some("resource"), + Variable => Some("variable"), + TriggerSchedule => Some("schedule"), + TriggerHttp => Some("http_trigger"), + TriggerWebsocket => Some("websocket_trigger"), + TriggerPostgres => Some("postgres_trigger"), + TriggerKafka => Some("kafka_trigger"), + TriggerNats => Some("nats_trigger"), + TriggerMqtt => Some("mqtt_trigger"), + TriggerSqs => Some("sqs_trigger"), + TriggerGcp => Some("gcp_trigger"), + TriggerAzure => Some("azure_trigger"), + TriggerEmail | TriggerDefaultEmail => Some("email_trigger"), + TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle + | TriggerGithub => None, + } + } + + /// Whether OTHER users' drafts at a path are visible to a viewer (the + /// "others are editing" list, owner circles, and the `get_draft_for_user` + /// View JSON / Fork endpoint). Enabled only for the full-page editor items + /// which have the cross-user draft UI. Drawer items keep drafts private to + /// their owner: they have no such UI, and exposing a secret variable draft + /// would hand out the `$encrypted:` ciphertext, which a viewer could + /// launder into plaintext via a deploy. + pub fn shares_drafts_across_users(&self) -> bool { + use UserDraftItemKind::*; + matches!(self, Script | Flow | App | RawApp) + } +} + +/// Query-string flag accepted by every "get by path" route that supports +/// the draft overlay. `#[serde(flatten)]` into a route-specific query struct +/// when the route has other query fields. +#[derive(Debug, Deserialize, Default)] +pub struct WithDraftQuery { + /// When true, attach the authed user's draft (if any) as a separate + /// `draft` field. Defaults to false so non-editor callers see the + /// deployed shape unchanged. + #[serde(default)] + pub get_draft: bool, +} + +/// One row of `other_drafts_users`: a draft on the same path owned by +/// someone other than the authed user. `username` is `None` for the legacy +/// NULL-email row, surfaced in the frontend as a "Legacy draft" entry. +#[derive(Debug, Serialize)] +pub struct OtherDraftUser { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} + +/// Response wrapper: the deployed entity untouched plus the authed user's +/// draft (if any) as a sibling `draft` field, which the frontend pairs to +/// diff/restore/discard. The deployed and the draft are NEVER merged on the +/// server — the editor's saved shape can diverge arbitrarily, so any per-kind +/// translation lives in the frontend loader. `inner` is boxed-erased so a +/// possibly MB-scale deployed payload serializes in ONE pass (no +/// `serde_json::Value` round-trip) while keeping the struct non-generic. +#[derive(Serialize)] +pub struct WithDraftOverlay { + /// Deployed payload, flattened to the top level. + #[serde(flatten)] + pub inner: Box, + pub is_draft: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_saved_at: Option>, + /// True when no deployed row exists at this path: `inner` is only a + /// best-effort stand-in synthesized from the draft and only `draft` is + /// canonical. Frontend uses this to disable "diff/reset vs deployed" and + /// skip its deployed-shape parsing of `inner`. Omitted when false. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub no_deployed: bool, + /// The user's saved draft payload (whatever shape the editor wrote). + /// Present when `get_draft=true` and a draft exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft: Option, + /// Other users with a draft on the same path (excludes the authed user). + /// Empty list is omitted to keep the common-case response lean. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub other_drafts_users: Vec, +} + +/// List every other user (and the legacy NULL-email row, if any) with a +/// draft at `(workspace, kind, path)`. Returns usernames only — emails never +/// leave the server. LEFT JOIN against `usr` so an orphaned draft (user +/// removed from the workspace) still surfaces with `username = None`. The +/// authed user is excluded via `email <> authed_email`; the legacy row +/// matches because `email IS NULL` fails that comparison. +async fn fetch_other_drafts_users( + db: &DB, + w_id: &str, + authed_email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // The `admins` workspace has no `usr` rows (username IS the email there), + // so fall back to `d.email` when the join misses, else a real teammate's + // draft renders as a phantom "Legacy draft". The genuine NULL-email legacy + // row keeps `username = None` (its `d.email` is NULL, so the CASE yields NULL). + let rows = sqlx::query_as!( + OtherDraftUser, + r#"SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as "username?" + FROM draft d + LEFT JOIN usr u + ON u.workspace_id = d.workspace_id + AND u.email = d.email + WHERE d.workspace_id = $1 + AND d.path = $2 + AND d.typ = $3 + AND (d.email IS NULL OR d.email <> $4) + ORDER BY d.email NULLS LAST"#, + w_id, + path, + kind as UserDraftItemKind, + authed_email, + ) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// If `get_draft` is true AND the authed user has a draft for +/// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always +/// serialized into `inner` untouched. +pub async fn maybe_overlay_draft( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: T, +) -> Result +where + T: serde::Serialize + Send + 'static, +{ + // Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps) + // pass `get_draft = false` and render no overlay, so skip the `usr` join. + if !get_draft { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users: Vec::new(), + }); + } + + // Independent of the authed user's OWN draft: reset-to-deployed reloads + // still need to know who else is editing this path. Only the cross-user + // kinds surface it (see `shares_drafts_across_users`). + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + + // Prefer the user's OWN per-user draft, falling back to the legacy + // NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy + // row when an owned one exists. + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users, + }); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + + Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: false, + draft: Some(draft_json), + other_drafts_users, + }) +} + +/// One row of a "draft-only" list synthesis: a draft at `path` with no +/// deployed counterpart. `value` is the editor's saved JSON (each handler +/// maps it into its own `Listable*` shape). +#[derive(sqlx::FromRow)] +pub struct DraftOnlyListRow { + pub path: String, + pub value: sqlx::types::Json>, + pub created_at: DateTime, +} + +/// Fetch the authed user's draft rows at paths with NO deployed counterpart, +/// for synthesizing draft-only entries into a list response. Absence is +/// checked against `kind.deployed_table()` (the shared single source). +/// Returns empty for kinds with no path-keyed table. Callers keep their own +/// gating (`include_draft_only`, page 0, no filters) and row mapping. +pub async fn fetch_draft_only_list_rows( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, +) -> Result> { + let Some(table) = kind.deployed_table() else { + return Ok(Vec::new()); + }; + // `table` is from the closed `deployed_table()` enum, never user input. + // `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows + // AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL` + // last collapses a path that has both to the owned row. + let sql = format!( + "SELECT DISTINCT ON (path) path, value, created_at FROM draft \ + WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \ + AND (email = $3 OR email IS NULL) \ + AND NOT EXISTS (SELECT 1 FROM {table} t \ + WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \ + ORDER BY path, (email IS NULL)" + ); + let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql) + .bind(w_id) + .bind(kind.as_str()) + .bind(email) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// The get-by-path draft choreography, shared by every entity's "get by path" +/// route. Given the deployed entity as an `Option` (caller maps its own "not +/// found" to `None`): +/// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`). +/// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when +/// a draft exists, else the caller's 404 via `not_found`. +/// - `None` without `get_draft` → the caller's 404. +pub async fn overlay_or_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: Option, + not_found: impl FnOnce() -> crate::error::Error, +) -> Result { + match deployed { + Some(deployed) => { + maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await + } + None if get_draft => fetch_draft_only(db, w_id, email, kind, path) + .await? + .ok_or_else(not_found), + None => Err(not_found()), + } +} + +/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind. +/// Use when the item is DELETED outright: it's gone for everyone, so leaving +/// teammates' drafts behind would orphan them forever. Discarding one's OWN +/// draft while the item lives on goes through `update_draft` with `value: null`. +/// Idempotent on the no-draft case. +pub async fn delete_all_drafts_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3"#, + w_id, + path, + kind as UserDraftItemKind, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Discard the deploying user's OWN draft (plus the legacy NULL-email row) +/// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the +/// item moved, so the draft at the old path is orphaned (no FK to cascade). +/// Teammates keep theirs and get the StaleDraftModal on their next reload. +/// Idempotent on the no-draft case. +pub async fn delete_own_draft_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, + email: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND (email = $4 OR email IS NULL)"#, + w_id, + path, + kind as UserDraftItemKind, + email, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Fetch the authed user's draft as a standalone payload, for "get by path" +/// routes when no deployed row exists but a draft might. Returns it as a +/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and +/// `no_deployed = true`. Callers must have established no deployed row exists; +/// `Ok(None)` when there's also no draft (caller should 404). +/// +/// The draft JSON is expected to be an object (so `serde(flatten)` on `inner` +/// works); a non-object draft renders with no fields flattened. +pub async fn fetch_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`). + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + Ok(Some(WithDraftOverlay { + // Best-effort stand-in for the missing deployed — same JSON as `draft`. + inner: Box::new(draft_json.clone()), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: true, + draft: Some(draft_json), + other_drafts_users, + })) +} + +/// Marker prefix for draft secret values encrypted at rest with the workspace +/// crypt key (`build_crypt`). Written by `update_draft` for secret variables; +/// resolved back to plaintext by the variable deploy endpoints. +pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:"; + +fn draft_decrypt_error() -> crate::error::Error { + crate::error::Error::BadRequest( + "An encrypted draft secret could not be decrypted (the workspace encryption key may \ + have changed since the draft was saved). Reset the field and re-enter the secret." + .to_string(), + ) +} + +/// Decrypt a `$encrypted:`-marked draft value back to plaintext with the +/// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt +/// (e.g. the workspace key was rotated after the draft save). +pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result { + let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value); + let mc = crate::variables::build_crypt(db, w_id).await?; + crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error()) +} diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 9c944cb778..0d42b95a5a 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -49,12 +49,29 @@ pub struct ListableVariable { pub expires_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Labels inherited from the parent folder, computed at read time. + #[sqlx(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ws_specific: Option, #[serde(skip_serializing_if = "Option::is_none")] pub edited_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub edited_by: Option, + /// True when this row is a per-user draft with no deployed variable + /// at the same path. Surfaced by `include_draft_only` so the frontend + /// can render a "Draft" badge and the editor can open from the draft + /// alone. `None`/omitted on rows fetched from the `variable` table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// layered over a deployed variable or a synthesized draft-only row. + /// Drives the `*` suffix on the variables page. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index c7816d7a70..e3bd7ef7ef 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -888,6 +888,47 @@ impl BashAnnotations { } None } + + /// If the script declares `#ssh ` (a resource path after the + /// ssh annotation), returns that path. This reroutes execution to a remote + /// host over SSH (enterprise feature): the script runs on the host described + /// by the `ssh_target` resource at `` instead of on the worker. + /// The `#ssh $` form returns the `$`-prefixed token verbatim; the + /// executor resolves it from the job argument of that name at run time. + /// + /// Mirrors `sandbox_image`: only leading comment lines are scanned, stopping + /// at the first non-comment line. A bare `#ssh` with no path returns `None`. + /// Only an exact `#ssh ` line triggers the reroute — the target must + /// be a resource path (`u/...`/`f/...`) or a `$arg` reference (a valid + /// identifier), with nothing else on the line — so prose comments like + /// `# ssh into the box`, `# ssh tunnel/proxy setup is below` or + /// `# ssh $HOST manually first` never match. + pub fn ssh_target(code: &str) -> Option { + for line in code.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if !line.starts_with('#') { + break; + } + let mut tokens = line[1..].split_whitespace(); + if tokens.next() == Some("ssh") { + if let Some(path) = tokens.next() { + let is_path = path.starts_with("u/") || path.starts_with("f/"); + let is_arg = path.strip_prefix('$').is_some_and(|a| { + !a.is_empty() + && !a.starts_with(|c: char| c.is_ascii_digit()) + && a.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }); + if (is_path || is_arg) && tokens.next().is_none() { + return Some(path.to_string()); + } + } + } + } + None + } } #[derive(Debug, Clone, Copy, PartialEq)] @@ -2283,6 +2324,60 @@ mod tests { ); } + #[test] + fn test_bash_ssh_target_annotation() { + // `#ssh ` returns the resource path (reroutes to remote execution). + assert_eq!( + BashAnnotations::ssh_target("#ssh f/infra/jump_node\nset -e\ndf -h"), + Some("f/infra/jump_node".to_string()) + ); + // `# ssh ` with a space after `#` also works. + assert_eq!( + BashAnnotations::ssh_target("# ssh u/me/box\n"), + Some("u/me/box".to_string()) + ); + // `#ssh $arg` (dynamic target from a job argument) is returned verbatim. + assert_eq!( + BashAnnotations::ssh_target("#ssh $jump_host\necho hi"), + Some("$jump_host".to_string()) + ); + // A bare `#ssh` with no path -> None. + assert_eq!(BashAnnotations::ssh_target("#ssh\necho hi"), None); + // `ssh` must be its own token, not a prefix. + assert_eq!(BashAnnotations::ssh_target("# sshd restart"), None); + // Prose comments mentioning ssh must not trigger the reroute: the line + // must be exactly `#ssh ` with a `u/`/`f/` path or `$identifier`. + assert_eq!( + BashAnnotations::ssh_target("# ssh into the box and restart nginx\necho hi"), + None + ); + assert_eq!( + BashAnnotations::ssh_target("# ssh tunnel/proxy setup is below\necho hi"), + None + ); + assert_eq!( + BashAnnotations::ssh_target("# ssh $HOST manually first\necho hi"), + None + ); + // Trailing tokens after a real-looking target also disqualify the line. + assert_eq!( + BashAnnotations::ssh_target("#ssh f/infra/box then reboot\necho hi"), + None + ); + // ...but a real directive on a later comment line is still found. + assert_eq!( + BashAnnotations::ssh_target("# ssh into the box\n#ssh f/infra/box\necho hi"), + Some("f/infra/box".to_string()) + ); + // Stops at the first non-comment line (declared too late is ignored). + assert_eq!( + BashAnnotations::ssh_target("echo hi\n#ssh f/infra/box"), + None + ); + // No annotation -> None (normal local bash). + assert_eq!(BashAnnotations::ssh_target("echo hello"), None); + } + #[test] fn test_mixed_tags() { let input = vec![ diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index e20a896103..e719891a69 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -68,6 +68,7 @@ bitflags::bitflags! { const DISABLE_DIRECT_DEPLOYMENT = 1 << 0; const DISABLE_WORKSPACE_FORKING = 1 << 1; const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2; + const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3; } } @@ -78,6 +79,7 @@ pub enum ProtectionRuleKind { DisableDirectDeployment, DisableWorkspaceForking, RestrictDeployToDeployers, + RestrictAnonymousAppDeployment, } impl ProtectionRuleKind { @@ -92,6 +94,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictDeployToDeployers => { ProtectionRules::RESTRICT_DEPLOY_TO_DEPLOYERS } + ProtectionRuleKind::RestrictAnonymousAppDeployment => { + ProtectionRules::RESTRICT_ANONYMOUS_APP_DEPLOYMENT + } } } @@ -104,6 +109,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictDeployToDeployers => { "Only workspace admins and members of wm_deployers can deploy to this workspace" } + ProtectionRuleKind::RestrictAnonymousAppDeployment => { + "Making an app publicly accessible without login (anonymous execution mode) is restricted in this workspace" + } } } } diff --git a/backend/windmill-duckdb-ffi-internal/src/lib.rs b/backend/windmill-duckdb-ffi-internal/src/lib.rs index 594b0f6df4..f3ee59cab7 100644 --- a/backend/windmill-duckdb-ffi-internal/src/lib.rs +++ b/backend/windmill-duckdb-ffi-internal/src/lib.rs @@ -266,6 +266,12 @@ fn setup_duckdb_connection( .unwrap_or(("http", &base_internal_url)); let s3_endpoint_ssl = s3_endpoint_ssl == "https"; + // Escape values interpolated into single-quoted SQL literals, consistent with + // configure_duckdb_resource_limits above (a stray quote would break the statement). + let s3_access_key = sql_single_quote(s3_access_key); + let s3_secret_key = sql_single_quote(s3_secret_key); + let endpoint = sql_single_quote(&format!("{s3_endpoint}/api/w/{w_id}/s3_proxy")); + conn.execute_batch(&format!( "INSTALL httpfs; LOAD httpfs; INSTALL azure; LOAD azure; @@ -274,7 +280,7 @@ fn setup_duckdb_connection( PROVIDER config, KEY_ID '{s3_access_key}', SECRET '{s3_secret_key}', - ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy', + ENDPOINT '{endpoint}', URL_STYLE path, USE_SSL {s3_endpoint_ssl} ); @@ -282,7 +288,7 @@ fn setup_duckdb_connection( TYPE gcs, KEY_ID '{s3_access_key}', SECRET '{s3_secret_key}', - ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy', + ENDPOINT '{endpoint}', USE_SSL {s3_endpoint_ssl} ); ", diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index 9d50a0921e..cf30efe116 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -52,6 +52,8 @@ use tokio::task; #[cfg(feature = "parquet")] use windmill_common::error::to_anyhow; #[cfg(feature = "parquet")] +use windmill_common::jobs::is_safe_log_file_path; +#[cfg(feature = "parquet")] use windmill_common::utils::rd_string; #[cfg(all(feature = "parquet", feature = "private"))] pub mod job_s3_helpers_ee; @@ -1423,6 +1425,9 @@ pub async fn get_logs_from_store( ) -> Option>> { if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { + if file_index.iter().any(|p| !is_safe_log_file_path(p)) { + return None; + } if let Some(os) = get_object_store().await { let logs = logs.to_string(); let stream = async_stream::stream! { diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 0a707418ba..5cb8911c7e 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5953,7 +5953,13 @@ async fn push_inner<'c, 'd>( labels -- 44 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind, - ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27, $44) + ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27, + -- $44 (payload labels) merged with the labels of the runnable's folder, if any + -- ($45/$46 are runnable_path/workspace_id again, kept separate to avoid parameter type conflicts) + (SELECT CASE WHEN fl.labels IS NULL THEN $44 + ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl) + END + FROM folder_labels($46, $45) AS fl(labels))) ), inserted_runtime AS ( INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null) @@ -6010,6 +6016,8 @@ async fn push_inner<'c, 'd>( cache_ignore_s3_path, runnable_settings_handle, labels.as_deref() as Option<&[String]>, + runnable_path, + workspace_id, ) .execute(&mut *tx) .warn_after_seconds(1) diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 402628e3ad..44db0a1b68 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -43,6 +43,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{self, Error, JsonResult, Result}, get_database_url, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only, + fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, + WithDraftQuery, + }, utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath}, variables, worker::{CLOUD_HOSTED, WINDMILL_DIR}, @@ -147,8 +152,21 @@ pub struct ListableResource { pub account: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Labels inherited from the parent folder, computed at read time. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ws_specific: Option, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the resources page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Deserialize)] @@ -181,6 +199,9 @@ pub struct ListResourceQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(Serialize, FromRow)] @@ -248,6 +269,7 @@ async fn list_resources( Query(lq): Query, Query(pagination): Query, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let (per_page, offset) = paginate(pagination); @@ -269,8 +291,16 @@ async fn list_resources( "resource.created_by", "resource.edited_at", "resource.labels", + "folder_labels(resource.workspace_id, resource.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", ]) + // Scalar EXISTS flags the authed user's per-user draft without fanning rows out. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = resource.workspace_id \ + AND draft.path = resource.path AND draft.typ = 'resource' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("variable") .on("variable.path = resource.path AND variable.workspace_id = resource.workspace_id") @@ -336,14 +366,18 @@ async fn list_resources( if let Some(label) = &lq.label { for l in label.split(',') { - sqlb.and_where("resource.labels @> ARRAY[?]".bind(&l.trim())); + sqlb.and_where( + "(resource.labels @> ARRAY[?] OR folder_labels(resource.workspace_id, resource.path) @> ARRAY[?])" + .bind(&l.trim()) + .bind(&l.trim()), + ); } } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "resources", "read"); - let rows = sqlx::query_as::<_, ListableResource>(&sql) + let mut rows = sqlx::query_as::<_, ListableResource>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -352,6 +386,101 @@ async fn list_resources( tx.commit().await?; + // Append the authed user's draft-only resources; see scripts.rs. + // `resource_type` / `resource_type_exclude` are deliberately NOT in the bail-out + // list (the resources page always passes `resource_type_exclude`); they're applied + // per-row below against the draft JSON's `resource_type` instead. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let rt_filter: Option> = lq + .resource_type + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let rt_exclude: Option> = lq + .resource_type_exclude + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Resource) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let description = v + .get("description") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let value = v.get("args").cloned(); + let resource_type = v + .get("resource_type") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Mirror the deployed query's resource_type narrowing for the + // synthesized rows (see the gate comment above). + if let Some(ref rts) = rt_filter { + if !rts.contains(&resource_type.as_str()) { + continue; + } + } + if let Some(ref excl) = rt_exclude { + if excl.contains(&resource_type.as_str()) { + continue; + } + } + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableResource { + workspace_id: w_id.clone(), + path, + value, + description, + resource_type, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + created_by: None, + edited_at: Some(row.created_at), + is_linked: None, + is_refreshed: None, + is_oauth: None, + is_expired: None, + refresh_error: None, + account: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -360,22 +489,27 @@ async fn get_resource( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("resources:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let resource_o = sqlx::query_as!( ListableResource, + // `null::bool` columns align with the struct fields; deployed rows are never draft-only. "SELECT resource.workspace_id, resource.path, resource.value, resource.description, resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at, resource.labels, + folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\", (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed, account.refresh_error, variable.path IS NOT NULL as is_linked, variable.is_oauth as \"is_oauth?\", variable.account, - ws_specific.path IS NOT NULL as ws_specific + ws_specific.path IS NOT NULL as ws_specific, + null::bool as draft_only, + null::bool as is_draft FROM resource LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2 LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2 @@ -387,11 +521,30 @@ async fn get_resource( .fetch_optional(&mut *tx) .await?; tx.commit().await?; + if resource_o.is_none() && q.get_draft { + // No deployed row + `get_draft`: synthesize the response from the draft + // alone (`no_deployed = true`); see scripts.rs. + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await? + { + return Ok(Json(overlay)); + } + } if resource_o.is_none() { explain_resource_perm_error(&path, &w_id, &db, &authed).await?; } let resource = not_found_if_none(resource_o, "Resource", path)?; - Ok(Json(resource)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Resource, + path, + q.get_draft, + resource, + ) + .await?; + Ok(Json(overlay)) } async fn exists_resource( @@ -1158,6 +1311,13 @@ async fn delete_resource( .await?; tx.commit().await?; + // Resource gone for everyone: wipe ALL users' drafts at this path (and any linked + // variables cascaded into) so teammates' drafts don't orphan. Idempotent on no-draft. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + for var_path in &deleted_linked_variables { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + handle_deployment_metadata( &authed.email, &authed.username, @@ -1427,6 +1587,14 @@ async fn delete_resources_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked variables); see delete_resource. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + for var_path in &linked_var_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + try_join_all(deleted_paths.iter().map(|path| { handle_deployment_metadata( &authed.email, @@ -1654,6 +1822,28 @@ async fn update_resource( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the draft at the OLD path orphans (no SQL FK); clear the deployer's + // own (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). The linked + // variable renames alongside the resource, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 24739ce940..72ed20cb71 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -35,6 +35,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{Error, JsonResult, Result}, scripts::ScriptHash, + user_drafts::{ + decrypt_draft_secret_value, delete_all_drafts_for_path, delete_own_draft_for_path, + fetch_draft_only, fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, + WithDraftOverlay, ENCRYPTED_DRAFT_PREFIX, + }, utils::{not_found_if_none, paginate, Pagination, StripPath, WarnAfterExt}, variables::{ build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable, @@ -109,11 +114,15 @@ struct ListVariableQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } async fn list_variables( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lq): Query, Query(pagination): Query, @@ -138,10 +147,18 @@ async fn list_variables( "account.refresh_token != '' as is_refreshed", "variable.expires_at", "variable.labels", + "folder_labels(variable.workspace_id, variable.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", "variable.edited_at", "variable.edited_by", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = variable.workspace_id \ + AND draft.path = variable.path AND draft.typ = 'variable' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("account") .on("variable.account = account.id AND account.workspace_id = ?".bind(&w_id)) @@ -187,14 +204,18 @@ async fn list_variables( if let Some(label) = &lq.label { for l in label.split(',') { - sqlb.and_where("variable.labels @> ARRAY[?]".bind(&l.trim())); + sqlb.and_where( + "(variable.labels @> ARRAY[?] OR folder_labels(variable.workspace_id, variable.path) @> ARRAY[?])" + .bind(&l.trim()) + .bind(&l.trim()), + ); } } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "variables", "read"); - let rows = sqlx::query_as::<_, ListableVariable>(&sql) + let mut rows = sqlx::query_as::<_, ListableVariable>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -202,13 +223,102 @@ async fn list_variables( .collect::>(); tx.commit().await?; + + // Append the authed user's draft-only variables; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Variable) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // VariableEditor's `VariableState`: { path, variable: { value, is_secret, description }, labels?, wsSpecific } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let variable = v + .get("variable") + .cloned() + .unwrap_or(serde_json::Value::Null); + let is_secret = variable + .get("is_secret") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let description = variable + .get("description") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Secret variables never expose their value in the list response, even from a draft. + let value = if is_secret { + None + } else { + variable + .get("value") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + }; + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableVariable { + workspace_id: w_id.clone(), + path, + value, + is_secret, + description, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + account: None, + is_oauth: None, + is_expired: None, + is_refreshed: None, + refresh_error: None, + is_linked: None, + expires_at: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + edited_at: Some(row.created_at), + edited_by: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } +// `get_draft` inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. #[derive(Deserialize)] struct GetVariableQuery { decrypt_secret: Option, include_encrypted: Option, + #[serde(default)] + get_draft: bool, } async fn get_variable( @@ -217,7 +327,7 @@ async fn get_variable( Extension(db): Extension, Query(q): Query, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("variables:read:{}", path))?; @@ -227,6 +337,7 @@ async fn get_variable( "SELECT variable.workspace_id, variable.path, variable.value, variable.is_secret, variable.description, variable.extra_perms, variable.account, variable.is_oauth, variable.expires_at, variable.labels, + folder_labels(variable.workspace_id, variable.path) as inherited_labels, variable.edited_at, variable.edited_by, (now() > account.expires_at) as is_expired, account.refresh_error, resource.path IS NOT NULL as is_linked, @@ -246,6 +357,17 @@ async fn get_variable( let variable = if let Some(variable) = variable_o { variable + } else if q.get_draft { + // No deployed row + `get_draft`: fall back to the draft (see scripts.rs). + // Drop the user_db tx first since `fetch_draft_only` runs on `db`. + tx.commit().await?; + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Variable, path).await? + { + return Ok(Json(overlay)); + } + explain_variable_perm_error(&path, &w_id, &db).await?; + unreachable!() } else { explain_variable_perm_error(&path, &w_id, &db).await?; unreachable!() @@ -304,7 +426,17 @@ async fn get_variable( variable }; - Ok(Json(r)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Variable, + path, + q.get_draft, + r, + ) + .await?; + Ok(Json(overlay)) } #[derive(Deserialize)] @@ -443,8 +575,15 @@ async fn create_variable( check_path_conflict(&db, &w_id, &variable.path).await?; let value = if variable.is_secret && !already_encrypted.unwrap_or(false) { + // A restored draft sends the `$encrypted:` marker as-is; decrypt it back + // (validating against the workspace key) before the secret backend re-stores it. + let plain = if variable.value.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &variable.value).await? + } else { + variable.value.clone() + }; // Use secret backend for encryption (supports both DB and Vault) - store_secret_value(&db, &w_id, &variable.path, &variable.value).await? + store_secret_value(&db, &w_id, &variable.path, &plain).await? } else { variable.value }; @@ -641,6 +780,13 @@ async fn delete_variable( tx.commit().await?; + // Variable gone for everyone: wipe ALL users' drafts at this path (see resources.rs). + // Resource included because variables cascade-delete the linked resource at the same path. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + if deleted_linked_resource.is_some() { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // If variable was a secret, also delete from Vault backend (if configured) if is_secret { delete_secret_from_backend(&db, &w_id, path).await?; @@ -779,12 +925,12 @@ async fn delete_variables_bulk( ) .execute(&mut *tx) .await?; - sqlx::query!( - "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2", + let deleted_resource_paths = sqlx::query_scalar!( + "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path", &deleted_paths, w_id ) - .execute(&mut *tx) + .fetch_all(&mut *tx) .await?; sqlx::query!( @@ -808,6 +954,14 @@ async fn delete_variables_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked resources); see delete_variable. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + } + for path in &deleted_resource_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // Delete secrets from Vault backend (if configured) for path in &secret_paths { if deleted_paths.contains(path) { @@ -912,9 +1066,15 @@ async fn update_variable( }; let value = if is_secret && !already_encrypted.unwrap_or(false) { + // Decrypt a restored draft's `$encrypted:` marker before re-storing; see create_variable. + let plain = if nvalue.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &nvalue).await? + } else { + nvalue + }; // Use secret backend for encryption (supports both DB and Vault) // Store at target_path (new path if renaming, otherwise current path) - store_secret_value(&db, &w_id, target_path, &nvalue).await? + store_secret_value(&db, &w_id, target_path, &plain).await? } else { nvalue }; @@ -1165,6 +1325,27 @@ async fn update_variable( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the old-path draft orphans (see resources.rs); the linked resource + // renames alongside the variable, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index ae2de880a4..c9c019c6e8 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -773,7 +773,6 @@ pub async fn assert_lockfile( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -796,6 +795,7 @@ pub async fn assert_lockfile( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await @@ -871,7 +871,6 @@ pub async fn run_deployed_relative_imports( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -894,6 +893,7 @@ pub async fn run_deployed_relative_imports( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await diff --git a/backend/windmill-trigger-azure/src/handler_oss.rs b/backend/windmill-trigger-azure/src/handler_oss.rs index e56c76a2fd..30257203b6 100644 --- a/backend/windmill-trigger-azure/src/handler_oss.rs +++ b/backend/windmill-trigger-azure/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for AzureTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerAzure; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/azure_triggers"; diff --git a/backend/windmill-trigger-email/src/handler_oss.rs b/backend/windmill-trigger-email/src/handler_oss.rs index b6cac94cc2..fccbde4b96 100644 --- a/backend/windmill-trigger-email/src/handler_oss.rs +++ b/backend/windmill-trigger-email/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for EmailTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerEmail; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/email_triggers"; diff --git a/backend/windmill-trigger-gcp/src/handler_oss.rs b/backend/windmill-trigger-gcp/src/handler_oss.rs index 1cacf7f594..c7e444f5ac 100644 --- a/backend/windmill-trigger-gcp/src/handler_oss.rs +++ b/backend/windmill-trigger-gcp/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for GcpTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerGcp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/gcp_triggers"; diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 74fd748f55..7b617e1b8d 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -373,6 +373,7 @@ impl TriggerCrud for HttpTrigger { const TABLE_NAME: &'static str = "http_trigger"; const TRIGGER_TYPE: &'static str = "http"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerHttp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/http_triggers"; diff --git a/backend/windmill-trigger-kafka/src/handler_oss.rs b/backend/windmill-trigger-kafka/src/handler_oss.rs index 57e786b0ea..0445664ddb 100644 --- a/backend/windmill-trigger-kafka/src/handler_oss.rs +++ b/backend/windmill-trigger-kafka/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for KafkaTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerKafka; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/kafka_triggers"; diff --git a/backend/windmill-trigger-mqtt/src/handler.rs b/backend/windmill-trigger-mqtt/src/handler.rs index 49fb701241..f1a6b6ff9d 100644 --- a/backend/windmill-trigger-mqtt/src/handler.rs +++ b/backend/windmill-trigger-mqtt/src/handler.rs @@ -25,6 +25,7 @@ impl TriggerCrud for MqttTrigger { const TABLE_NAME: &'static str = "mqtt_trigger"; const TRIGGER_TYPE: &'static str = "mqtt"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerMqtt; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/mqtt_triggers"; diff --git a/backend/windmill-trigger-nats/src/handler_oss.rs b/backend/windmill-trigger-nats/src/handler_oss.rs index 226bd653fd..b377774ee3 100644 --- a/backend/windmill-trigger-nats/src/handler_oss.rs +++ b/backend/windmill-trigger-nats/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for NatsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerNats; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/nats_triggers"; diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index dc2f4776fd..10a26e0066 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -46,6 +46,7 @@ impl TriggerCrud for PostgresTrigger { const TABLE_NAME: &'static str = "postgres_trigger"; const TRIGGER_TYPE: &'static str = "postgres"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerPostgres; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/postgres_triggers"; diff --git a/backend/windmill-trigger-sqs/src/handler_oss.rs b/backend/windmill-trigger-sqs/src/handler_oss.rs index fc72159e24..0f54a548fe 100644 --- a/backend/windmill-trigger-sqs/src/handler_oss.rs +++ b/backend/windmill-trigger-sqs/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for SqsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerSqs; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/sqs_triggers"; diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index df8bccaaa6..dd6b9f4363 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -28,6 +28,7 @@ impl TriggerCrud for WebsocketTrigger { const TABLE_NAME: &'static str = "websocket_trigger"; const TRIGGER_TYPE: &'static str = "websocket"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerWebsocket; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/websocket_triggers"; diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 3591c88931..4f3d381569 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -16,6 +16,10 @@ use windmill_api_auth::{check_scopes, ApiAuthed}; use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only_list_rows, + overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, @@ -60,7 +64,10 @@ pub trait TriggerCrud: Send + Sync + 'static { + for<'r> FromRow<'r, sqlx::postgres::PgRow> + Send + Sync - + Unpin; + + Unpin + // `'static` so the deployed trigger can be boxed into + // `WithDraftOverlay`'s erased-serde inner (it's an owned row). + + 'static; type TriggerConfig: Debug + DeserializeOwned @@ -77,6 +84,9 @@ pub trait TriggerCrud: Send + Sync + 'static { /// constant set by each trigger impl — it is never user-controllable. const TABLE_NAME: &'static str; const TRIGGER_TYPE: &'static str; + /// `UserDraftItemKind` for this trigger's per-user `draft` rows. Required (no + /// default) so a trigger that forgets it is a compile error, not a runtime panic. + const DRAFT_KIND: UserDraftItemKind; const SUPPORTS_SERVER_STATE: bool; const SUPPORTS_TEST_CONNECTION: bool; const ROUTE_PREFIX: &'static str; @@ -127,6 +137,11 @@ pub trait TriggerCrud: Send + Sync + 'static { &Self::ROUTE_PREFIX[1..] } + /// Accessor for `DRAFT_KIND` used at the draft-lookup call sites. + fn user_draft_item_kind() -> UserDraftItemKind { + Self::DRAFT_KIND + } + async fn create_trigger( &self, db: &DB, @@ -349,11 +364,14 @@ pub trait TriggerCrud: Send + Sync + 'static { count } + /// `authed_email = Some` adds the per-user `is_draft` flag (scalar EXISTS); + /// `None` (e.g. workspace export) leaves it omitted. async fn list_triggers( &self, tx: &mut PgConnection, workspace_id: &str, query: Option<&StandardTriggerQuery>, + authed_email: Option<&str>, ) -> Result> { let mut fields = vec![ "workspace_id", @@ -381,6 +399,19 @@ pub trait TriggerCrud: Send + Sync + 'static { .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&workspace_id)); + if let Some(email) = authed_email { + // SAFETY: interpolated TABLE_NAME and draft kind are compile-time constants; email is bound. + sqlb.field( + &format!( + "EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = {t}.workspace_id \ + AND draft.path = {t}.path AND draft.typ = '{k}' AND draft.email = ?) as is_draft", + t = Self::TABLE_NAME, + k = Self::user_draft_item_kind().as_str(), + ) + .bind(&email), + ); + } + if let Some(query) = query { let (per_page, offset) = paginate(Pagination { per_page: query.per_page, page: query.page }); @@ -563,15 +594,74 @@ async fn list_triggers( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(workspace_id): Path, Query(query): Query, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; - let triggers = handler - .list_triggers(&mut *tx, &workspace_id, Some(&query)) + let mut triggers = handler + .list_triggers(&mut *tx, &workspace_id, Some(&query), Some(&authed.email)) .await?; tx.commit().await?; + // Append the authed user's draft-only triggers of this kind; see scripts.rs. + // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind + // config can deviate, so drop a row on deserialize failure rather than fail the list. + if query.include_draft_only.unwrap_or(false) + && !authed.is_operator + && query.page.unwrap_or(0) == 0 + && query.path.is_none() + && query.is_flow.is_none() + && query.path_start.is_none() + && query.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + ) + .await?; + + for row in draft_only_rows { + let created_at = row.created_at; + let v: serde_json::Value = match serde_json::from_str(row.value.0.get()) { + Ok(v) => v, + Err(_) => continue, + }; + let serde_json::Value::Object(mut map) = v else { + continue; + }; + // Fill operational fields the editor draft omits so the merged JSON matches + // `Trigger`'s flattened shape (mode derived from `enabled`). + map.insert( + "workspace_id".into(), + serde_json::Value::String(workspace_id.clone()), + ); + map.insert("edited_by".into(), serde_json::Value::String(String::new())); + if let Ok(at) = serde_json::to_value(&created_at) { + map.insert("edited_at".into(), at); + } + map.entry("permissioned_as") + .or_insert(serde_json::Value::String(String::new())); + map.entry("extra_perms").or_insert(serde_json::Value::Null); + if !map.contains_key("mode") { + let enabled = map.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + map.insert( + "mode".into(), + serde_json::Value::String(if enabled { "enabled" } else { "disabled" }.into()), + ); + } + map.insert("draft_only".into(), serde_json::Value::Bool(true)); + // Synthesized rows are the authed user's draft. + map.insert("is_draft".into(), serde_json::Value::Bool(true)); + match serde_json::from_value::(serde_json::Value::Object(map)) { + Ok(t) => triggers.push(t), + Err(_) => continue, + } + } + } + Ok(Json(triggers)) } @@ -579,21 +669,41 @@ async fn get_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || { format!("{}:read:{}", T::scope_domain_name(), &path) })?; let mut tx = user_db.begin(&authed).await?; - let trigger = handler + let trigger_res = handler .get_trigger_by_path(&mut *tx, &workspace_id, path) - .await?; - + .await; tx.commit().await?; - Ok(Json(trigger)) + // Map "no deployed trigger" to `None` and let the shared choreography + // handle the draft overlay / draft-only fallback / 404. + let deployed = match trigger_res { + Ok(t) => Some(t), + Err(Error::NotFound(_)) => None, + Err(e) => return Err(e), + }; + + let overlay = overlay_or_draft_only( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + path, + q.get_draft, + deployed, + || Error::NotFound(format!("Trigger not found at path: {}", path)), + ) + .await?; + Ok(Json(overlay)) } async fn update_trigger( @@ -717,6 +827,19 @@ async fn update_trigger( tx.commit().await?; + // On rename the old-path draft orphans (no SQL FK); clear the deployer's own + // (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). See scripts.rs. + if path != new_path { + delete_own_draft_for_path( + &db, + &workspace_id, + T::user_draft_item_kind(), + path, + &authed.email, + ) + .await?; + } + Ok(format!("Trigger '{}' updated", path)) } @@ -724,6 +847,7 @@ async fn delete_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, ) -> Result { let path = path.to_path(); @@ -781,6 +905,9 @@ async fn delete_trigger( tx.commit().await?; + // Trigger gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &workspace_id, T::user_draft_item_kind(), path).await?; + Ok(format!("Trigger '{}' deleted", path)) } diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index 8b42c5b1f9..77d90bea20 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -27,6 +27,10 @@ pub struct StandardTriggerQuery { pub is_flow: Option, pub path_start: Option, pub label: Option, + /// When true, append per-user draft rows whose path has no + /// deployed trigger of this kind. Same gate as scripts/flows/apps: + /// non-operators, offset 0, no narrowing filters. + pub include_draft_only: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -42,6 +46,22 @@ pub struct BaseTrigger { pub extra_perms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when this row is a per-user draft with no deployed trigger + /// at the same path. Set by `list_triggers` when the response + /// includes synthesized draft-only rows (gated on + /// `include_draft_only`). Always `None`/omitted on deployed rows + /// fetched from the trigger table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// either layered over a deployed trigger (EXISTS subquery in the + /// list SQL) or a synthesized draft-only row. Drives the `*` suffix + /// on the trigger list pages. `None`/omitted for callers that list + /// without an authed context (e.g. workspace export). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -192,6 +212,7 @@ impl Default for StandardTriggerQuery { path_start: None, is_flow: None, label: None, + include_draft_only: None, } } } @@ -260,6 +281,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 100); assert_eq!(q.limit(), 50); @@ -274,6 +296,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 0); assert_eq!(q.limit(), 100); diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index c7d291d6a1..2d1a538124 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -30,8 +30,6 @@ pub struct Flow { pub schema: Option, pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, @@ -45,6 +43,10 @@ pub struct Flow { pub on_behalf_of_email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Labels inherited from the parent folder, computed at read time. Not stored on the flow row. + #[sqlx(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, } #[derive(Serialize, sqlx::FromRow)] @@ -79,7 +81,9 @@ pub struct ListableFlow { pub archived: bool, pub extra_perms: serde_json::Value, pub starred: bool, - pub has_draft: bool, + /// `Some(true)` only on synthesised draft-only rows; `None` on deployed rows. + /// See ListableScript in scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -89,6 +93,24 @@ pub struct ListableFlow { pub deployment_msg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this flow (draft-only or layered + /// over the deployed row). See ListableScript in scripts.rs. + #[serde(default)] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, + /// Labels inherited from the parent folder, computed at read time. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] @@ -99,7 +121,6 @@ pub struct NewFlow { #[serde(deserialize_with = "validate_flow_value")] pub value: Box, pub schema: Option, - pub draft_only: Option, pub tag: Option, pub dedicated_worker: Option, pub timeout: Option, @@ -314,6 +335,7 @@ impl Step { pub struct StopAfterIf { pub expr: String, pub skip_if_stopped: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] pub error_message: Option, /// When stopping with an error (`error_message` set), embed the stopping /// step's own result inside the raised error object (as `error.result`) @@ -330,7 +352,9 @@ pub struct RetryIf { #[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] #[serde(default)] pub struct Retry { + #[serde(skip_serializing_if = "is_default")] pub constant: ConstantDelay, + #[serde(skip_serializing_if = "is_default")] pub exponential: ExponentialDelay, #[serde(skip_serializing_if = "Option::is_none")] pub retry_if: Option, @@ -942,6 +966,8 @@ pub enum FlowModuleValue { AIAgent { input_transforms: HashMap, tools: Vec, + #[serde(skip_serializing_if = "is_none_or_empty")] + tag: Option, #[serde(default, skip_serializing_if = "is_false")] omit_output_from_conversation: bool, }, @@ -1080,6 +1106,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue { tools: untagged .tools .ok_or_else(|| serde::de::Error::missing_field("tools"))?, + tag: untagged.tag, omit_output_from_conversation: untagged .omit_output_from_conversation .unwrap_or(false), @@ -1233,4 +1260,84 @@ mod tests { assert!(omit_output_from_conversation); } + + #[test] + fn ai_agent_tag_round_trips() { + let input = json!({ + "type": "aiagent", + "tools": [], + "input_transforms": {}, + "tag": "bedrock" + }); + + let val: FlowModuleValue = serde_json::from_value(input).unwrap(); + let FlowModuleValue::AIAgent { ref tag, .. } = val else { + panic!("expected aiagent module"); + }; + assert_eq!(tag.as_deref(), Some("bedrock")); + + let output = serde_json::to_string(&val).unwrap(); + assert!(output.contains("\"tag\":\"bedrock\"")); + } + + #[test] + fn ai_agent_tag_defaults_to_none_and_is_omitted_when_serializing() { + let input = json!({ + "type": "aiagent", + "tools": [], + "input_transforms": {} + }); + + let val: FlowModuleValue = serde_json::from_value(input).unwrap(); + let FlowModuleValue::AIAgent { ref tag, .. } = val else { + panic!("expected aiagent module"); + }; + assert!(tag.is_none()); + + let output = serde_json::to_string(&val).unwrap(); + assert!(!output.contains("tag")); + } + + #[test] + fn retry_omits_default_constant_and_exponential() { + // A constant-only retry must not materialize a default exponential block + // on serialization (and vice-versa). Round-trips through Retry used to + // emit seconds:0 / random_factor:null, which the CLI linter rejected. + let input = json!({ "constant": { "attempts": 1, "seconds": 60 } }); + let retry: Retry = serde_json::from_value(input).unwrap(); + + // Deserialization still fills in defaults in memory. + assert_eq!(retry.exponential, ExponentialDelay::default()); + + let output = serde_json::to_value(&retry).unwrap(); + assert!(output.get("constant").is_some()); + assert!(output.get("exponential").is_none()); + + // A fully-default retry serializes to an empty object. + let empty = serde_json::to_value(&Retry::default()).unwrap(); + assert_eq!(empty, json!({})); + } + + #[test] + fn stop_after_if_omits_null_error_message() { + let input = json!({ "expr": "result == 404", "skip_if_stopped": true }); + let stop: StopAfterIf = serde_json::from_value(input).unwrap(); + assert!(stop.error_message.is_none()); + + let output = serde_json::to_value(&stop).unwrap(); + assert!(output.get("error_message").is_none()); + + // A set error message still round-trips. + let with_msg = StopAfterIf { + expr: "true".to_string(), + skip_if_stopped: false, + error_message: Some("boom".to_string()), + error_include_result: false, + }; + let output = serde_json::to_value(&with_msg).unwrap(); + assert_eq!( + output.get("error_message").and_then(|v| v.as_str()), + Some("boom") + ); + } } diff --git a/backend/windmill-types/src/lib.rs b/backend/windmill-types/src/lib.rs index 4d9b96c2af..9144d61f89 100644 --- a/backend/windmill-types/src/lib.rs +++ b/backend/windmill-types/src/lib.rs @@ -19,6 +19,8 @@ pub mod schedule; pub mod scripts; #[cfg(not(target_arch = "wasm32"))] pub mod triggers; +#[cfg(not(target_arch = "wasm32"))] +pub mod user_drafts; /// Duplicated from windmill-common::worker::to_raw_value. /// windmill-types cannot depend on windmill-common (it would be circular). diff --git a/backend/windmill-types/src/s3.rs b/backend/windmill-types/src/s3.rs index 0ab1794d7e..5a7634043c 100644 --- a/backend/windmill-types/src/s3.rs +++ b/backend/windmill-types/src/s3.rs @@ -364,13 +364,11 @@ mod tests { assert_eq!(deserialized, S3Permission::READ | S3Permission::WRITE); // Unknown permissions are silently ignored - let deserialized: S3Permission = - serde_json::from_str("\"read,unknown,delete\"").unwrap(); + let deserialized: S3Permission = serde_json::from_str("\"read,unknown,delete\"").unwrap(); assert_eq!(deserialized, S3Permission::READ | S3Permission::DELETE); // All four permissions - let all: S3Permission = - serde_json::from_str("\"read,write,delete,list\"").unwrap(); + let all: S3Permission = serde_json::from_str("\"read,write,delete,list\"").unwrap(); assert_eq!( all, S3Permission::READ | S3Permission::WRITE | S3Permission::DELETE | S3Permission::LIST @@ -409,20 +407,15 @@ mod tests { ); // Region set, endpoint empty → use region - let with_region = S3Resource { - region: "ap-southeast-1".to_string(), - ..resource.clone() - }; + let with_region = S3Resource { region: "ap-southeast-1".to_string(), ..resource.clone() }; assert_eq!( with_region.endpoint_with_region_fallback(Some("ignored".to_string())), "s3.ap-southeast-1.amazonaws.com" ); // Endpoint set → return as-is - let with_endpoint = S3Resource { - endpoint: "custom.s3.endpoint.com".to_string(), - ..resource.clone() - }; + let with_endpoint = + S3Resource { endpoint: "custom.s3.endpoint.com".to_string(), ..resource.clone() }; assert_eq!( with_endpoint.endpoint_with_region_fallback(Some("ignored".to_string())), "custom.s3.endpoint.com" @@ -431,10 +424,8 @@ mod tests { #[test] fn test_lfs_methods_filesystem() { - let rules = vec![S3PermissionRule { - pattern: "**/*.csv".to_string(), - allow: S3Permission::READ, - }]; + let rules = + vec![S3PermissionRule { pattern: "**/*.csv".to_string(), allow: S3Permission::READ }]; let lfs = LargeFileStorage::FilesystemStorage(FilesystemStorage { root_path: "/data/workspace".to_string(), public_resource: Some(true), diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index c26947d8d6..5e5c7f313e 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -323,7 +323,7 @@ pub fn id_to_codebase_info(id: &str) -> CodebaseInfo { pub const SCRIPT_COLUMNS: &str = concat!( "workspace_id, hash, path, parent_hashes, summary, description, content, ", "created_by, created_at, archived, schema, deleted, is_template, extra_perms, ", - "lock, lock_error_logs, language, kind, tag, draft_only, envs, ", + "lock, lock_error_logs, language, kind, tag, envs, ", "dedicated_worker, ws_error_handler_muted, priority, cache_ttl, cache_ignore_s3_path, ", "timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ", "visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, ", @@ -355,8 +355,6 @@ pub struct Script { pub kind: ScriptKind, pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub envs: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, @@ -394,6 +392,10 @@ pub struct Script { pub modules: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Labels inherited from the parent folder, computed at read time. Not stored on the script row. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, #[serde(flatten)] #[sqlx(flatten)] pub runnable_settings: SR, @@ -445,8 +447,10 @@ pub struct ListableScript { pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_draft: Option, + /// `Some(true)` only on rows synthesised from the `draft` table (never-deployed + /// items the user owns a draft for); `None` on deployed rows. Kept on the public + /// response so consumers checking `draft_only === true` keep working. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, pub has_deploy_errors: bool, @@ -461,6 +465,26 @@ pub struct ListableScript { pub kind: ScriptKind, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `true` when this entry is the authed user's draft — draft-only, or a deployed + /// row the user has saved a draft on top of. Distinguishes user state from team state. + #[serde(skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path, so the home list shows a meaningful name over the + /// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use + /// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an + /// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners (`{ username }`, `None` for the legacy NULL-email row), + /// driving the home-page avatar circles. `None` when no drafts; never an empty array. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, + /// Labels inherited from the parent folder, computed at read time. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub inherited_labels: Option>, } fn is_false(x: &bool) -> bool { @@ -508,7 +532,6 @@ pub struct NewScript { pub language: ScriptLang, pub kind: Option, pub tag: Option, - pub draft_only: Option, pub envs: Option>, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, @@ -566,7 +589,6 @@ impl Hash for NewScript { self.language.hash(state); self.kind.hash(state); self.tag.hash(state); - self.draft_only.hash(state); self.envs.hash(state); self.concurrency_settings.hash(state); self.debouncing_settings.hash(state); diff --git a/backend/windmill-types/src/user_drafts.rs b/backend/windmill-types/src/user_drafts.rs new file mode 100644 index 0000000000..30740fc561 --- /dev/null +++ b/backend/windmill-types/src/user_drafts.rs @@ -0,0 +1,18 @@ +//! Shared types for the per-user draft surface. +//! +//! Mirrors the `OtherDraftUser` type in `windmill-common::user_drafts` — +//! kept here so `windmill-types` row structs (ListableScript / ListableFlow / +//! ListableApp) can expose a typed `draft_users` field without taking a +//! dependency on `windmill-common`. The two structs serialize identically, +//! so the frontend doesn't notice. + +use serde::{Deserialize, Serialize}; + +/// One workspace user (or the legacy NULL-email row) with a per-user draft +/// at a given path. Used by the home-page list endpoints to feed the +/// avatar-circles inside the Draft badge. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DraftUserRef { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 2e75ac3632..14c3698162 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -146,7 +146,7 @@ hyper-util = { workspace = true, optional = true } rcgen = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] -windows = { version = "0.61", features = ["Win32_System_JobObjects", "Win32_System_Threading"] } +windows = { version = "0.61", features = ["Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] } [dev-dependencies] tempfile.workspace = true diff --git a/backend/windmill-worker/nsjail/run.ansible.config.proto b/backend/windmill-worker/nsjail/run.ansible.config.proto index 11660a5372..2c731e34f9 100644 --- a/backend/windmill-worker/nsjail/run.ansible.config.proto +++ b/backend/windmill-worker/nsjail/run.ansible.config.proto @@ -44,6 +44,7 @@ mount { src: "/root/.local/share/uv/tools/ansible" dst: "/root/.local/share/uv/tools/ansible" is_bind: true + mandatory: false } mount { @@ -97,6 +98,7 @@ mount { src: "{JOB_DIR}/ansible_collections/" dst: "/tmp/ansible_collections/" is_bind: true + mandatory: false } mount { diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 11100d4888..612b534578 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -426,6 +426,8 @@ async fn execute_windmill_tool( )); } let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id); + // tool jobs are pushed with the parent agent job's tag and executed inline on the + // same worker, so a tag override on a nested agent tool does not apply here JobPayloadWithTag { payload: JobPayload::AIAgent { path }, tag: None, diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 353bab17a0..6e80551650 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -557,21 +557,20 @@ pub async fn load_mcp_tools( tracing::debug!("Loading MCP tools from resource: {}", config.resource_path); let path = config.resource_path.trim_start_matches("$res:"); - let mcp_resource = { - // Fetch the resource from database - let resource= sqlx::query_scalar!( - "SELECT value as \"value: sqlx::types::Json>\" FROM resource WHERE path = $1 AND workspace_id = $2", - &path, - &workspace_id - ) - .fetch_optional(db) - .await? - .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", config.resource_path)))? - .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", config.resource_path)))?; - - serde_json::from_str::(resource.0.get()) - .context("Failed to parse MCP resource")? - }; + // Load the resource through the job's permissioned (RLS + scope) path so + // a flow author cannot make the agent use an MCP resource their identity + // is not allowed to read (resources:read:{path}). Reading through the raw + // db pool here would bypass the authorization enforced by the regular MCP + // tools API (get_mcp_tools) and act as a confused deputy. + let mcp_resource = client + .get_resource_value::(path) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to load MCP resource {}: {}", + config.resource_path, e + )) + })?; let resource_name = mcp_resource.name.clone(); diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 4c470b8dfc..3101541d36 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -98,6 +98,26 @@ pub async fn handle_bash_job( .await; } + // `#ssh ` reroutes execution to a remote host over SSH + // (enterprise feature). The script runs on the host described by the + // `ssh_target` resource instead of on this worker. The OSS build returns a + // clear "enterprise feature" error from the stub. + if let Some(ssh_path) = windmill_common::worker::BashAnnotations::ssh_target(content) { + return crate::ssh_executor_oss::handle_ssh_bash_job( + &ssh_path, + mem_peak, + canceled_by, + job, + conn, + client, + content, + job_dir, + worker_name, + occupancy_metrics, + ) + .await; + } + // Check if sandbox annotation is used but nsjail is not available if annotation.sandbox && NSJAIL_AVAILABLE.is_none() { return Err(Error::ExecutionErr( diff --git a/backend/windmill-worker/src/bigquery_executor.rs b/backend/windmill-worker/src/bigquery_executor.rs index bfe0874f83..8a80de25ef 100644 --- a/backend/windmill-worker/src/bigquery_executor.rs +++ b/backend/windmill-worker/src/bigquery_executor.rs @@ -378,6 +378,19 @@ pub async fn do_bigquery( .await .map_err(|e| Error::ExecutionErr(e.to_string()))?; + // Validate before it is interpolated into request URLs as a path segment + // (https://bigquery.googleapis.com/.../projects//...). + if project_id.is_empty() + || !project_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')) + { + return Err(Error::ExecutionErr(format!( + "Invalid BigQuery project id '{}': only alphanumeric, '.', '-', '_' and ':' allowed", + project_id.chars().take(64).collect::() + ))); + } + let mut sig = parse_bigquery_sig(&query) .map_err(|x| Error::ExecutionErr(x.to_string()))? .args; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 1bef9e4c6d..a88ba67bb7 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -748,17 +748,20 @@ lazy_static! { const MEMORY_LIMIT_1CU: usize = 2 * 1024 * 1024 * 1024; /// Wrapper that holds a Windows Job Object handle alongside the child process. -/// The job object enforces memory limits and is closed when the child is dropped. +/// The job object carries KILL_ON_JOB_CLOSE and/or a memory limit. With +/// KILL_ON_JOB_CLOSE, the worker process dying closes the handle and the OS reaps the +/// whole child tree — preventing orphans when the worker is force-killed (e.g. a second +/// CTRL_BREAK_EVENT or Nomad's kill_timeout) before a job has drained. #[cfg(windows)] -struct MemoryLimitedChild { +struct WindowsJobChild { inner: Box, _job_handle: Win32JobHandle, } #[cfg(windows)] -impl std::fmt::Debug for MemoryLimitedChild { +impl std::fmt::Debug for WindowsJobChild { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MemoryLimitedChild").finish() + f.debug_struct("WindowsJobChild").finish() } } @@ -781,7 +784,7 @@ impl Drop for Win32JobHandle { } #[cfg(windows)] -impl process_wrap::tokio::TokioChildWrapper for MemoryLimitedChild { +impl process_wrap::tokio::TokioChildWrapper for WindowsJobChild { fn inner(&self) -> &tokio::process::Child { self.inner.inner() } @@ -789,7 +792,11 @@ impl process_wrap::tokio::TokioChildWrapper for MemoryLimitedChild { self.inner.inner_mut() } fn into_inner(self: Box) -> tokio::process::Child { - self.inner.into_inner() + // Leak the job handle: with KILL_ON_JOB_CLOSE, closing the last handle reaps + // the (still-running) child, so extracting the inner Child must not close it. + let WindowsJobChild { inner, _job_handle } = *self; + std::mem::forget(_job_handle); + inner.into_inner() } fn start_kill(&mut self) -> std::io::Result<()> { self.inner.start_kill() @@ -805,9 +812,59 @@ impl process_wrap::tokio::TokioChildWrapper for MemoryLimitedChild { } } -/// Create a Windows Job Object with a memory limit and assign the process to it. +/// Resume all threads of a process created with CREATE_SUSPENDED. We create the child +/// suspended so it can be assigned to its job object before running any code; otherwise +/// a child that forks a helper at startup could create it before the assignment and +/// leave it outside the job (escaping KILL_ON_JOB_CLOSE reaping / the memory cap). +/// (Ported from process-wrap's resume_threads.) #[cfg(windows)] -fn apply_job_memory_limit(pid: u32, memory_limit: usize) -> Result { +fn resume_process(pid: u32) -> Result<(), std::io::Error> { + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("CreateToolhelp32Snapshot: {e}"), + ) + })?; + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + cntUsage: 0, + th32ThreadID: 0, + th32OwnerProcessID: 0, + tpBasePri: 0, + tpDeltaPri: 0, + dwFlags: 0, + }; + let mut res = Thread32First(snapshot, &mut entry); + while res.is_ok() { + if entry.th32OwnerProcessID == pid { + if let Ok(thread) = OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) { + ResumeThread(thread); + let _ = windows::Win32::Foundation::CloseHandle(thread); + } + } + res = Thread32Next(snapshot, &mut entry); + } + let _ = windows::Win32::Foundation::CloseHandle(snapshot); + } + Ok(()) +} + +/// Create a Windows Job Object and assign the process to it, optionally with +/// KILL_ON_JOB_CLOSE (so the child tree is reaped when the worker drops the handle / +/// dies) and/or a memory limit. Assigning post-spawn (rather than via process-wrap's +/// suspend/resume JobObject wrap) keeps the dotnet-safe behavior that csharp relies on. +#[cfg(windows)] +fn assign_job_object( + pid: u32, + memory_limit: Option, + kill_on_close: bool, +) -> Result { use windows::Win32::System::JobObjects::*; use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE}; @@ -817,8 +874,13 @@ fn apply_job_memory_limit(pid: u32, memory_limit: usize) -> Result()), but + // get_wrap returns None mid-spawn (process-wrap 8.2.1 takes the wrappers out of + // `self` before running pre_spawn), so it silently overwrites and drops + // CREATE_NEW_PROCESS_GROUP. Job-object grouping is done post-spawn below instead, + // which also matches the dotnet-safe assignment csharp already relies on. + #[cfg(windows)] + if !*DISABLE_PROCESS_GROUP { + use process_wrap::tokio::CreationFlags; + use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED}; + // Also create the child suspended so it can be placed in its job object (below) + // before it runs any code; otherwise a child that forks a helper at startup could + // create it before the assignment and leave it outside the job. Resumed right + // after the job assignment. + cmd.wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED)); + } + if !*DISABLE_PROCESS_GROUP && !disable_process_group { #[cfg(unix)] { @@ -916,31 +1000,51 @@ pub async fn start_child_process( cmd.wrap(ProcessGroup::leader()); } - #[cfg(windows)] - { - cmd.wrap(JobObject); - } } let child: Box = cmd .spawn() .map_err(|err| tentatively_improve_error(err.into(), executable))?; + // On Windows, assign the child to a job object. KILL_ON_JOB_CLOSE makes the OS reap + // the whole child tree when the worker drops the handle or dies, so jobs aren't + // orphaned if the worker is force-killed (second CTRL_BREAK_EVENT, or Nomad exceeding + // kill_timeout) before they drain; it is gated on the DISABLE_PROCESS_GROUP escape + // hatch alongside CREATE_NEW_PROCESS_GROUP above. The LIMIT_WINDOWS_TO_1CU memory cap + // is independent of that hatch (it's its own opt-in), so it's applied whenever set. + // Assigning post-spawn does not touch the creation flags (so it composes with + // CREATE_NEW_PROCESS_GROUP) and is the dotnet-safe path csharp already uses. #[cfg(windows)] - if *windmill_common::worker::LIMIT_WINDOWS_TO_1CU { - if let Some(pid) = child.inner().id() { - match apply_job_memory_limit(pid, MEMORY_LIMIT_1CU) { - Ok(job_handle) => { - tracing::info!( - "Applied 2GB memory limit (LIMIT_WINDOWS_TO_1CU) to child process {pid}" - ); - return Ok(Box::new(MemoryLimitedChild { - inner: child, - _job_handle: job_handle, - })); + { + let kill_on_close = !*DISABLE_PROCESS_GROUP; + let memory_limit = + (*windmill_common::worker::LIMIT_WINDOWS_TO_1CU).then_some(MEMORY_LIMIT_1CU); + if kill_on_close || memory_limit.is_some() { + if let Some(pid) = child.inner().id() { + let assigned = assign_job_object(pid, memory_limit, kill_on_close); + // When kill_on_close, the child was created suspended (CREATE_SUSPENDED + // above) so it could be placed in the job before running. Resume it now — + // unconditionally of assign success, so a failed assign never leaves it hung. + if kill_on_close { + if let Err(e) = resume_process(pid) { + tracing::error!("Failed to resume child process {pid}: {e}"); + } } - Err(e) => { - tracing::warn!("Failed to apply memory limit to child process {pid}: {e}"); + match assigned { + Ok(job_handle) => { + if memory_limit.is_some() { + tracing::info!( + "Applied 2GB memory limit (LIMIT_WINDOWS_TO_1CU) to child process {pid}" + ); + } + return Ok(Box::new(WindowsJobChild { + inner: child, + _job_handle: job_handle, + })); + } + Err(e) => { + tracing::warn!("Failed to assign child process {pid} to job object: {e}"); + } } } } diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index 4a019be9ab..1a474ee9a0 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -638,9 +638,13 @@ async fn db_resource_to_attach_statements( db_type: &str, extra_args: Option<&str>, ) -> Result> { + // Escape single quotes: the connection string is built from resource fields + // (host/db/user/password) and embedded in a single-quoted DuckDB literal, so an + // unescaped quote in any field would otherwise break out of the ATTACH statement. + let conn_str = format_attach_db_conn_str(db_resource, db_type)?.replace('\'', "''"); let attach_str = format!( "ATTACH '{}' as {} (TYPE {}{});", - format_attach_db_conn_str(db_resource, db_type)?, + conn_str, ident_name, db_type, extra_args.unwrap_or("") @@ -690,13 +694,18 @@ async fn transform_attach_ducklake( hidden_passwords.lock().unwrap().push(pwd.to_string()); } - let db_conn_str = format_attach_db_conn_str(ducklake.catalog_resource, db_type)?; + // Escape single quotes: db_conn_str, storage and data_path are embedded in + // single-quoted DuckDB literals below, so an unescaped quote in a resource + // field would break out of the ATTACH statement. + let db_conn_str = + format_attach_db_conn_str(ducklake.catalog_resource, db_type)?.replace('\'', "''"); let storage = ducklake .storage .storage .as_deref() - .unwrap_or(DEFAULT_STORAGE); - let data_path = ducklake.storage.path; + .unwrap_or(DEFAULT_STORAGE) + .replace('\'', "''"); + let data_path = ducklake.storage.path.replace('\'', "''"); let extra_args = if let Some(default_extra_args) = ducklake.extra_args { format!("{},{}", extra_args, default_extra_args) diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 7727982cf8..9fa0b514c4 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -22,6 +22,12 @@ mod r_executor; mod ai; mod ai_executor; + +// Exposed for the MCP resource-authorization regression test +// (tests/mcp_resource_authz.rs): the AI agent worker must load MCP resources +// through the job's permissioned client, not the raw DB pool. +#[cfg(feature = "mcp")] +pub use ai::utils::{load_mcp_tools, McpResourceConfig}; mod bun_executor; pub mod common; mod config; @@ -75,6 +81,9 @@ mod sanitized_sql_params; mod schema; mod sql_s3_input; pub mod sql_utils; +#[cfg(feature = "private")] +mod ssh_executor_ee; +mod ssh_executor_oss; mod universal_pkg_installer; #[cfg(feature = "private")] mod volume_ee; diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index 1ce2eb09b3..c089930b42 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -21,13 +21,17 @@ const NSJAIL_CONFIG_RUN_POWERSHELL_CONTENT: &str = lazy_static::lazy_static! { static ref RE_POWERSHELL_IMPORTS: Regex = Regex::new(r#"^\s*Import-Module\s+(?:-Name\s+)?"?([^\s"]+)"?(?:\s+-RequiredVersion\s+"?([^\s"]+)"?)?"#).unwrap(); + // Module names are interpolated into PowerShell string literals in the install + // script, so they must not contain anything that could break out of a quoted + // string. Restrict to characters valid in real module names. + static ref RE_SAFE_MODULE_NAME: Regex = Regex::new(r"^[a-zA-Z0-9._-]+$").unwrap(); } use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - MaybeLock, OccupancyMetrics, + read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, + start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, is_sandboxing_enabled, read_ee_registry_with_workspace_override, DISABLE_NUSER, HOME_ENV, @@ -264,12 +268,31 @@ struct ModuleRequest { version: Option, } +/// Reject module names that could break out of the PowerShell string literal they +/// are interpolated into (e.g. a name containing a single quote), preventing command +/// injection via crafted lock content or script imports (CWE-78). +fn validate_module_name(name: &str) -> Result<(), Error> { + if !RE_SAFE_MODULE_NAME.is_match(name) { + return Err(Error::internal_err(format!( + "Invalid PowerShell module name '{}': only alphanumeric, '.', '_' and '-' are allowed", + name.chars().take(50).collect::() + ))); + } + Ok(()) +} + /// Parse Import-Module statements from PowerShell code into module requests. +/// Imports with an invalid module name are skipped with a warning rather than +/// aborting the whole job. fn parse_script_imports(code: &str) -> Vec { let mut modules = Vec::new(); for line in code.lines() { for cap in RE_POWERSHELL_IMPORTS.captures_iter(line) { let name = cap.get(1).unwrap().as_str().to_string(); + if let Err(e) = validate_module_name(&name) { + tracing::warn!("Skipping PowerShell import with unsafe module name: {e}"); + continue; + } let version = cap.get(2).map(|m| m.as_str().to_string()); modules.push(ModuleRequest { name, version }); } @@ -293,6 +316,7 @@ fn parse_modules_json(content: &str) -> Result, Error> { })?; let mut result = Vec::new(); for (name, version) in modules { + validate_module_name(name)?; let version = match version { serde_json::Value::String(v) if v != "*" => Some(v.clone()), _ => None, @@ -511,13 +535,15 @@ pub async fn handle_powershell_job( let modules_list = modules_to_install .iter() .map(|module_req| { + // Defense-in-depth: escape single quotes so a name/version can never + // break out of the PowerShell string literal, even if validation is + // bypassed. Mirrors val_to_pwsh_param. + let name = module_req.name.replace("'", "''"); if let Some(version) = &module_req.version { - format!( - "@{{ Name = '{}'; Version = '{}' }}", - module_req.name, version - ) + let version = version.replace("'", "''"); + format!("@{{ Name = '{}'; Version = '{}' }}", name, version) } else { - format!("@{{ Name = '{}'; Version = $null }}", module_req.name) + format!("@{{ Name = '{}'; Version = $null }}", name) } }) .collect::>() @@ -528,13 +554,17 @@ pub async fn handle_powershell_job( .replace("{job_id}", &job.id.to_string()) .replace("{has_private_repo}", &format!("${has_private_repo}")) .replace("{has_credentials}", &format!("${has_credentials}")) + // Escape single quotes: these are interpolated into single-quoted + // PowerShell literals ($privateRepoUrl/$privateRepoPat) in the install + // script, so an unescaped quote in the configured repo URL/PAT would + // break out of the literal (same sink as the module names above). .replace( "{private_repo_url}", - &powershell_repo_url.unwrap_or_default(), + &powershell_repo_url.unwrap_or_default().replace("'", "''"), ) .replace( "{private_repo_pat}", - &powershell_repo_pat.unwrap_or_default(), + &powershell_repo_pat.unwrap_or_default().replace("'", "''"), ) .replace("{modules}", &modules_list); let mut cmd = Command::new(POWERSHELL_PATH.as_str()); @@ -1159,4 +1189,50 @@ Write-Host "Hello""#; versions.sort(); assert_eq!(versions, vec!["1.0.0", "1.655.0"]); } + + // --- module name sanitization (CWE-78) tests --- + + #[test] + fn test_validate_module_name_accepts_real_names() { + for name in [ + "PSWriteColor", + "ImportExcel", + "Az.Accounts", + "My_Module-1.0", + ] { + assert!(validate_module_name(name).is_ok(), "{name} should be valid"); + } + } + + #[test] + fn test_validate_module_name_rejects_injection() { + for name in [ + "Mod'; Remove-Item -Recurse / #", + "Mod' }; Invoke-Expression 'evil'; @{ Name = '", + "Mod with space", + "Mod\nImport-Module Evil", + "", + ] { + assert!( + validate_module_name(name).is_err(), + "{name:?} should be rejected" + ); + } + } + + #[test] + fn test_parse_modules_json_rejects_unsafe_name() { + let json = r#"{"modules": {"Mod'; Remove-Item /": "1.0.0"}}"#; + assert!(parse_modules_json(json).is_err()); + } + + #[test] + fn test_parse_script_imports_skips_unsafe_name() { + // Quoted import lets the regex capture a name containing a single quote; + // it must be silently dropped while the safe import is kept. + let code = "Import-Module \"Bad'Name\"\nImport-Module PSWriteColor"; + let modules = parse_script_imports(code); + assert_eq!(modules.len(), 1); + assert_eq!(modules[0].name, "PSWriteColor"); + } } diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index 9b2bbc6874..0d4219c0df 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -314,6 +314,38 @@ struct RenvPackage { dependencies: Vec, } +/// Reject renv package names/versions that could break out of the R string +/// literal they are interpolated into (`renv::install("name@version", ...)`), +/// preventing command injection (CWE-78) via crafted renv.lock content. +/// CRAN package names are letters/digits/'.' starting with a letter; versions +/// are dotted numerics optionally with '-'/'_'/'+' separators. +fn validate_renv_package(name: &str, version: &str) -> Result<(), Error> { + let name_ok = name + .chars() + .next() + .map_or(false, |c| c.is_ascii_alphabetic()) + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.'); + let version_ok = !version.is_empty() + && version + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')); + if !name_ok || !version_ok { + return Err(Error::ExecutionErr(format!( + "Invalid renv package name '{}' / version '{}': name must be alphanumeric or '.', \ + version alphanumeric or '.-_+'", + name.chars().take(50).collect::(), + version.chars().take(50).collect::(), + ))); + } + Ok(()) +} + +/// Escape a value for safe interpolation into an R double-quoted string literal: +/// backslash and double-quote are the only metacharacters inside `"..."`. +fn escape_r_double_quoted(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + /// Parse renv.lock JSON and extract package info including dependency edges. fn parse_renv_lock(lockfile: &str) -> Result, Error> { let lock: serde_json::Value = serde_json::from_str(lockfile) @@ -378,6 +410,7 @@ fn parse_renv_lock(lockfile: &str) -> Result, Error> { // Skip renv itself — it's already loaded and reinstalling it while // loaded triggers a noisy "Restart your R session" message. if !pkg_name.is_empty() && !version.is_empty() && pkg_name != "renv" { + validate_renv_package(&pkg_name, &version)?; result.push(RenvPackage { name: pkg_name, version, repo_url, dependencies }); } } @@ -493,9 +526,9 @@ async fn install<'a>( r#"options(renv.verbose = {verbose_r}, renv.config.install.verbose = {install_verbose_r}, renv.config.restart.enabled = FALSE); renv::install("{pkg}@{version}", library = "{lib}", dependencies = FALSE)"#, verbose_r = verbose_r, install_verbose_r = install_verbose_r, - pkg = dependency.custom_payload.pkg, - version = dependency.custom_payload.version, - lib = install_lib, + pkg = escape_r_double_quoted(&dependency.custom_payload.pkg), + version = escape_r_double_quoted(&dependency.custom_payload.version), + lib = escape_r_double_quoted(&install_lib), ), // install.packages fallback (no version pinning): // &format!( @@ -730,3 +763,65 @@ tryCatch({{ spread = spread, )) } + +#[cfg(test)] +mod tests { + use super::{escape_r_double_quoted, parse_renv_lock, validate_renv_package}; + + #[test] + fn test_validate_renv_package_accepts_real() { + for (n, v) in [ + ("ggplot2", "3.4.4"), + ("data.table", "1.14.8"), + ("Rcpp", "1.0.11"), + ("renv", "1.0.3"), + ("pkg", "1.2-3"), + ("x", "0.9.8.9000"), + ] { + assert!( + validate_renv_package(n, v).is_ok(), + "{n}@{v} should be valid" + ); + } + } + + #[test] + fn test_validate_renv_package_rejects_injection() { + // name breakouts, version breakouts, leading non-letter, empty + for (n, v) in [ + ("ggplot2\", library=system(\"id\"))#", "1.0"), + ("ok", "1.0\"); system(\"id\"); (\""), + ("ok", "1.0\\\"x"), + ("9pkg", "1.0"), + ("pkg name", "1.0"), + ("ok", ""), + ] { + assert!( + validate_renv_package(n, v).is_err(), + "{n:?}@{v:?} should be rejected" + ); + } + } + + #[test] + fn test_escape_r_double_quoted() { + assert_eq!(escape_r_double_quoted(r#"a"b"#), r#"a\"b"#); + assert_eq!(escape_r_double_quoted(r"a\b"), r"a\\b"); + // backslash escaped before quote so \" cannot be reinterpreted + assert_eq!(escape_r_double_quoted(r#"\""#), r#"\\\""#); + } + + #[test] + fn test_parse_renv_lock_rejects_unsafe_name() { + let lock = r#"{"Packages": {"evil": {"Package": "evil\"); system(\"id\"); (\"", "Version": "1.0"}}}"#; + assert!(parse_renv_lock(lock).is_err()); + } + + #[test] + fn test_parse_renv_lock_accepts_clean() { + let lock = r#"{"Packages": {"ggplot2": {"Package": "ggplot2", "Version": "3.4.4"}}}"#; + let pkgs = parse_renv_lock(lock).unwrap(); + assert_eq!(pkgs.len(), 1); + assert_eq!(pkgs[0].name, "ggplot2"); + } +} diff --git a/backend/windmill-worker/src/snowflake_executor.rs b/backend/windmill-worker/src/snowflake_executor.rs index 35051aee98..d3d4555f40 100644 --- a/backend/windmill-worker/src/snowflake_executor.rs +++ b/backend/windmill-worker/src/snowflake_executor.rs @@ -604,6 +604,24 @@ pub async fn do_snowflake( return Err(Error::BadRequest("Missing database argument".to_string())); }; + // Validate before it is interpolated into request URLs as the hostname + // (https://.snowflakecomputing.com/...). + if database.account_identifier.is_empty() + || !database + .account_identifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + { + return Err(Error::BadRequest(format!( + "Invalid Snowflake account identifier '{}': only alphanumeric, '.', '-' and '_' allowed", + database + .account_identifier + .chars() + .take(64) + .collect::() + ))); + } + let annotations = windmill_common::worker::SqlAnnotations::parse(query); let collection_strategy = if annotations.return_last_result { SqlResultCollectionStrategy::LastStatementAllRows diff --git a/backend/windmill-worker/src/ssh_executor_oss.rs b/backend/windmill-worker/src/ssh_executor_oss.rs new file mode 100644 index 0000000000..3cb1203c0e --- /dev/null +++ b/backend/windmill-worker/src/ssh_executor_oss.rs @@ -0,0 +1,27 @@ +#[cfg(feature = "private")] +#[allow(unused)] +pub(crate) use crate::ssh_executor_ee::*; + +// OSS stub: the `#ssh ` directive is an enterprise feature. The real +// implementation lives in ssh_executor_ee.rs (compiled under the `private` +// feature). See examples/usecase/ssh-execution-wrapper/ for the userland +// (no-license) alternative. +#[cfg(not(feature = "private"))] +pub(crate) async fn handle_ssh_bash_job( + _ssh_path: &str, + _mem_peak: &mut i32, + _canceled_by: &mut Option, + _job: &windmill_queue::MiniPulledJob, + _conn: &windmill_common::worker::Connection, + _client: &windmill_common::client::AuthedClient, + _content: &str, + _job_dir: &str, + _worker_name: &str, + _occupancy_metrics: &mut crate::common::OccupancyMetrics, +) -> Result, windmill_common::error::Error> { + Err(windmill_common::error::Error::ExecutionErr( + "SSH execution (#ssh) is an enterprise feature. Use the enterprise image, or the userland \ + SSH wrapper in examples/usecase/ssh-execution-wrapper/." + .to_string(), + )) +} diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 8e15e053ea..6e9a77597e 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1077,6 +1077,15 @@ async fn get_otel_tracing_proxy_envs( /// through the proxy without TLS interception, so clients that pin their own CA (kubectl, /// helm, terraform, etc.) keep working. Empty when unset, matching the prior behavior of /// intercepting all destinations including loopback. +/// +/// The worker's own `NO_PROXY` env is merged in so that enabling HTTP request tracing never +/// silently narrows exclusions an operator already configured at the container level: hosts +/// reachable directly before tracing was turned on stay reachable directly afterwards. The +/// upstream-relay side (`build_no_proxy_intercept`) already honors the container `NO_PROXY`; +/// this keeps the injected-into-jobs side symmetric. Note that job runtimes match `NO_PROXY` +/// by hostname/suffix, not by resolving against CIDR ranges, so CIDR-only entries (e.g. +/// `10.0.0.0/8`) carry over but won't match a hostname target — operators still need an +/// explicit host/suffix entry for those. #[cfg(all(feature = "private", feature = "enterprise"))] async fn build_tracing_proxy_no_proxy() -> String { let configured = OTEL_TRACING_PROXY_SETTINGS @@ -1084,22 +1093,23 @@ async fn build_tracing_proxy_no_proxy() -> String { .await .no_proxy_hosts .clone(); - normalize_no_proxy_hosts(configured.as_deref()) + normalize_no_proxy_hosts([configured.as_deref(), NO_PROXY.as_deref()]) } -/// Split a comma-separated NO_PROXY value, trim whitespace, drop empty entries, and -/// deduplicate while preserving order. `None` returns an empty string. +/// Split comma-separated NO_PROXY sources, trim whitespace, drop empty entries, and +/// deduplicate while preserving first-occurrence order. Sources are concatenated in the +/// order given (earlier sources win on ordering). Returns an empty string when all sources +/// are `None`/empty. #[cfg(all(feature = "private", feature = "enterprise"))] -fn normalize_no_proxy_hosts(configured: Option<&str>) -> String { - let Some(configured) = configured else { - return String::new(); - }; +fn normalize_no_proxy_hosts<'a>(sources: impl IntoIterator>) -> String { let mut seen = std::collections::HashSet::new(); let mut out: Vec<&str> = Vec::new(); - for entry in configured.split(',') { - let trimmed = entry.trim(); - if !trimmed.is_empty() && seen.insert(trimmed) { - out.push(trimmed); + for source in sources.into_iter().flatten() { + for entry in source.split(',') { + let trimmed = entry.trim(); + if !trimmed.is_empty() && seen.insert(trimmed) { + out.push(trimmed); + } } } out.join(",") @@ -1111,26 +1121,58 @@ mod no_proxy_tests { #[test] fn unset_returns_empty() { - assert_eq!(normalize_no_proxy_hosts(None), ""); + assert_eq!(normalize_no_proxy_hosts([None]), ""); + assert_eq!(normalize_no_proxy_hosts([None, None]), ""); } #[test] fn empty_and_whitespace_only_returns_empty() { - assert_eq!(normalize_no_proxy_hosts(Some("")), ""); - assert_eq!(normalize_no_proxy_hosts(Some(" , ,\t")), ""); + assert_eq!(normalize_no_proxy_hosts([Some("")]), ""); + assert_eq!(normalize_no_proxy_hosts([Some(" , ,\t")]), ""); } #[test] fn trims_and_skips_empties() { assert_eq!( - normalize_no_proxy_hosts(Some(" *.eks.amazonaws.com ,, *.internal ")), + normalize_no_proxy_hosts([Some(" *.eks.amazonaws.com ,, *.internal ")]), "*.eks.amazonaws.com,*.internal" ); } #[test] fn dedupes_preserving_first_occurrence_order() { - assert_eq!(normalize_no_proxy_hosts(Some("a,b,a,c,b,d")), "a,b,c,d"); + assert_eq!(normalize_no_proxy_hosts([Some("a,b,a,c,b,d")]), "a,b,c,d"); + } + + #[test] + fn merges_configured_with_container_no_proxy() { + // Configured tracing hosts come first, then the container NO_PROXY is appended. + assert_eq!( + normalize_no_proxy_hosts([ + Some("gitlab.internal"), + Some("localhost,127.0.0.1,10.0.0.0/8,.cluster.local") + ]), + "gitlab.internal,localhost,127.0.0.1,10.0.0.0/8,.cluster.local" + ); + } + + #[test] + fn merges_dedupe_across_sources() { + // Entries present in both sources are not duplicated. + assert_eq!( + normalize_no_proxy_hosts([Some("localhost,gitlab.internal"), Some("localhost,.svc")]), + "localhost,gitlab.internal,.svc" + ); + } + + #[test] + fn container_no_proxy_carries_over_when_unconfigured() { + // Tracing setting unset but container NO_PROXY present: exclusions still carry over, + // so enabling tracing does not silently drop them. + assert_eq!( + normalize_no_proxy_hosts([None, Some(".cluster.local,gitlab.internal")]), + ".cluster.local,gitlab.internal" + ); } } diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index c8d109c465..86263350c3 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -3268,7 +3268,7 @@ async fn push_next_flow_job( } // Compute and initialize last_job_result - let arc_last_job_result = if status_module.is_failure() { + let mut arc_last_job_result = if status_module.is_failure() { // if job is being retried, pass the result of its previous failure last_job_result.unwrap_or_else(|| Arc::new(to_raw_value(&json!("{}")))) } else if matches!(step, Step::Step { idx: 0, .. }) || step.is_preprocessor_step() { @@ -3699,6 +3699,30 @@ async fn push_next_flow_job( .context("update flow retry")?; status_module = FlowStatusModule::WaitingForPriorSteps { id: status_module.id() }; + + // The failed attempt's error has already been consumed by `evaluate_retry` + // above. Restore `previous_result` to the preceding step's result (or the + // flow args for the first step) so that predicates re-evaluated for the + // retry (skip_if, loop iterator expressions, ...) don't see the failed + // attempt's error instead. Like the suspend/restart path above, this + // falls back to `"{}"` when the preceding step has no Success status + // (e.g. it failed with continue_on_error). + if !matches!(step, Step::FailureStep) { + arc_last_job_result = if matches!(step, Step::Step { idx: 0, .. }) + || step.is_preprocessor_step() + { + Arc::new(to_raw_value(&flow_job.args)) + } else { + match get_previous_job_result(db, flow_job.workspace_id.as_str(), &status) + .warn_after_seconds(3) + .await? + { + None => Arc::new(to_raw_value(&json!("{}"))), + Some(previous_job_result) => Arc::new(previous_job_result), + } + }; + } + // we get the args from the last failed job status.retry.failed_jobs.last() /* Start the failure module ... */ @@ -5058,13 +5082,13 @@ async fn compute_next_flow_transform( NextStatus::NextStep, )) } - FlowModuleValue::AIAgent { .. } => { + FlowModuleValue::AIAgent { tag, .. } => { let path = get_path(flow_job, status, module); let payload = JobPayload::AIAgent { path }; Ok(NextFlowTransform::Continue( ContinuePayload::SingleJob(JobPayloadWithTag { payload, - tag: None, + tag: tag.filter(|t| !t.trim().is_empty()), delete_after_use, delete_after_secs, timeout: None, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 1634b4dc88..d98a2cd2e0 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -1248,6 +1248,7 @@ async fn lock_modules( FlowModuleValue::AIAgent { input_transforms, mut tools, + tag, omit_output_from_conversation, } => { // Extract FlowModules from tools and track their original indices @@ -1299,6 +1300,7 @@ async fn lock_modules( e.value = FlowModuleValue::AIAgent { input_transforms, tools, + tag, omit_output_from_conversation, } .into(); diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 3cd0c3effe..a82663c132 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.719.0"; +export const VERSION = "v1.726.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 682cc8119d..f588f5841d 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -228,6 +228,33 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { const ignore = await ignoreF(opts); const codebases = await listSyncCodebases(opts); + // Resolve relative imports from local (not-yet-deployed) content so dev-page + // previews use locally-edited workspace scripts instead of the deployed + // versions. Diverged local scripts are uploaded to temp storage once here + // and the resulting path -> hash refs ride along on every preview run. + // Computed as a startup snapshot, like `wmill app dev`; restart `wmill dev` + // to pick up later edits to imported workspace scripts. Uses the "all" + // target because the previewed item can change at runtime (picker / + // loadWmPath), so there is no single anchor node. Degrades gracefully + // (undefined) on older backends without the /raw_temp endpoints. + let tempScriptRefs: Record | undefined = undefined; + { + const { buildPreviewTempScriptRefs } = await import( + "../generate-metadata/generate-metadata.ts" + ); + tempScriptRefs = await buildPreviewTempScriptRefs( + workspace, + opts, + codebases, + { kind: "all" }, + ); + if (tempScriptRefs) { + log.info( + `Resolved ${Object.keys(tempScriptRefs).length} locally-edited script(s) for preview relative imports (snapshot — restart wmill dev to refresh)` + ); + } + } + const changesTimeouts: Record> = {}; function watchChanges() { return new Promise((_resolve, _reject) => { @@ -314,6 +341,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { flow: localFlow, uriPath: localPath, path: wmFlowPath, + temp_script_refs: tempScriptRefs, }; log.info("Updated " + wmFlowPath); broadcastChanges(currentLastEdit); @@ -337,6 +365,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { language: lang, tag: typed?.tag, lock: typed?.lock, + temp_script_refs: tempScriptRefs, }; log.info("Updated " + wmPath); broadcastChanges(currentLastEdit); @@ -350,7 +379,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { language: string; tag?: string; lock?: string; - + temp_script_refs?: Record; }; type LastEditFlow = { @@ -358,6 +387,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { flow: OpenFlow; uriPath: string; path: string; + temp_script_refs?: Record; }; // Load a resource by its windmill path (e.g., "u/admin/my_script" or "f/my_flow") @@ -399,6 +429,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { flow: localFlow, uriPath: flowDir, path: wmPath, + temp_script_refs: tempScriptRefs, }; currentLastEdit = edit; return edit; @@ -421,6 +452,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { language: lang, tag: typed?.tag, lock: typed?.lock, + temp_script_refs: tempScriptRefs, }; currentLastEdit = edit; return edit; diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index 5c3ca578c7..180491ea90 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -1,5 +1,7 @@ import { colors } from "@cliffy/ansi/colors"; import * as log from "../../core/log.ts"; +import { existsSync } from "node:fs"; +import { rm } from "node:fs/promises"; import * as path from "node:path"; import { sep as SEP } from "node:path"; import { stringify as yamlStringify } from "yaml"; @@ -17,7 +19,7 @@ import { filterWorkspaceDependenciesForScripts, } from "../../utils/metadata.ts"; import { ScriptLanguage } from "../../utils/script_common.ts"; -import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping, legacyLockPathForContent } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts"; import { generateHash, getHeaders, readTextFile, writeIfChanged } from "../../utils/utils.ts"; @@ -344,6 +346,31 @@ export async function generateFlowLockInternal( process.cwd() + SEP + folder + SEP + "flow.yaml", yamlStringify(flowValue as Record, yamlOptions) ); + + // CLI versions between #8561 and the canonical-lock-name fix named lock + // files after the content path minus only its last dot segment (e.g. + // "x.inline_script.deno.lock"). The canonical name strips the full + // language extension ("x.inline_script.lock"), so remove the legacy file. + // Runs after the flow.yaml rewrite above so an interruption can't leave + // flow.yaml referencing an already-deleted legacy file. + for (const s of inlineScripts) { + if (s.is_lock) continue; + const legacyRelPath = legacyLockPathForContent(s.path, s.language); + if (!legacyRelPath) continue; + // A legacy name can coincide with another step's canonical file written + // in this run (e.g. deno step "x" vs a step whose summary is "x.deno") — + // never delete a path that is part of the current extraction. + if (inlineScripts.some((other) => other.path === legacyRelPath)) continue; + const legacyAbsPath = process.cwd() + SEP + folder + SEP + legacyRelPath; + if (existsSync(legacyAbsPath)) { + try { + await rm(legacyAbsPath); + log.info(colors.gray(`Removed legacy lock file ${legacyRelPath} (renamed to canonical name)`)); + } catch (e) { + log.info(colors.yellow(`Failed to remove legacy lock file ${legacyRelPath}: ${e}`)); + } + } + } } // In tree mode, workspace deps are tracked via the tree — exclude from hash diff --git a/cli/src/commands/folder/folder.ts b/cli/src/commands/folder/folder.ts index 23167353ef..a91be98083 100644 --- a/cli/src/commands/folder/folder.ts +++ b/cli/src/commands/folder/folder.ts @@ -20,6 +20,7 @@ export interface FolderFile { owners: Array | undefined; extra_perms: { [record: string]: boolean } | undefined; default_permissioned_as?: Array<{ path_glob: string; permissioned_as: string }>; + labels?: Array; } async function list(opts: GlobalOptions & { json?: boolean }) { diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index 76c82ed39f..95a0fc3b22 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -97,8 +97,10 @@ async function walkLocalAppItems( * or app), so a preview run resolves relative imports from not-yet-deployed * local content instead of the deployed scripts. Walks all local scripts so * transitive relative-import targets can be uploaded, then for flow/app adds - * that item's node. Degrades gracefully (returns undefined) on older backends - * without the /raw_temp endpoints. + * that item's node. The "all" target skips per-node filtering and returns refs + * for every uploaded script — used by `wmill dev`, where the previewed item + * changes at runtime. Degrades gracefully (returns undefined) on older + * backends without the /raw_temp endpoints. */ export async function buildPreviewTempScriptRefs( workspace: Workspace, @@ -107,7 +109,8 @@ export async function buildPreviewTempScriptRefs( target: | { kind: "script"; path: string } | { kind: "flow"; folder: string } - | { kind: "app"; folder: string; rawApp: boolean }, + | { kind: "app"; folder: string; rawApp: boolean } + | { kind: "all" }, ): Promise | undefined> { try { const rawWorkspaceDependencies = await getRawWorkspaceDependencies(true); @@ -129,8 +132,11 @@ export async function buildPreviewTempScriptRefs( ); } - let nodePath: string; - if (target.kind === "script") { + let nodePath: string | undefined; + if (target.kind === "all") { + // No anchor node — refs are collected tree-wide below + nodePath = undefined; + } else if (target.kind === "script") { nodePath = scriptPathToRemotePath(target.path); } else if (target.kind === "flow") { const folder = target.folder.endsWith(SEP) @@ -157,7 +163,9 @@ export async function buildPreviewTempScriptRefs( tree.propagateStaleness(); await uploadScripts(tree, workspace); - const refs = tree.getTempScriptRefs(nodePath); + const refs = nodePath !== undefined + ? tree.getTempScriptRefs(nodePath) + : tree.getAllTempScriptRefs(); return refs && Object.keys(refs).length > 0 ? refs : undefined; } catch (e) { // Degrade gracefully (preview still runs against deployed versions) but do diff --git a/cli/src/commands/refresh/prompts.ts b/cli/src/commands/refresh/prompts.ts index 863ab4f9ff..0828342202 100644 --- a/cli/src/commands/refresh/prompts.ts +++ b/cli/src/commands/refresh/prompts.ts @@ -53,11 +53,19 @@ export async function refreshPrompts(opts: { }, }); - log.info(colors.green("Refreshed AGENTS.cli.md")); + log.info(colors.green("Refreshed AGENTS.wmill.md")); + + if (result.legacyManagedRemoved) { + log.info( + colors.yellow( + "Migrated legacy AGENTS.cli.md → AGENTS.wmill.md (removed the old file and rewrote any @AGENTS.cli.md include)." + ) + ); + } reportReconciliation({ file: "AGENTS.md", - includeLine: "@AGENTS.cli.md", + includeLine: "@AGENTS.wmill.md", created: result.agentsCreated, migration: result.agentsMigration, }); @@ -178,10 +186,10 @@ async function promptsAction(opts: CommandOptions): Promise { } const command = new Command() - .description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.") + .description("Refresh AGENTS.wmill.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.") .option( "--yes", - "Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched." + "Non-interactive: append the @AGENTS.wmill.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched." ) .action(promptsAction as any); diff --git a/cli/src/commands/refresh/refresh.ts b/cli/src/commands/refresh/refresh.ts index d1fea23af2..34b29663e3 100644 --- a/cli/src/commands/refresh/refresh.ts +++ b/cli/src/commands/refresh/refresh.ts @@ -4,7 +4,7 @@ import tsconfigCommand from "./tsconfig.ts"; const command = new Command() .description( - "Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)" + "Refresh wmill-managed project files (AGENTS.wmill.md, skills, tsconfig.wmill.json)" ) .command("prompts", promptsCommand) .command("tsconfig", tsconfigCommand); diff --git a/cli/src/commands/refresh/tsconfig.ts b/cli/src/commands/refresh/tsconfig.ts index 10d9993d50..167b030cc3 100644 --- a/cli/src/commands/refresh/tsconfig.ts +++ b/cli/src/commands/refresh/tsconfig.ts @@ -22,7 +22,7 @@ const WORKSPACE_IMPORT_DIRS = ["f", "u"]; // wmill-managed files holding the recommended config. They are always // (re)written so we can ship updated recommendations over time; users keep // their own overrides in tsconfig.json / deno.json, which reference these -// managed files and are never overwritten. This mirrors how AGENTS.cli.md +// managed files and are never overwritten. This mirrors how AGENTS.wmill.md // (managed) and AGENTS.md (user-owned) work for AI prompts. const MANAGED_TSCONFIG = "tsconfig.wmill.json"; const MANAGED_IMPORT_MAP = "import_map.wmill.json"; @@ -33,7 +33,7 @@ const MANAGED_NOTICE = // Embedded in tsconfig.wmill.json so any command can detect a stale managed file // (the recommended config changed) and nudge the user to `wmill refresh tsconfig` -// — mirroring the prompts freshness marker in AGENTS.cli.md. +// — mirroring the prompts freshness marker in AGENTS.wmill.md. const TSCONFIG_HASH_PREFIX = "// wmill-tsconfig-hash: "; const TSCONFIG_HASH_REGEX = /^\/\/ wmill-tsconfig-hash: ([0-9a-f]{12})/m; @@ -291,7 +291,7 @@ async function refreshManagedDenoImportMap(mode: WireMode) { /** * Ensure a user-owned config file references the wmill-managed file. Mirrors how - * `wmill refresh prompts` wires `@AGENTS.cli.md` into AGENTS.md: + * `wmill refresh prompts` wires `@AGENTS.wmill.md` into AGENTS.md: * - missing → create the minimal file (already linked); * - exists & linked → leave it alone; * - exists & unlinked → auto-wire it (parse JSON, apply `wire`, write back). diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index ed3b7828f1..e0e4e73659 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1836,6 +1836,318 @@ export interface Skips { includeKey?: boolean | undefined; } +// Detect paths (and their parent directories) within a single set that differ +// only by letter case — e.g. a remote workspace that genuinely holds both +// f/Caps/a and f/caps/b. On a case-insensitive filesystem (Windows, default +// macOS) these cannot be represented as two distinct files/directories at all, +// so we can only warn. Returns one group per collision, each listing the +// distinct casings sorted for stable output. +// +// Only the shallowest clash is reported: when two case-variant folders also +// contain same-named files (f/Caps/main.ts + f/caps/main.ts), the folder clash +// is the root cause, so the nested per-file group is suppressed rather than +// inflating the count with one entry per duplicated leaf. +export function findCaseInsensitiveCollisions( + paths: Iterable, +): string[][] { + // lowercased prefix -> set of distinct original casings observed + const byLower = new Map>(); + for (const full of paths) { + // Compare on normalized forward-slash prefixes so a Windows-style "\" map + // key and a remote "/" key collapse to the same prefix. + const segs = full.split(/[\\/]/).filter((s) => s.length > 0); + let acc = ""; + for (let i = 0; i < segs.length; i++) { + // Accumulate every directory prefix as well as the full file path, so a + // "f/Caps" vs "f/caps" folder clash is caught even when the leaf files + // (e.g. a.ts vs b.ts) don't themselves collide. + acc = i === 0 ? segs[i] : `${acc}/${segs[i]}`; + const lower = acc.toLowerCase(); + let set = byLower.get(lower); + if (!set) { + set = new Set(); + byLower.set(lower, set); + } + set.add(acc); + } + } + const collidingLowers = new Set(); + for (const [lower, set] of byLower) { + if (set.size > 1) collidingLowers.add(lower); + } + const collisions: string[][] = []; + for (const lower of collidingLowers) { + // Drop this group if any ancestor prefix is itself a collision — the + // shallower folder clash already names the root cause. + const parts = lower.split("/"); + let hasCollidingAncestor = false; + for (let i = 1; i < parts.length; i++) { + if (collidingLowers.has(parts.slice(0, i).join("/"))) { + hasCollidingAncestor = true; + break; + } + } + if (!hasCollidingAncestor) { + collisions.push([...byLower.get(lower)!].sort()); + } + } + return collisions; +} + +type CaseTrieNode = { + // lowercased segment -> child, recording the canonical (server) casing and + // whether the server holds more than one casing of that segment (ambiguous). + children: Map< + string, + { canonical: string; ambiguous: boolean; node: CaseTrieNode } + >; +}; + +// Rewrite `localMap` keys to the canonical casing recorded on the server +// (`remoteMap`) when they differ only by letter case. This is the core +// WIN-2020 fix: on a case-insensitive filesystem a folder such as `f/Caps` +// can have its on-disk casing silently drift (e.g. to `f/caps`) — Windows +// stores whatever case the directory was first created with and reports that +// from readdir, regardless of the server's path. Without this, the diff sees +// the drifted local path as an entirely different item and emits a destructive +// "delete f/Caps + add f/caps" pair, so a single capitalized folder appears to +// vanish and a lowercase clone shows up out of nowhere. Adopting the server +// casing collapses that phantom and leaves the canonical path on the server +// untouched. +// +// Canonicalization is segment-by-segment against a trie of remote paths, so it +// also applies the server's folder casing to brand-new local files that have no +// exact remote match (e.g. adding f/caps/New.ts under a drifted f/Caps folder +// becomes f/Caps/New.ts) — otherwise the push would recreate the case-only +// collision the fix is meant to prevent. A segment is only adopted when the +// server casing is unambiguous; at the first ambiguous or unknown segment the +// remainder of the path keeps its local casing. +// +// Returns the rewritten map, the per-key rewrites, and any genuinely ambiguous +// server-side groups (two distinct remote paths differing only by case) — those +// can't be canonicalized to a single target and are left for the caller to warn +// about. +export function canonicalizeCaseInsensitiveKeys( + localMap: Record, + remoteMap: Record, +): { + map: Record; + ambiguous: string[][]; + rewritten: { from: string; to: string }[]; +} { + const root: CaseTrieNode = { children: new Map() }; + for (const k of Object.keys(remoteMap)) { + let node = root; + for (const seg of k.split(/[\\/]/)) { + if (seg.length === 0) continue; + const lk = seg.toLowerCase(); + let entry = node.children.get(lk); + if (!entry) { + entry = { canonical: seg, ambiguous: false, node: { children: new Map() } }; + node.children.set(lk, entry); + } else if (entry.canonical !== seg) { + entry.ambiguous = true; + } + node = entry.node; + } + } + + const out: Record = {}; + const rewritten: { from: string; to: string }[] = []; + for (const [k, v] of Object.entries(localMap)) { + // Preserve the key's own separator style so the rewritten key still matches + // the rest of the map (and round-trips through push) on every platform. + const sep = k.includes("\\") ? "\\" : "/"; + const segs = k.split(/[\\/]/); + const canonSegs: string[] = []; + let node: CaseTrieNode | undefined = root; + let changed = false; + for (const seg of segs) { + if (seg.length === 0) { + canonSegs.push(seg); + continue; + } + const entry = node?.children.get(seg.toLowerCase()); + if (entry && !entry.ambiguous) { + if (entry.canonical !== seg) changed = true; + canonSegs.push(entry.canonical); + node = entry.node; + } else { + // No unambiguous server guidance for this segment: keep the local + // casing here and below (deeper server structure is unknown). + canonSegs.push(seg); + node = undefined; + } + } + const canonKey = canonSegs.join(sep); + if (changed && canonKey !== k) { + out[canonKey] = v; + rewritten.push({ from: k, to: canonKey }); + } else { + out[k] = v; + } + } + + return { + map: out, + ambiguous: findCaseInsensitiveCollisions(Object.keys(remoteMap)), + rewritten, + }; +} + +// Summarize case-only key rewrites by their differing path prefix (typically a +// folder such as f/caps -> f/Caps) so a folder whose casing drifted is reported +// once instead of once per contained file. +export function summarizeCaseRewrites( + rewritten: { from: string; to: string }[], +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const { from, to } of rewritten) { + const fromSegs = from.split(/[\\/]/); + const toSegs = to.split(/[\\/]/); + // Find the shortest prefix at which the two casings first differ; that is + // the folder (or file) whose casing actually changed. + let i = 0; + while ( + i < fromSegs.length && + i < toSegs.length && + fromSegs[i] === toSegs[i] + ) { + i++; + } + const fromPrefix = fromSegs.slice(0, i + 1).join("/"); + const toPrefix = toSegs.slice(0, i + 1).join("/"); + const key = `${fromPrefix} -> ${toPrefix}`; + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; +} + +// Emit a single grouped warning for case-only collisions that cannot be +// represented on a case-insensitive filesystem (two distinct server paths +// differing only by case). Unlike the drift handled by +// canonicalizeCaseInsensitiveKeys, these require the user to rename one side. +function warnUnrepresentableCaseCollisions(collisions: string[][]): void { + if (collisions.length === 0) return; + const groups = collisions.map((g) => ` - ${g.join(" <-> ")}`).join("\n"); + log.warn( + `Found ${collisions.length} path(s) that differ only by letter case:\n` + + `${groups}\n` + + `On case-insensitive filesystems (Windows, default macOS) these collapse ` + + `into a single file/directory and cannot both be synced. Rename one side ` + + `to a distinct path to make the tree sync reliably across platforms.`, + ); +} + +// Probe (and cache) whether `dir` lives on a case-insensitive filesystem. +// Auto-detected by round-tripping a probe file under two casings, with an +// explicit WMILL_CASE_INSENSITIVE_FS=true/false override so Windows behaviour +// can be forced (or emulated for cross-platform repos / tests) on any host. +let _caseInsensitiveFsCache: boolean | undefined; +export async function isCaseInsensitiveFilesystem( + dir: string, +): Promise { + const override = (process.env.WMILL_CASE_INSENSITIVE_FS ?? "") + .trim() + .toLowerCase(); + if (override === "true" || override === "1") return true; + if (override === "false" || override === "0") return false; + if (_caseInsensitiveFsCache !== undefined) return _caseInsensitiveFsCache; + let result = false; + try { + const upper = path.join(dir, `.wmill-CASEPROBE-${process.pid}.tmp`); + const lower = path.join(dir, `.wmill-caseprobe-${process.pid}.tmp`); + await writeFile(upper, "", "utf-8"); + try { + await stat(lower); + result = true; // lowercase name resolves to the file we wrote uppercase + } catch { + result = false; + } + await rm(upper).catch(() => {}); + await rm(lower).catch(() => {}); + } catch { + result = false; + } + _caseInsensitiveFsCache = result; + return result; +} + +// A script's `lock` is NULL on the remote only while the server is still +// (re)generating it — e.g. an importer relock is in flight after a dependent +// relative-import module changed, or the script's own first lock job has not +// settled yet. NULL means "lock pending", NOT "this script has no lock": a +// genuinely lock-free script (no dependencies, or a codebase script) serializes +// `lock: ''` (empty string), which keeps its metadata key. The git-sync deploy +// mirror reads the workspace inside that window and would otherwise mirror the +// transient NULL as a deletion of the committed `.script.lock` (and strip the +// `lock: '!inline …'` line from the metadata), corrupting the mirror until the +// relock writes the identical lock back seconds later (issue #9588). +// +// When pulling (remote -> local), if the remote reports a pending (NULL) lock +// for a script whose committed lock still exists locally, carry the local lock +// onto the remote map so the diff is a no-op for both the lock file and the +// metadata `lock` line. An empty-string lock ('') is left untouched, so a real +// "dependencies removed" transition still deletes the obsolete lock. +export function preservePendingScriptLocks( + remote: Record, + local: Record, +): void { + // A multi-module script keeps its metadata in the folder layout + // `…__mod/script.{yaml,json}` instead of `….script.{yaml,json}`. + // Map keys are always forward-slash normalized, on every platform. + const modMeta = getModuleFolderSuffix() + "/script"; + for (const metaKey of Object.keys(remote)) { + const isYaml = + metaKey.endsWith(".script.yaml") || metaKey.endsWith(modMeta + ".yaml"); + const isJson = + metaKey.endsWith(".script.json") || metaKey.endsWith(modMeta + ".json"); + if (!isYaml && !isJson) continue; + + const localMeta = local[metaKey]; + if (localMeta === undefined) continue; // script not committed locally + + let remoteParsed: any; + let localParsed: any; + try { + remoteParsed = isYaml + ? yamlParseContent(metaKey, remote[metaKey]) + : JSON.parse(remote[metaKey]); + localParsed = isYaml ? yamlParseContent(metaKey, localMeta) : JSON.parse(localMeta); + } catch { + continue; + } + if (typeof remoteParsed !== "object" || remoteParsed === null) continue; + if (typeof localParsed !== "object" || localParsed === null) continue; + + // Only a NULL/absent remote lock is "pending". An empty-string lock ('') is + // a real "no dependencies" state and must still propagate as a deletion. + const remoteLock = remoteParsed["lock"]; + if (remoteLock !== undefined && remoteLock !== null) continue; + + // The local side must reference an inline lock backed by a committed file. + const localLock = localParsed["lock"]; + if (typeof localLock !== "string" || !localLock.startsWith("!inline ")) continue; + + // Derive the lock-file key from the `!inline` reference itself, not from the + // metadata path: a multi-module script keeps its lock at `…__mod/script.lock`, + // which a `.script.yaml -> .script.lock` rewrite would miss. The reference and + // the map keys are both forward-slash, so no separator rewrite is needed. + const lockKey = localLock.slice("!inline ".length); + if (local[lockKey] === undefined) continue; // committed lock already gone + + remoteParsed["lock"] = localLock; + remote[metaKey] = isYaml + ? yamlStringify(remoteParsed, yamlOptions) + : JSON.stringify(remoteParsed, null, 2); + remote[lockKey] = local[lockKey]; + } +} + async function compareDynFSElement( els1: DynFSElement, els2: DynFSElement | undefined, @@ -1848,14 +2160,61 @@ async function compareDynFSElement( specificItems?: SpecificItemsConfig, branchOverride?: string, isEls1Remote?: boolean, + caseInsensitiveFs?: boolean, ): Promise<{ changes: Change[]; localMap: Record }> { - const [m1, m2] = els2 + let [m1, m2] = els2 ? await Promise.all([ elementsToMap(els1, ignore, json, skips, specificItems, branchOverride, isEls1Remote), elementsToMap(els2, ignore, json, skips, specificItems, branchOverride, !isEls1Remote), ]) : [await elementsToMap(els1, ignore, json, skips, specificItems, branchOverride, isEls1Remote), {}]; + // Reconcile letter-case differences between the local tree and the + // authoritative server casing. Only meaningful for an actual two-sided diff + // (els2 defined) where we know which side is the remote. + if (els2 && isEls1Remote !== undefined) { + const remoteMap = isEls1Remote ? m1 : m2; + + // Always warn about server paths that differ only by case (e.g. f/Caps and + // f/caps as two distinct items). These cannot coexist on a case-insensitive + // filesystem, so flag them on every platform — a Linux author needs to know + // their tree won't round-trip for a Windows/macOS teammate. + warnUnrepresentableCaseCollisions( + findCaseInsensitiveCollisions(Object.keys(remoteMap)), + ); + + // On a case-insensitive filesystem, the local on-disk casing of a folder + // can drift from the server's (Windows reports the case the directory was + // first created with). Rewrite those drifted local keys to the server + // casing so the diff treats them as the same item instead of a destructive + // delete+add pair. This is the WIN-2020 fix. + if (caseInsensitiveFs) { + const { map, rewritten } = canonicalizeCaseInsensitiveKeys( + isEls1Remote ? m2 : m1, + remoteMap, + ); + if (isEls1Remote) { + m2 = map; + } else { + m1 = map; + } + const summary = summarizeCaseRewrites(rewritten); + if (summary.length > 0) { + log.info( + `Reconciled ${summary.length} local path(s) to the server's casing ` + + `(case-insensitive filesystem):\n` + + summary.map((s) => ` ${s}`).join("\n"), + ); + } + } + } + + // Pull only (remote is els1): keep a committed `.script.lock` when the remote + // lock is transiently NULL (a relock is mid-flight). See #9588. + if (isEls1Remote === true) { + preservePendingScriptLocks(m1, m2); + } + const changes: Change[] = []; function parseYaml(k: string, v: string) { @@ -2613,6 +2972,7 @@ export async function pull( specificItems, wsNameForFiles, true, // els1 (remote) is the remote source + await isCaseInsensitiveFilesystem(process.cwd()), ); log.info( @@ -3182,6 +3542,14 @@ function removeSuffix(str: string, suffix: string) { return str.slice(0, str.length - suffix.length); } +// Shown after a `wmill sync push --dry-run` preview that has changes. `sync push` +// deploys to the remote workspace and is destructive (it overwrites and prunes +// remote items that differ from or are absent locally), so the preview reminds +// the caller — especially an AI agent that ran the dry-run to inspect changes — +// to get explicit user confirmation before applying it for real. +const SYNC_PUSH_DESTRUCTIVE_WARNING = + "`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites or deletes remote items that differ from or are absent locally — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation."; + export async function push( opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string; acceptOverridingPermissionedAsWithSelf?: boolean }, ) { @@ -3361,6 +3729,7 @@ export async function push( specificItems, wsNameForFiles, false, // els1 (local) is not the remote source + await isCaseInsensitiveFilesystem(process.cwd()), ); // Detect resources/variables that the local config flags as ws_specific @@ -3717,6 +4086,9 @@ export async function push( : {}), })), total: changes.length, + ...(changes.length > 0 + ? { warning: SYNC_PUSH_DESTRUCTIVE_WARNING } + : {}), }; console.log(JSON.stringify(result, null, 2)); return; @@ -3760,6 +4132,7 @@ export async function push( if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); + log.warn(colors.yellow(`\n⚠ ${SYNC_PUSH_DESTRUCTIVE_WARNING}`)); return; } diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 8d95db5b2b..dec35fc4f0 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -185,7 +185,13 @@ async function push( } async function add( - opts: GlobalOptions & { public?: boolean; plainSecrets?: boolean }, + opts: GlobalOptions & { + public?: boolean; + plainSecrets?: boolean; + yes?: boolean; + secret?: boolean; + description?: string; + }, value: string, remotePath: string ) { @@ -196,6 +202,10 @@ async function add( return; } + // --secret/--no-secret take precedence over the legacy --public flag; + // undefined means "secret on create, preserve current setting on update" + const isSecret = opts.secret ?? (opts.public ? false : undefined); + if ( await wmill.existsVariable({ workspace: workspace.workspaceId, @@ -203,6 +213,7 @@ async function add( }) ) { if ( + !opts.yes && !(await Confirm.prompt({ message: `Variable already exist, do you want to update its value?`, default: true, @@ -210,22 +221,46 @@ async function add( ) { return; } + if (isSecret === false) { + const existing = await wmill.getVariable({ + workspace: workspace.workspaceId, + path: remotePath, + decryptSecret: false, + }); + if (existing.is_secret) { + log.warn( + colors.yellow( + `Variable ${remotePath} is currently secret and will be downgraded to non-secret: its value will be stored in plaintext` + ) + ); + } + } log.info(colors.bold.yellow("Updating variable...")); + await wmill.updateVariable({ + workspace: workspace.workspaceId, + path: remotePath, + alreadyEncrypted: false, // value from CLI is always plaintext + requestBody: { + value, + ...(isSecret !== undefined ? { is_secret: isSecret } : {}), + ...(opts.description !== undefined + ? { description: opts.description } + : {}), + }, + }); + } else { + log.info(colors.bold.yellow("Creating variable...")); + await wmill.createVariable({ + workspace: workspace.workspaceId, + alreadyEncrypted: false, // value from CLI is always plaintext + requestBody: { + path: remotePath, + value, + is_secret: isSecret ?? true, + description: opts.description ?? "", + }, + }); } - - log.info(colors.bold.yellow("Pushing variable...")); - - await pushVariable( - workspace.workspaceId, - remotePath + ".variable.yaml", - undefined, - { - value, - is_secret: !opts.public, - description: "", - }, - true // value from CLI is always plaintext — tell API not to treat it as pre-encrypted - ); log.info(colors.bold.underline.green(`Variable ${remotePath} pushed`)); } @@ -255,8 +290,24 @@ const command = new Command() "Create a new variable on the remote. This will update the variable if it already exists." ) .arguments(" ") + .option( + "--yes", + "Skip confirmation prompt when updating an existing variable" + ) + .option( + "--secret", + "Mark the variable as secret (default when creating a new variable)" + ) + .option( + "--no-secret", + "Mark the variable as non-secret (when updating, the existing setting is preserved if neither --secret nor --no-secret is passed)" + ) + .option( + "--description ", + "Set the variable description (when updating, the existing description is preserved if not passed)" + ) .option("--plain-secrets", "Push secrets as plain text") - .option("--public", "Legacy option, use --plain-secrets instead") + .option("--public", "Legacy option, use --no-secret instead") .action(add as any); diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 03c5bd35ed..5742d9ae68 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -5,15 +5,29 @@ import * as log from "../../core/log.ts"; import { setClient } from "../../core/client.ts"; import { allWorkspaces, list, removeWorkspace } from "./workspace.ts"; import * as wmill from "../../../gen/services.gen.ts"; -import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository } from "../../utils/git.ts"; +import { + getCurrentGitBranch, + getOriginalBranchForWorkspaceForks, + gitBranchExists, + isGitRepository, + renameCurrentGitBranch, +} from "../../utils/git.ts"; +import process from "node:process"; import { WM_FORK_PREFIX } from "../../core/constants.ts"; import { tryResolveBranchWorkspace } from "../../core/context.ts"; +import { + findWorkspaceByGitBranch, + getEffectiveGitBranch, + getWorkspaceNames, + readConfigFile, +} from "../../core/conf.ts"; async function createWorkspaceFork( opts: GlobalOptions & { createWorkspaceName: string | undefined; color: string | undefined; datatableBehavior: string | undefined; + fromBranch: string | undefined; yes: boolean | undefined; }, workspaceName: string | undefined, @@ -23,7 +37,84 @@ async function createWorkspaceFork( throw new Error("You can only create forks within a git repo. Forks are tracked with git and synced to your instance with the git sync workflow."); } - const workspace = await tryResolveBranchWorkspace(opts); + const currentBranch = getCurrentGitBranch() + if (!currentBranch) { + throw new Error("Could not get git branch name"); + } + + const config = await readConfigFile({ warnIfMissing: false }); + const originalBranchIfForked = getOriginalBranchForWorkspaceForks(currentBranch); + + // A "base branch" is one we must not rename onto a fork branch: mapped to a + // workspace in wmill.yaml, or a conventional default (main/master). + const isBaseBranch = (branch: string): boolean => + branch === "main" || + branch === "master" || + findWorkspaceByGitBranch(config.workspaces, branch) !== undefined; + + // Decide the base branch the fork links to, and whether to rename the + // current working branch onto the fork branch. Auto-detected from where you + // are; `--from-branch` is the explicit/non-interactive override. + let clonedBranchName: string; + let renameCurrent: boolean; + + if (opts.fromBranch) { + // Explicit override: base on , rename the current branch. + if (opts.fromBranch === currentBranch) { + throw new Error( + `--from-branch is for converting a *different* working branch into the fork branch, but you are already on \`${currentBranch}\`. ` + + `Omit --from-branch to create a fresh fork branch with \`git checkout -b\`.`, + ); + } + if (isBaseBranch(currentBranch)) { + throw new Error( + `Refusing to rename your current branch \`${currentBranch}\` — it looks like a base branch (mapped to a workspace in wmill.yaml, or main/master). ` + + `Check out the disposable working branch you want to convert first.`, + ); + } + if (getOriginalBranchForWorkspaceForks(currentBranch)) { + // Current branch is itself a fork branch (wm-fork//). + // Renaming it onto the new fork branch would detach the existing fork. + throw new Error( + `Refusing to rename your current branch \`${currentBranch}\` — it is already a fork branch. ` + + `To fork a fork, omit --from-branch: \`wmill workspace fork\` bases the new fork on this fork's original branch and creates a fresh fork branch without renaming.`, + ); + } + if (!findWorkspaceByGitBranch(config.workspaces, opts.fromBranch)) { + throw new Error( + `Could not find a workspace mapped to branch \`${opts.fromBranch}\` in wmill.yaml's workspaces section. ` + + `Pass the base branch your fork should be based on (e.g. the branch bound to the parent workspace).`, + ); + } + clonedBranchName = opts.fromBranch; + renameCurrent = true; + } else if (originalBranchIfForked) { + // Fork of a fork: link to the original branch; user checks out a new branch. + log.info(`You are creating a fork of a fork. The branch will be linked to the original branch this was forked from, i.e. \`${originalBranchIfForked}\`, for all settings and overrides.`); + clonedBranchName = originalBranchIfForked; + renameCurrent = false; + } else if (isBaseBranch(currentBranch)) { + // On a base branch: base the fork on it; user checks out a fresh fork branch. + clonedBranchName = currentBranch; + renameCurrent = false; + } else { + // On a non-base working branch: offer to base the fork on it and rename it. + clonedBranchName = await resolveWorkingBranchBase(config, opts, currentBranch); + renameCurrent = true; + } + + // Resolve the parent workspace. When the base differs from the current + // branch (the rename workflows), resolve via the base branch's workspace; + // otherwise use plain branch resolution. + let workspace; + if (clonedBranchName === currentBranch) { + workspace = await tryResolveBranchWorkspace(opts); + } else { + const baseMatch = findWorkspaceByGitBranch(config.workspaces, clonedBranchName); + workspace = baseMatch + ? await tryResolveBranchWorkspace(opts, baseMatch[0]) + : await tryResolveBranchWorkspace(opts); + } if (!workspace) { throw new Error("Could not resolve workspace from branch name. Make sure you are in a git repo to use workspace forks"); @@ -31,24 +122,6 @@ async function createWorkspaceFork( log.info(`You are forking workspace (${workspace.workspaceId})`) - const currentBranch = getCurrentGitBranch() - if (!currentBranch) { - throw new Error("Could not get git branch name"); - } - const originalBranchIfForked = getOriginalBranchForWorkspaceForks(currentBranch); - - let clonedBranchName: string | null; - if (originalBranchIfForked) { - log.info(`You are creating a fork of a fork. The branch will be linked to the original branch this was forked from, i.e. \`${originalBranchIfForked}\`, for all settings and overrides.`); - clonedBranchName = originalBranchIfForked; - } else { - clonedBranchName = currentBranch; - } - - if (!clonedBranchName) { - throw new Error("Failed to get current branch name, aborting operation"); - } - if (opts.workspace) { log.info( colors.red.bold( @@ -58,18 +131,39 @@ async function createWorkspaceFork( return; } - while (workspaceName === undefined) { - if (!workspaceName) { - workspaceName = await Input.prompt("Name this forked workspace:"); + // When we're converting the current branch into the fork branch, default + // the fork's name/id to that branch — almost always what you want, and it + // keeps the fork branch named after the work you already have + // (wm-fork//). Interactive: pre-fill the prompt (press enter + // to accept). Non-interactive (`--yes`): use it automatically. + const branchDefaultId = renameCurrent ? branchToForkId(currentBranch) : undefined; + const interactive = process.stdin.isTTY && opts.yes !== true; + + if (workspaceName === undefined) { + if (branchDefaultId && !interactive) { + workspaceName = branchDefaultId; + log.info(`Naming the fork after the current branch: \`${workspaceName}\``); + } else { + workspaceName = await Input.prompt({ + message: "Name this forked workspace:", + default: branchDefaultId, + }); } } if (!workspaceId) { - workspaceId = await Input.prompt({ - message: `Enter the ID of this forked workspace, it will then be prefixed by ${WM_FORK_PREFIX}. It will also determine the branch name`, - default: workspaceName, - suggestions: [workspaceName], - }); + // The id (unlike the display name) must be a valid slug — derive it from + // the name rather than using the free-form name verbatim. + const idDefault = branchToForkId(workspaceName); + if (branchDefaultId && !interactive) { + workspaceId = idDefault; + } else { + workspaceId = await Input.prompt({ + message: `Enter the ID of this forked workspace, it will then be prefixed by ${WM_FORK_PREFIX}. It will also determine the branch name`, + default: idDefault, + suggestions: [idDefault], + }); + } } const token = workspace.token; @@ -87,6 +181,11 @@ async function createWorkspaceFork( log.info(colors.blue(`Creating forked workspace: ${workspaceName}...`)); const trueWorkspaceId = `${WM_FORK_PREFIX}-${workspaceId}`; + // Fail fast on an invalid id (e.g. an explicit positional id, or a long + // branch name under --yes) before existsWorkspace, datatable cloning, and + // branch creation — a late backend rejection would leave cloned databases + // behind. + validateForkWorkspaceId(trueWorkspaceId); let alreadyExists = false; try { alreadyExists = await wmill.existsWorkspace({ @@ -100,7 +199,10 @@ async function createWorkspaceFork( } if (alreadyExists) { - throw new Error(`This forked workspace '${workspaceId}' (${workspaceName}) already exists. Choose a different id`); + throw new Error( + `This forked workspace '${workspaceId}' (${workspaceName}) already exists, possibly archived (archiving keeps the id reserved). ` + + `Permanently delete it with \`wmill workspace delete-fork ${workspaceId}\` to reuse the id, or choose a different id`, + ); } // --- Datatable cloning (matches ForkDatatableSection.svelte) --- @@ -245,9 +347,39 @@ async function createWorkspaceFork( const newBranchName = `${WM_FORK_PREFIX}/${clonedBranchName}/${workspaceId}` - log.info(`Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command: + // Rename workflow: turn the current working branch into the fork branch in + // place so its commits become the fork's. (Consent was already established — + // by `--from-branch`, or the interactive prompt for a non-base branch.) + // Otherwise: leave the user on their branch and have them check out a fresh + // fork branch. + let onForkBranch = false; + if (renameCurrent) { + if (currentBranch === newBranchName) { + onForkBranch = true; + log.info(colors.green(`Your current branch is already \`${newBranchName}\`.`)); + } else if (gitBranchExists(newBranchName)) { + log.warn( + `Branch \`${newBranchName}\` already exists locally, so the current branch \`${currentBranch}\` was not renamed. ` + + `Check out the fork branch yourself (e.g. \`git checkout ${newBranchName}\`).`, + ); + } else { + renameCurrentGitBranch(newBranchName); + onForkBranch = true; + log.info( + colors.green( + `Renamed \`${currentBranch}\` → \`${newBranchName}\`. Your existing commits are now on the fork branch.`, + ), + ); + } + } -\t`+colors.white(`git checkout -b ${newBranchName}`) + ` + const checkoutHint = onForkBranch + ? `Created forked workspace ${trueWorkspaceId}. You are on the fork branch \`${newBranchName}\` — push it to sync your fork.` + : `Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command: + +\t` + colors.white(`git checkout -b ${newBranchName}`); + + log.info(`${checkoutHint} When doing operations on the forked workspace, it will use the remote setup in the workspaces section for the branch it was forked from. @@ -258,6 +390,122 @@ To merge changes back to the parent workspace, you can: See: https://www.windmill.dev/docs/advanced/workspace_forks`); } +/** + * When `wmill workspace fork` is run from a non-base working branch, confirm + * the user wants to turn it into a fork branch, and resolve which base branch + * the fork should be linked to. Throws in non-interactive mode (where the user + * must pass `--from-branch ` instead). + */ +async function resolveWorkingBranchBase( + config: Awaited>, + opts: { yes?: boolean }, + currentBranch: string, +): Promise { + const interactive = process.stdin.isTTY && opts.yes !== true; + if (!interactive) { + throw new Error( + `You are on working branch \`${currentBranch}\`, which is not a base branch. ` + + `Pass --from-branch to base the fork on a base branch and rename this branch onto the fork branch, ` + + `or check out a base branch and run \`wmill workspace fork\` to create a fresh fork branch.`, + ); + } + + const { Select } = await import("@cliffy/prompt/select"); + const proceed = await Select.prompt({ + message: `You're on working branch \`${currentBranch}\`, not a base branch. Base a fork on it and rename it onto the fork branch?`, + options: [ + { name: "Yes, base the fork on this branch and rename it", value: "yes" }, + { name: "No, cancel", value: "no" }, + ], + }); + if (proceed !== "yes") { + throw new Error("Fork cancelled. Check out a base branch to create a fresh fork branch instead."); + } + + const baseBranches = listConfiguredBaseBranches(config); + if (baseBranches.length === 0) { + throw new Error( + `No base branches are configured in wmill.yaml's workspaces section, so the fork can't be linked to a parent. ` + + `Add the parent workspace to wmill.yaml, or pass --from-branch .`, + ); + } + if (baseBranches.length === 1) { + log.info(`Basing the fork on \`${baseBranches[0]}\`.`); + return baseBranches[0]; + } + return await Select.prompt({ + message: "Which base branch is this fork based on (the parent)?", + options: baseBranches.map((b) => ({ name: b, value: b })), + }); +} + +// The backend caps a fork workspace id (`wm-fork-`) at 50 chars total +// (validate_fork_workspace_id in windmill-common), so the slug is at most +// 50 - "wm-fork-".length (8) = 42. +const MAX_FORK_ID_SLUG = 42; + +/** + * Derive a workspace-id-safe slug from a git branch name. Branch names can + * contain `/` and other characters that aren't valid in a workspace id and + * would break the `wm-fork//` branch-name parsing, so collapse any + * invalid run to a single dash, trim, and cap to the backend length limit. + */ +function branchToForkId(branch: string): string { + const slug = branch + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_FORK_ID_SLUG) + .replace(/-+$/g, ""); // re-trim if the cut landed on a dash + return slug || "fork"; +} + +/** + * Mirror the backend `validate_fork_workspace_id` so an invalid id fails fast + * — before `existsWorkspace`, datatable cloning (which creates real per-fork + * Postgres databases), and branch creation, none of which get cleaned up on a + * late backend rejection. `id` is the full `wm-fork-` workspace id. + */ +function validateForkWorkspaceId(id: string): void { + const reject = (reason: string): never => { + throw new Error( + `Fork workspace id \`${id}\` is invalid: ${reason}. Choose a shorter or simpler name/id.`, + ); + }; + if (id.length > 50) { + reject(`too long (${id.length} chars; max 50 including the \`${WM_FORK_PREFIX}-\` prefix)`); + } + if (id.endsWith(".")) reject("cannot end with '.'"); + if (id.endsWith(".lock")) reject("cannot end with '.lock'"); + if (id.includes("..")) reject("cannot contain '..'"); + if (id.includes("@{")) reject("cannot contain '@{'"); + if (id.includes("//")) reject("cannot contain '//'"); + for (const ch of id) { + if (":~^?*[\\ ".includes(ch)) reject(`contains forbidden character '${ch}'`); + const code = ch.charCodeAt(0); + if (code < 0x20 || code === 0x7f) reject("contains a control character"); + } + for (const component of id.split("/")) { + if (component.startsWith(".")) reject("a path component cannot start with '.'"); + if (component.endsWith(".lock")) reject("a path component cannot end with '.lock'"); + } +} + +/** + * Base branches configured in wmill.yaml, mirroring how `findWorkspaceByGitBranch` + * keys them (effective git branch = `gitBranch ?? workspaceName`, reserved keys + * excluded) so the chosen base resolves to a workspace afterwards. + */ +function listConfiguredBaseBranches( + config: Awaited>, +): string[] { + const workspaces = config.workspaces; + const branches = new Set(); + for (const name of getWorkspaceNames(workspaces)) { + branches.add(getEffectiveGitBranch(name, workspaces![name])); + } + return [...branches]; +} + async function deleteWorkspaceFork( opts: GlobalOptions & { yes?: boolean; @@ -329,4 +577,9 @@ async function deleteWorkspaceFork( } } -export { createWorkspaceFork, deleteWorkspaceFork }; +export { + branchToForkId, + createWorkspaceFork, + deleteWorkspaceFork, + validateForkWorkspaceId, +}; diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 2ff71fda27..5628213b75 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -812,7 +812,11 @@ const command = new Command() "--datatable-behavior ", "How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)" ) - .option("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip')") + .option( + "--from-branch ", + "Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. Usually unneeded — from a working branch `wmill workspace fork` offers this interactively; from a base branch it creates a fresh fork branch." + ) + .option("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.") .action(createWorkspaceFork as any) .command("delete-fork") .description("Delete a forked workspace and git branch") diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 31df17af6b..b30d6f2f92 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.719.0"; +export const VERSION = "1.726.1"; diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index e87f3c72ca..25890e1923 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -3,18 +3,28 @@ * * `wmill` writes two files: * - * - `AGENTS.cli.md` — managed CLI / workspace guidance, refreshed by + * - `AGENTS.wmill.md` — managed CLI / workspace guidance, refreshed by * `wmill refresh prompts` (and the implicit refresh inside `wmill init`). * - `AGENTS.md` — user-owned project entry point. The default skeleton - * references `AGENTS.cli.md` via an `@`-include so the managed content is + * references `AGENTS.wmill.md` via an `@`-include so the managed content is * pulled in automatically. + * + * The managed file used to be named `AGENTS.cli.md`; `wmill init` / + * `wmill refresh prompts` migrate the old name to `AGENTS.wmill.md` (and + * rewrite the `@`-include) automatically. The legacy constants below exist + * solely for that migration. */ -export const AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md"; +export const AGENTS_WMILL_FILENAME = "AGENTS.wmill.md"; +export const AGENTS_WMILL_INCLUDE_LINE = "@AGENTS.wmill.md"; + +/** Legacy managed filename / include line, migrated away from on init/refresh. */ +export const LEGACY_AGENTS_CLI_FILENAME = "AGENTS.cli.md"; +export const LEGACY_AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md"; /** * Lightweight, user-owned AGENTS.md skeleton. Written only when no AGENTS.md - * exists in the project. Everything below the `@AGENTS.cli.md` include is for + * exists in the project. Everything below the `@AGENTS.wmill.md` include is for * the user to edit; nothing in this file is refreshed by `wmill`. */ export function generateAgentsMdSkeleton(): string { @@ -28,7 +38,7 @@ The line below pulls in Windmill's managed CLI guidance (skills, deploy flow, debugging jobs, etc.). Refresh it with \`wmill refresh prompts\`. Remove the include line if you don't want the managed guidance in this project. -${AGENTS_CLI_INCLUDE_LINE} +${AGENTS_WMILL_INCLUDE_LINE} ## Project-specific instructions @@ -41,8 +51,12 @@ ${AGENTS_CLI_INCLUDE_LINE} } /** - * Managed AGENTS.cli.md content. Rewritten by `wmill init` and + * Managed AGENTS.wmill.md content. Rewritten by `wmill init` and * `wmill refresh prompts` every time. + * + * NOTE: `system_prompts/generate.py` extracts this template by anchoring on + * the function name `generateAgentsCliMdContent` — keep the name in sync if + * you rename it. */ export function generateAgentsCliMdContent(skillsReference: string): string { return `# Windmill CLI Agent Instructions @@ -112,10 +126,11 @@ There are two ways local changes reach the workspace. Pick based on how the repo Before deploying, check whether this repo has a **GitHub Actions (or other CI) workflow that runs \`wmill sync push\` on push**. That workflow is the signal that pushing a branch will deploy: - Look for \`.github/workflows/*.yml\` (or other CI configs) that invoke \`wmill sync push\`, \`wmill\` deployment commands, or similar. -- Cache the result for the rest of the session — don't re-scan on every deploy. If such a workflow exists → **use \`git push\`** (Option A). Otherwise → **use \`wmill sync push\`** directly (Option B). +**Save the preference so you don't re-detect it every session.** Once you've determined which option this repo uses (or the user tells you), record it in the **project-specific instructions** section of \`AGENTS.md\` (user-owned — never overwritten by \`wmill refresh prompts\`), e.g. a line like \`Deploy mode: git push (CI runs wmill sync push)\` or \`Deploy mode: wmill sync push (no CI wiring)\`. On later sessions, read that line first and skip the scan. Re-detect only if the CI wiring visibly changed. + ### Option A — \`git push\` (CI is wired to sync) The CI workflow will pick up the commit and run \`wmill sync push\` on the backend, which is how deployments are intended to happen in this repo. Don't bypass it. @@ -137,6 +152,19 @@ No CI workflow runs \`wmill sync push\` automatically, so deploy directly from t Only deploy when the user explicitly asks to deploy, publish, push, or ship — not when they say "run", "try", or "test". For testing local edits use the per-entity \`preview\` commands (\`wmill script preview\`, \`wmill flow preview\`) — they don't deploy. +## Workspace forks + +A **fork** is an isolated copy of a workspace for parallel or experimental work — make changes (including to datatables, which are cloned per fork) without touching the parent, then merge back after review. Each fork is paired with a git branch named \`wm-fork//\`. Forks require a git repo. + +Just run \`wmill workspace fork\` — it adapts to where you are: + +- **On a base branch** (e.g. \`main\`, or a branch bound to a workspace): it bases the fork on that branch and prints a \`git checkout -b wm-fork//\` to start a fresh fork branch. +- **On a working branch** (e.g. you've branched and already edited a forked datatable): it offers to base the fork on that branch and rename it onto \`wm-fork//\` in place, preserving its commits — asking which base branch is the parent if there's more than one. + +For non-interactive runs from a working branch, pass \`--from-branch \` to skip the prompts. The CLI refuses to rename a base branch. + +Merge a fork back into its parent with \`wmill workspace merge\` (or the Merge UI on the fork's home page). Full reference: https://www.windmill.dev/docs/advanced/workspace_forks + ## Debugging Jobs When the user reports a script or flow failure, is investigating unexpected output, or asks why something ran the way it did, use the CLI to fetch job details before speculating. See the \`cli-commands\` skill for all flags. @@ -157,5 +185,13 @@ For flow failures, start with \`wmill job get \` to identify the failing ste For specific guidance, ALWAYS use the skills listed below. Paths point at \`.agents/skills/\` — Claude Code reads identical copies under \`.claude/skills/\`. ${skillsReference} + +## Windmill Documentation + +For Windmill concepts not covered by the skills (triggers, schedules, workers, flows, error handling, etc.), read the official docs: + +- Fetch https://www.windmill.dev/llms.txt — a curated index of every docs page with one-line descriptions — to find the right page for any concept. +- Every docs page is available as raw markdown by appending \`.md\` to its URL, e.g. https://www.windmill.dev/docs/core_concepts/scheduling.md — prefer these over the HTML pages. +- https://www.windmill.dev/llms-full.txt is the entire documentation as a single ~2.3 MB file — only for bulk indexing/RAG, do NOT load it directly into context. `; } diff --git a/cli/src/guidance/freshness.ts b/cli/src/guidance/freshness.ts index 5104f7c0e6..02590b7235 100644 --- a/cli/src/guidance/freshness.ts +++ b/cli/src/guidance/freshness.ts @@ -1,13 +1,13 @@ /** - * Versioning + freshness check for the managed AGENTS.cli.md bundle. + * Versioning + freshness check for the managed AGENTS.wmill.md bundle. * - * We embed a short hash of "what this CLI would write" into AGENTS.cli.md as + * We embed a short hash of "what this CLI would write" into AGENTS.wmill.md as * an HTML comment. On every `wmill` command (with a few exceptions), we read * the stored hash and compare against the current CLI's hash. Mismatch => * one-line warning telling the user to `wmill refresh prompts`. * * The hash covers all inputs that affect the rendered bundle: the - * AGENTS.cli.md template, every skill body, schemas and schema mappings, and + * AGENTS.wmill.md template, every skill body, schemas and schema mappings, and * the nonDottedPaths setting. It is *not* tied to the CLI's package version, * so non-prompt CLI releases don't produce false positives. */ @@ -15,7 +15,11 @@ import { createHash } from "node:crypto"; import { stat } from "node:fs/promises"; import { colors } from "@cliffy/ansi/colors"; import { readTextFile } from "../utils/utils.ts"; -import { generateAgentsCliMdContent } from "./core.ts"; +import { + AGENTS_WMILL_FILENAME, + LEGACY_AGENTS_CLI_FILENAME, + generateAgentsCliMdContent, +} from "./core.ts"; import { SCHEMAS, SCHEMA_MAPPINGS, @@ -43,7 +47,7 @@ export function extractPromptsHash(content: string): string | null { } /** - * Insert the hash marker into rendered AGENTS.cli.md content. The marker + * Insert the hash marker into rendered AGENTS.wmill.md content. The marker * goes on the line right after the title so it's easy to find and doesn't * break the rendered Markdown structure. */ @@ -73,7 +77,7 @@ export function currentPromptsHash(nonDottedPaths: boolean): string { hasher.update(generateAgentsCliMdContent("__PLACEHOLDER__")); // Skill metadata (names + descriptions) — fed into the skills reference - // line in AGENTS.cli.md and the wrapper frontmatter. + // line in AGENTS.wmill.md and the wrapper frontmatter. hasher.update("\nskills:"); hasher.update(JSON.stringify(SKILLS)); @@ -101,10 +105,16 @@ export function currentPromptsHash(nonDottedPaths: boolean): string { } /** - * Read AGENTS.cli.md in the current working directory, compare its embedded - * hash to the current CLI's hash, and print a one-line warning if they - * differ. Silent on every other code path (no AGENTS.cli.md, no marker, + * Read the managed guidance file in the current working directory, compare its + * embedded hash to the current CLI's hash, and print a one-line warning if they + * differ. Silent on every other code path (no managed file, no marker, * matching hash, IO error, …) so it never gets in the user's way. + * + * Back-compat: prefers `AGENTS.wmill.md` but falls back to the legacy + * `AGENTS.cli.md` and runs the exact same staleness check on it. We do NOT + * warn merely because the old filename is in use — an up-to-date `AGENTS.cli.md` + * stays quiet; only a stale hash (which `wmill refresh prompts` fixes, and + * which also migrates the filename) trips the warning. */ export async function warnIfPromptsStale(opts?: { cwd?: string; @@ -114,9 +124,18 @@ export async function warnIfPromptsStale(opts?: { if (opts?.argv && !shouldRunFreshnessCheck(opts.argv)) return; const cwd = opts?.cwd ?? process.cwd(); - const path = `${cwd}/AGENTS.cli.md`; - if (!(await stat(path).catch(() => null))) return; + // Prefer the current filename; fall back to the legacy one for back-compat. + let fileName = AGENTS_WMILL_FILENAME; + let path = `${cwd}/${fileName}`; + if (!(await stat(path).catch(() => null))) { + fileName = LEGACY_AGENTS_CLI_FILENAME; + path = `${cwd}/${fileName}`; + if (!(await stat(path).catch(() => null))) { + // Neither file present — this project just isn't wmill-managed. + return; + } + } let content: string; try { @@ -127,10 +146,10 @@ export async function warnIfPromptsStale(opts?: { const stored = extractPromptsHash(content); if (!stored) { - // Older AGENTS.cli.md without a marker. Warn so the user re-runs - // refresh and picks up the new format. + // Managed file without a marker. Warn so the user re-runs refresh and + // picks up the new format (and the new filename). emitWarning( - "Your AGENTS.cli.md predates prompt versioning. Run `wmill refresh prompts` to refresh and add a version marker." + `Your ${fileName} predates prompt versioning. Run \`wmill refresh prompts\` to refresh and add a version marker.` ); return; } @@ -152,7 +171,7 @@ export async function warnIfPromptsStale(opts?: { const current = currentPromptsHash(nonDottedPaths); if (stored !== current) { emitWarning( - "Your AGENTS.cli.md is out of date. Run `wmill refresh prompts` to refresh." + `Your ${fileName} is out of date. Run \`wmill refresh prompts\` to refresh.` ); } } diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index aded724843..a8fb92bee0 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -291,6 +291,10 @@ import Stripe from "stripe"; import { someFunction } from "some-package"; \`\`\` +## Prefer \`//native\` when the runtime allows it + +If a script only needs \`fetch\` and the JavaScript standard library — including when it uses \`windmill-client\` — prefer making it a **native** script: add \`//native\` as the first line and write it with the \`write-script-bunnative\` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. \`windmill-client\` works on the native worker (its calls go over \`fetch\`), so needing the Windmill client is **not** a reason to avoid \`//native\`. Use the regular \`bun\` language only when the code (or a dependency) needs Node/Bun runtime APIs — \`node:*\` modules, the filesystem, child processes, or native addons. + ## Windmill Client Import the windmill client for platform interactions: @@ -299,7 +303,9 @@ Import the windmill client for platform interactions: import * as wmill from "windmill-client"; \`\`\` -See the SDK documentation for available methods. +**Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill. + +The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to \`fetch\`. ## Preprocessor Scripts @@ -1012,7 +1018,9 @@ export async function main(url: string) { ## Windmill Client -\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP. +\`windmill-client\` works on the native worker (its calls go over \`fetch\`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). It handles auth, the workspace, and the base URL for you. Reserve raw \`fetch\` for calling *external* HTTP APIs that aren't Windmill. + +The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a \`fetch\` against the Windmill API. ## Preprocessor Scripts @@ -1813,7 +1821,9 @@ Import the windmill client for platform interactions: import * as wmill from "windmill-client"; \`\`\` -See the SDK documentation for available methods. +**Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill. + +The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to \`fetch\`. ## Preprocessor Scripts @@ -4494,7 +4504,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se ## CLI Commands — running, previewing, deploying -After writing, tell the user which command fits what they want to do: +After writing, act on the user's intent instead of just listing commands. Run the safe, non-deploying command yourself when it fits (\`wmill flow preview\` — see "After writing — offer to run, don't wait passively" below); only *name* the commands that deploy or rewrite files (\`wmill sync push\`, \`wmill generate-metadata\`) so the user can approve them. The options: - \`wmill flow preview \` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. Add \`--step \` to run only one module in isolation (see "Single-step vs whole-flow preview" below). - \`wmill flow run \` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. @@ -4831,7 +4841,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. @@ -4960,7 +4970,7 @@ The runnable ID is the filename without extension. For example, \`get_user.ts\` | C# | \`.cs\` | \`myFunc.cs\` | | Java | \`.java\` | \`myFunc.java\` | -After creating a runnable, tell the user they can generate lock files by running: +After creating a runnable, offer to generate its lock files as a one-sentence next step (e.g. "Want me to generate the lock files?") and run it yourself once they agree — don't just name the command and wait. If the user already asked you to finish/lock the app, run it directly. It writes local lock files (not a deploy), so offer rather than running silently: \`\`\`bash wmill generate-metadata \`\`\` @@ -5051,15 +5061,16 @@ data: ## CLI Commands -\`wmill app new\` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. +Two commands you run yourself, not the user: +- \`wmill app new\` — run it with flags, per the "Creating a Raw App" section above. +- \`wmill generate-metadata\` — generates local lock files; offer it and run it on consent, per "After creating a runnable" above (it writes local lock files, not a deploy). -For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: +For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time: | Command | Description | |---------|-------------| | \`wmill app dev\` | Start dev server with live reload (see the \`preview\` skill for the full open-the-app-in-the-IDE-pane procedure). | | \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md | -| \`wmill generate-metadata\` | Generate lock files for backend runnables | | \`wmill sync push\` | Deploy app to Windmill | | \`wmill sync pull\` | Pull latest from Windmill | @@ -5266,10 +5277,10 @@ Text/HTML/inline parts are placed inline in \`body\` as strings. ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +\`wmill sync push\` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". \`sync pull\` is safe to run yourself — it never mutates remote state, though it does overwrite local files to match the remote (use \`sync pull --dry-run\` to only preview). \`\`\`bash -# Push trigger configuration +# Push trigger configuration — only when the user explicitly asks to deploy wmill sync push # Pull triggers from Windmill @@ -5317,10 +5328,10 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +\`wmill sync push\` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". The commands below never mutate remote state, so they're safe to run yourself — note that \`sync pull\` does overwrite local files to match the remote (use \`sync pull --dry-run\` to only preview), while \`schedule\` just lists. \`\`\`bash -# Push schedules to Windmill +# Push schedules to Windmill — only when the user explicitly asks to deploy wmill sync push # Pull schedules from Windmill @@ -5574,7 +5585,8 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push \`\`\` `, @@ -6469,12 +6481,12 @@ List all queues with their metrics ### refresh -Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json) +Refresh wmill-managed project files (AGENTS.wmill.md, skills, tsconfig.wmill.json) **Subcommands:** -- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. - - \`--yes\` - Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched. +- \`refresh prompts\` - Refresh AGENTS.wmill.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - \`--yes\` - Non-interactive: append the @AGENTS.wmill.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched. - \`refresh tsconfig\` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects) - \`--yes\` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically). @@ -6707,8 +6719,12 @@ variable related commands - \`variable push \` - Push a local variable spec. This overrides any remote versions. - \`--plain-secrets\` - Push secrets as plain text - \`variable add \` - Create a new variable on the remote. This will update the variable if it already exists. + - \`--yes\` - Skip confirmation prompt when updating an existing variable + - \`--secret\` - Mark the variable as secret (default when creating a new variable) + - \`--no-secret\` - Mark the variable as non-secret (when updating, the existing setting is preserved if neither --secret nor --no-secret is passed) + - \`--description \` - Set the variable description (when updating, the existing description is preserved if not passed) - \`--plain-secrets\` - Push secrets as plain text - - \`--public\` - Legacy option, use --plain-secrets instead + - \`--public\` - Legacy option, use --no-secret instead ### version @@ -6765,7 +6781,8 @@ workspace related commands - \`--create-workspace-name \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`--color \` - Workspace color (hex code, e.g. #ff0000) - \`--datatable-behavior \` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt) - - \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip') + - \`--from-branch \` - Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on (its bound workspace is the parent) and rename the current branch onto wm-fork//. Usually unneeded — from a working branch \`wmill workspace fork\` offers this interactively; from a base branch it creates a fresh fork branch. + - \`-y --yes\` - Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for. - \`workspace delete-fork \` - Delete a forked workspace and git branch - \`-y --yes\` - Skip confirmation prompt - \`workspace merge\` - Compare and deploy changes between a fork and its parent workspace @@ -6949,6 +6966,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' azure_resource_path: type: string azure_mode: @@ -7034,6 +7071,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' local_part: type: string workspaced_local_part: @@ -7099,6 +7156,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' gcp_resource_path: type: string description: Path to the GCP resource containing service account credentials for @@ -7200,6 +7277,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' route_path: type: string description: The URL route path that will trigger this endpoint (e.g., 'api/myendpoint'). @@ -7334,6 +7431,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' kafka_resource_path: type: string description: Path to the Kafka resource containing connection configuration @@ -7437,6 +7554,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' mqtt_resource_path: type: string description: Path to the MQTT resource containing broker connection configuration @@ -7531,6 +7668,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' nats_resource_path: type: string description: Path to the NATS resource containing connection configuration @@ -7612,6 +7769,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' postgres_resource_path: type: string description: Path to the PostgreSQL resource containing connection configuration @@ -7794,6 +7971,30 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + schedule at the same path. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' + inherited_labels: + type: array + items: + type: string + description: 'Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. + + ' required: - schedule - script_path @@ -7817,6 +8018,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -7897,6 +8118,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' url: type: string description: The WebSocket URL to connect to (can be a static URL or computed diff --git a/cli/src/guidance/writer.ts b/cli/src/guidance/writer.ts index 033519bf8e..d1ae4e5fe9 100644 --- a/cli/src/guidance/writer.ts +++ b/cli/src/guidance/writer.ts @@ -1,8 +1,11 @@ -import { cp, mkdir, readdir, stat, writeFile } from "node:fs/promises"; +import { cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises"; import { readTextFile } from "../utils/utils.ts"; import { join } from "node:path"; import { - AGENTS_CLI_INCLUDE_LINE, + AGENTS_WMILL_FILENAME, + AGENTS_WMILL_INCLUDE_LINE, + LEGACY_AGENTS_CLI_FILENAME, + LEGACY_AGENTS_CLI_INCLUDE_LINE, generateAgentsCliMdContent, generateAgentsMdSkeleton, } from "./core.ts"; @@ -25,7 +28,7 @@ type ResolvedSkillMetadata = SkillMetadata & { /** * How to reconcile an existing user-owned guidance file (AGENTS.md or * CLAUDE.md) that doesn't reference the managed file below it - * (`@AGENTS.cli.md` for AGENTS.md, `@AGENTS.md` for CLAUDE.md). + * (`@AGENTS.wmill.md` for AGENTS.md, `@AGENTS.md` for CLAUDE.md). * * - `append`: leave the file as-is and append the include line. * - `overwrite`: replace the file with the managed skeleton. @@ -45,13 +48,13 @@ export interface WriteAiGuidanceOptions { nonDottedPaths?: boolean; /** Skill source override (testing / source-of-truth bundling). */ skillsSourcePath?: string; - /** AGENTS.cli.md source override (testing). */ + /** AGENTS.wmill.md source override (testing). */ agentsSourcePath?: string; /** CLAUDE.md source override (testing). */ claudeSourcePath?: string; /** * Optional resolver invoked when an existing AGENTS.md lacks an - * `@AGENTS.cli.md` reference. Callers are expected to prompt the user; if + * `@AGENTS.wmill.md` reference. Callers are expected to prompt the user; if * omitted, the writer defaults to `append` (non-destructive). */ resolveAgentsMdMigration?: () => Promise; @@ -64,6 +67,12 @@ export interface WriteAiGuidanceResult { claudeCreated: boolean; claudeMigration: ReconcileOutcome; skillCount: number; + /** + * True when a legacy `AGENTS.cli.md` was found and removed (its content is + * superseded by `AGENTS.wmill.md`, and any `@AGENTS.cli.md` includes were + * rewritten to `@AGENTS.wmill.md`). + */ + legacyManagedRemoved: boolean; } export const WMILL_INIT_AI_SKILLS_SOURCE_ENV = "WMILL_INIT_AI_SKILLS_SOURCE"; @@ -94,7 +103,7 @@ export async function writeAiGuidanceFiles( ? await readSkillMetadataFromDirectory(options.skillsSourcePath) : getGeneratedSkillMetadata(); - // AGENTS.cli.md — always (re)written, this is the managed file. + // AGENTS.wmill.md — always (re)written, this is the managed file. // We embed a content-hash marker so other `wmill` commands can detect a // stale bundle and prompt the user to `wmill refresh prompts`. const rawAgentsCliContent = @@ -105,23 +114,29 @@ export async function writeAiGuidanceFiles( rawAgentsCliContent, currentPromptsHash(nonDottedPaths) ); - const agentsCliPath = join(options.targetDir, "AGENTS.cli.md"); + const agentsCliPath = join(options.targetDir, AGENTS_WMILL_FILENAME); await writeFile(agentsCliPath, agentsCliContent, "utf8"); const agentsCliWritten = true; + // Migrate the legacy `AGENTS.cli.md`: rewrite `@AGENTS.cli.md` includes in + // user-owned files to `@AGENTS.wmill.md`, then remove the stale managed + // file. Done before reconciliation so the rewritten include reads as + // "already-linked" rather than triggering a duplicate append. + const legacyManagedRemoved = await migrateLegacyManagedFile(options.targetDir); + // Cache the user's first migration answer and reuse it for every file // that needs reconciling in this run — there's never a good reason to ask // the same question twice in a row. const resolveMigration = cacheOnce(options.resolveAgentsMdMigration); // AGENTS.md — user-owned. Three paths: - // 1. doesn't exist → create skeleton (which already includes @AGENTS.cli.md). - // 2. exists and already references @AGENTS.cli.md → leave alone. - // 3. exists but doesn't reference @AGENTS.cli.md → ask caller via + // 1. doesn't exist → create skeleton (which already includes @AGENTS.wmill.md). + // 2. exists and already references @AGENTS.wmill.md → leave alone. + // 3. exists but doesn't reference @AGENTS.wmill.md → ask caller via // resolveMigration (defaults to append). const agentsMdResult = await reconcileIncludingFile({ path: join(options.targetDir, "AGENTS.md"), - includeLine: AGENTS_CLI_INCLUDE_LINE, + includeLine: AGENTS_WMILL_INCLUDE_LINE, skeleton: generateAgentsMdSkeleton(), resolveMigration, }); @@ -153,9 +168,60 @@ export async function writeAiGuidanceFiles( claudeCreated: claudeMdResult.created, claudeMigration: claudeMdResult.migration, skillCount: skillMetadata.length, + legacyManagedRemoved, }; } +/** + * One-time migration from the old managed filename (`AGENTS.cli.md`) to + * `AGENTS.wmill.md`: + * + * 1. Rewrite the `@AGENTS.cli.md` include token → `@AGENTS.wmill.md` in + * AGENTS.md and CLAUDE.md (only when it appears as a standalone + * whitespace-delimited token, so lookalikes like `@AGENTS.cli.md.backup` + * are left intact). + * 2. Delete the stale `AGENTS.cli.md` — its content is fully superseded by + * the freshly written `AGENTS.wmill.md`. + * + * Returns true when a legacy `AGENTS.cli.md` was present and removed. + */ +async function migrateLegacyManagedFile(targetDir: string): Promise { + for (const fileName of ["AGENTS.md", "CLAUDE.md"]) { + const filePath = join(targetDir, fileName); + const existing = await readTextFile(filePath).catch(() => null); + if (existing == null) continue; + const rewritten = rewriteIncludeToken( + existing, + LEGACY_AGENTS_CLI_INCLUDE_LINE, + AGENTS_WMILL_INCLUDE_LINE + ); + if (rewritten !== existing) { + await writeFile(filePath, rewritten, "utf8"); + } + } + + const legacyPath = join(targetDir, LEGACY_AGENTS_CLI_FILENAME); + const legacyExists = (await stat(legacyPath).catch(() => null)) != null; + if (legacyExists) { + await rm(legacyPath, { force: true }); + } + return legacyExists; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Replace `from` with `to` only where `from` appears as a standalone + * whitespace-delimited token. Line endings and surrounding content are + * preserved (`\s` in the lookahead matches `\r`), so a CRLF file stays CRLF. + */ +function rewriteIncludeToken(content: string, from: string, to: string): string { + const re = new RegExp(`(?<=^|\\s)${escapeRegExp(from)}(?=\\s|$)`, "gm"); + return content.replace(re, to); +} + function cacheOnce( resolver: (() => Promise) | undefined ): (() => Promise) | undefined { @@ -212,7 +278,7 @@ function referencesIncludeLine(content: string, includeLine: string): boolean { // line by itself: our own CLAUDE.md default is `Instructions are in // @AGENTS.md` (one sentence), and a strict equality check made `wmill // refresh prompts` re-prompt every run on files wmill itself wrote. - // Skipping comment-bearing lines keeps `` from + // Skipping comment-bearing lines keeps `` from // false-positiving. for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); diff --git a/cli/src/main.ts b/cli/src/main.ts index 6d71f45d04..ff8c576ee9 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -305,7 +305,7 @@ async function main() { return response; }); - // Warn (one line) if AGENTS.cli.md predates this CLI's prompts bundle. + // Warn (one line) if AGENTS.wmill.md predates this CLI's prompts bundle. // The check is gated on argv parsing (cheap) so the ~360 KB skills.gen.ts // bundle stays out of the import graph for help/version/init/refresh/etc. if (shouldRunFreshnessCheck(process.argv)) { diff --git a/cli/src/utils/dependency_tree.ts b/cli/src/utils/dependency_tree.ts index 5e2adf7453..b0b75e659e 100644 --- a/cli/src/utils/dependency_tree.ts +++ b/cli/src/utils/dependency_tree.ts @@ -353,6 +353,22 @@ export class DoubleLinkedDependencyTree { return result; } + /** + * Returns path → contentHash for ALL uploaded nodes, regardless of which + * script imports them. Superset of getTempScriptRefs(path) for every path — + * used by `wmill dev`, where the previewed item changes at runtime so no + * single anchor node exists. Must be called after uploadScripts(). + */ + getAllTempScriptRefs(): Record { + const result: Record = {}; + for (const [path, node] of this.nodes.entries()) { + if (node.contentHash) { + result[path] = node.contentHash; + } + } + return result; + } + /** * Persist workspace dep hashes to wmill-lock.yaml so getRawWorkspaceDependencies * considers them up-to-date on the next run. diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index 0d2e8cb8a8..65e4064a29 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -20,6 +20,33 @@ export function getCurrentGitBranch(): string | null { } } +/** Whether a local branch with this exact name exists. */ +export function gitBranchExists(branchName: string): boolean { + const r = spawnSync( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], + { stdio: "pipe" }, + ); + return r.status === 0; +} + +/** + * Rename the currently checked-out branch (`git branch -m `). Used by + * `wmill workspace fork --from-branch` to turn an existing working branch into + * the `wm-fork//` fork branch in place, preserving its commits. + */ +export function renameCurrentGitBranch(newName: string): void { + const r = spawnSync("git", ["branch", "-m", newName], { + encoding: "utf8", + stdio: "pipe", + }); + if ((r.status ?? 1) !== 0) { + throw new Error( + `git branch -m ${newName} failed (exit ${r.status}): ${r.stderr ?? ""}`, + ); + } +} + export function getOriginalBranchForWorkspaceForks(branchName: string | null): string | null { if (!branchName || !branchName.startsWith(WM_FORK_PREFIX)) { return null @@ -248,7 +275,13 @@ export function gitSyncIncludePattern( case "emailtrigger": return `${path}.email_trigger.*`; default: - return `${path}.*`; + // Scripts: `${path}.*` matches the dotted layout + // (`${path}.script.yaml` etc.), `${path}__mod/**` matches the folder + // layout used by scripts with companion modules + // (`${path}__mod/script.ts`, `${path}__mod/helper.ts`, ...). Without the + // second pattern the module files are filtered out of the pull and the + // subsequent `git add '${path}**'` fails with "pathspec did not match". + return `${path}.*,${path}__mod/**`; } } diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 9668dd19e6..ebb35e9d33 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -574,6 +574,49 @@ export function extractWorkspaceDepsAnnotation( return { mode, external, inline }; } +// Mirrors backend ScriptLang::as_comment_lit (windmill-types/src/scripts.rs) +// for the languages that can reach the lock cache. +const LANG_COMMENT_LIT: Partial> = { + python3: "#", + ansible: "#", + powershell: "#", + bun: "//", + nativets: "//", + deno: "//", + go: "//", + php: "//", + rust: "//!", +}; + +/** + * Returns the leading comment/blank-line block of the script, verbatim. + * + * Backend annotation parsing (PythonAnnotations / TypeScriptAnnotations via + * the `annotations!` macro, and `# py:` version lines) only reads this leading + * block, and those annotations (e.g. `# py311`, `# py: `, `//npm`, + * `# no_cache`) change the generated lockfile even when workspace + * dependencies fully specify the requirements. With workspace deps the + * backend runs in manual mode and ignores the script body, so two scripts + * sharing the same header, annotation, and deps resolve to the same lockfile. + */ +export function extractLockRelevantHeader( + scriptContent: string, + language: ScriptLanguage +): string { + const comment = LANG_COMMENT_LIT[language]; + // Unknown language: be conservative and treat the whole content as relevant. + if (!comment) return scriptContent; + const headerLines: string[] = []; + for (const line of scriptContent.split("\n")) { + if (line.trim() === "" || line.startsWith(comment)) { + headerLines.push(line); + } else { + break; + } + } + return headerLines.join("\n"); +} + export async function computeLockCacheKey( scriptContent: string, language: ScriptLanguage, @@ -589,7 +632,26 @@ export async function computeLockCacheKey( const tempRefsStr = tempScriptRefs ? Object.keys(tempScriptRefs).sort().map((k) => `${k}=${tempScriptRefs[k]}`).join(";") : ""; - return await generateHash(`${language}|${annotationStr}|${depsStr}|${tempRefsStr}`); + // The lock only ignores the script body when the backend resolves it in + // manual workspace-deps mode: either the script has a manual annotation + // (external refs are fetched server-side by name when not in the raw map), + // or it has no annotation but its language matches a default workspace deps + // file (callers like updateModuleLocks pass the unfiltered map, so + // re-filter here). In every other case — extra mode (appends the script's + // scanned imports), deno/rust/ansible modules that only see deps of other + // languages, relative-import resolution via tempScriptRefs — the backend + // scans the script's own content, so it must be in the key. + const isManualWorkspaceDeps = annotation + ? annotation.mode === "manual" + : Object.keys( + filterWorkspaceDependencies(rawWorkspaceDependencies, scriptContent, language) + ).length > 0; + const contentStr = isManualWorkspaceDeps + ? extractLockRelevantHeader(scriptContent, language) + : scriptContent; + return await generateHash( + `${language}|${annotationStr}|${depsStr}|${tempRefsStr}|${contentStr}` + ); } const lockCache = new Map(); diff --git a/cli/test/case_insensitive_collisions_unit.test.ts b/cli/test/case_insensitive_collisions_unit.test.ts new file mode 100644 index 0000000000..511a5975ca --- /dev/null +++ b/cli/test/case_insensitive_collisions_unit.test.ts @@ -0,0 +1,232 @@ +import { expect, test } from "bun:test"; + +import { + findCaseInsensitiveCollisions, + canonicalizeCaseInsensitiveKeys, + summarizeCaseRewrites, +} from "../src/commands/sync/sync.ts"; + +// ============================================================================= +// Case-insensitive sync handling (WIN-2020) +// +// Windmill paths are case-sensitive, but Windows and the default macOS setup +// use case-insensitive filesystems. The real-world failure is NOT a user +// deliberately authoring both f/Caps and f/caps — it is a single capitalized +// folder whose on-disk casing drifts (Windows reports the case the directory +// was first created with), so the diff sees a brand-new lowercase path and a +// destructive delete of the real one. These tests pin: +// 1. findCaseInsensitiveCollisions — warn about genuinely unrepresentable +// server-side collisions (two distinct remote paths differing only by +// case). +// 2. canonicalizeCaseInsensitiveKeys — the fix: rewrite drifted local keys +// to the server's casing so no phantom delete+add is produced. +// ============================================================================= + +function normalize(groups: string[][]): string[][] { + return groups + .map((g) => [...g]) + .sort((a, b) => a.join().localeCompare(b.join())); +} + +// --------------------------------------------------------------------------- +// findCaseInsensitiveCollisions +// --------------------------------------------------------------------------- + +test("collision detection: sibling folders differing only by case", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/Caps/a.script.ts", + "f/caps/b.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: leaf files differing only by case", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/team/Report.script.ts", + "f/team/report.script.ts", + ]); + expect(normalize(collisions)).toEqual([ + ["f/team/Report.script.ts", "f/team/report.script.ts"], + ]); +}); + +test("collision detection: none for case-consistent distinct paths", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/caps/foo.script.ts", + "f/caps/bar.script.ts", + "f/other/baz.script.ts", + ]); + expect(collisions).toEqual([]); +}); + +test("collision detection: normalizes mixed forward/back slashes", () => { + const collisions = findCaseInsensitiveCollisions([ + "f\\Caps\\a.script.ts", + "f/caps/b.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: reports only the shallowest clash for same-named leaves", () => { + // Two case-variant folders that ALSO hold a same-named file must report the + // single folder clash, not the folder group plus a nested per-file group. + const collisions = findCaseInsensitiveCollisions([ + "f/Caps/main.script.ts", + "f/caps/main.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: groups three distinct casings together", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/caps/a.script.ts", + "f/CAPS/b.script.ts", + "f/Caps/c.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/CAPS", "f/Caps", "f/caps"]]); +}); + +// --------------------------------------------------------------------------- +// canonicalizeCaseInsensitiveKeys (the WIN-2020 fix) +// --------------------------------------------------------------------------- + +test("canonicalize: drifted local folder casing adopts the server casing", () => { + // Server (remote) is authoritative: f/Caps. The local tree drifted to + // f/caps on a case-insensitive FS. + const remote = { + "f/Caps/x.script.ts": "content", + "f/Caps/x.script.yaml": "meta", + }; + const local = { + "f/caps/x.script.ts": "content", + "f/caps/x.script.yaml": "meta", + }; + const { map, ambiguous, rewritten } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + // Local keys are rewritten to the server casing, so a subsequent exact-key + // diff sees identical paths — no phantom delete+add. + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/x.script.ts", + "f/Caps/x.script.yaml", + ]); + expect(ambiguous).toEqual([]); + expect(rewritten).toEqual([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "f/caps/x.script.yaml", to: "f/Caps/x.script.yaml" }, + ]); +}); + +test("canonicalize: preserves content values while rewriting keys", () => { + const remote = { "f/MyFolder/Script.script.ts": "remote" }; + const local = { "f/myfolder/Script.script.ts": "LOCAL EDIT" }; + const { map } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(map["f/MyFolder/Script.script.ts"]).toEqual("LOCAL EDIT"); + expect(map["f/myfolder/Script.script.ts"]).toBeUndefined(); +}); + +test("canonicalize: leaves keys with no case-insensitive remote match", () => { + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { + "f/Caps/x.script.ts": "a", + "f/brand_new/y.script.ts": "b", // genuinely local-only add + }; + const { map, rewritten } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(rewritten).toEqual([]); + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/x.script.ts", + "f/brand_new/y.script.ts", + ]); +}); + +test("canonicalize: does NOT rewrite when the server casing is ambiguous", () => { + // The server itself holds two paths differing only by case — we must not + // silently pick one. Leave the local key untouched and report the ambiguity. + const remote = { + "f/Caps/x.script.ts": "a", + "f/caps/x.script.ts": "b", + }; + const local = { "f/CAPS/x.script.ts": "local" }; + const { map, ambiguous, rewritten } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + expect(rewritten).toEqual([]); + expect(Object.keys(map)).toEqual(["f/CAPS/x.script.ts"]); + // Reported as the shallowest (folder) clash, not the nested per-file group. + expect(normalize(ambiguous)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("canonicalize: new local file under a drifted folder adopts the server folder casing", () => { + // Regression for the P1 review finding: a brand-new local file has no exact + // remote match, but it still lives under a folder whose casing drifted. It + // must inherit the server's folder casing (f/Caps), otherwise push would + // create f/caps/New beside f/Caps/* and reintroduce the case-only collision. + const remote = { "f/Caps/Existing.script.ts": "remote" }; + const local = { + "f/caps/Existing.script.ts": "remote", // drifted, existing + "f/caps/New.script.ts": "brand new", // drifted folder, local-only file + }; + const { map, rewritten } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/Existing.script.ts", + "f/Caps/New.script.ts", + ]); + expect(map["f/Caps/New.script.ts"]).toEqual("brand new"); + expect(rewritten).toContainEqual({ + from: "f/caps/New.script.ts", + to: "f/Caps/New.script.ts", + }); +}); + +test("canonicalize: stops at the first segment with no server guidance", () => { + // Only the folder prefix that exists on the server is canonicalized; deeper + // local-only directories keep their own casing. + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { "f/caps/SubDir/y.script.ts": "b" }; + const { map } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(Object.keys(map)).toEqual(["f/Caps/SubDir/y.script.ts"]); +}); + +test("canonicalize: identical casing is a no-op", () => { + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { "f/Caps/x.script.ts": "a" }; + const { map, rewritten, ambiguous } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + expect(rewritten).toEqual([]); + expect(ambiguous).toEqual([]); + expect(map).toEqual({ "f/Caps/x.script.ts": "a" }); +}); + +// --------------------------------------------------------------------------- +// summarizeCaseRewrites +// --------------------------------------------------------------------------- + +test("summarize: collapses per-file rewrites into one folder entry", () => { + const summary = summarizeCaseRewrites([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "f/caps/x.script.yaml", to: "f/Caps/x.script.yaml" }, + { from: "f/caps/y.script.ts", to: "f/Caps/y.script.ts" }, + ]); + expect(summary).toEqual(["f/caps -> f/Caps"]); +}); + +test("summarize: reports a leaf-file casing change at file granularity", () => { + const summary = summarizeCaseRewrites([ + { from: "f/team/report.script.ts", to: "f/team/Report.script.ts" }, + ]); + expect(summary).toEqual([ + "f/team/report.script.ts -> f/team/Report.script.ts", + ]); +}); + +test("summarize: reports independent folder drifts separately", () => { + const summary = summarizeCaseRewrites([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "u/alice/y.script.ts", to: "u/Alice/y.script.ts" }, + ]); + expect(summary.sort()).toEqual(["f/caps -> f/Caps", "u/alice -> u/Alice"]); +}); diff --git a/cli/test/dependency_tree_unit.test.ts b/cli/test/dependency_tree_unit.test.ts new file mode 100644 index 0000000000..1a92bb9894 --- /dev/null +++ b/cli/test/dependency_tree_unit.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import * as path from "node:path"; +import { DoubleLinkedDependencyTree } from "../src/utils/dependency_tree.ts"; + +// addNode consults wmill-lock.yaml from cwd for workspace deps; run inside a +// temp dir so the test never reads/writes the repo's own lock file. +async function withTempDir( + fn: () => Promise, +): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_deptree_test_")); + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + await fn(); + } finally { + process.chdir(originalCwd); + await rm(tempDir, { recursive: true }); + } +} + +async function buildTree(): Promise { + const tree = new DoubleLinkedDependencyTree(); + // main imports lib; other is unrelated + await tree.addNode( + "f/test/main", + `import { x } from "./lib.ts"`, + "bun", + "", + ["f/test/lib"], + "script", + "f/test/main", + "f/test/main.ts", + true, + ); + await tree.addNode( + "f/test/lib", + "export const x = 1", + "bun", + "", + [], + "script", + "f/test/lib", + "f/test/lib.ts", + true, + ); + await tree.addNode( + "f/other/standalone", + "export const y = 2", + "bun", + "", + [], + "script", + "f/other/standalone", + "f/other/standalone.ts", + true, + ); + return tree; +} + +test("getAllTempScriptRefs returns refs for every uploaded node", async () => { + await withTempDir(async () => { + const tree = await buildTree(); + // Simulate uploadScripts marking all three as uploaded + tree.setContentHash("f/test/main", "hash_main"); + tree.setContentHash("f/test/lib", "hash_lib"); + tree.setContentHash("f/other/standalone", "hash_standalone"); + + expect(tree.getAllTempScriptRefs()).toEqual({ + "f/test/main": "hash_main", + "f/test/lib": "hash_lib", + "f/other/standalone": "hash_standalone", + }); + }); +}); + +test("getAllTempScriptRefs skips nodes without a content hash", async () => { + await withTempDir(async () => { + const tree = await buildTree(); + // Only lib diverged from deployed + tree.setContentHash("f/test/lib", "hash_lib"); + + expect(tree.getAllTempScriptRefs()).toEqual({ "f/test/lib": "hash_lib" }); + }); +}); + +test("getAllTempScriptRefs is a superset of getTempScriptRefs for any node", async () => { + await withTempDir(async () => { + const tree = await buildTree(); + tree.setContentHash("f/test/lib", "hash_lib"); + tree.setContentHash("f/other/standalone", "hash_standalone"); + + const all = tree.getAllTempScriptRefs(); + for (const node of ["f/test/main", "f/test/lib", "f/other/standalone"]) { + const perNode = tree.getTempScriptRefs(node); + for (const [k, v] of Object.entries(perNode)) { + expect(all[k]).toBe(v); + } + } + // And the per-node view of main only sees its own transitive import + expect(tree.getTempScriptRefs("f/test/main")).toEqual({ + "f/test/lib": "hash_lib", + }); + }); +}); diff --git a/cli/test/fork_branch_id_unit.test.ts b/cli/test/fork_branch_id_unit.test.ts new file mode 100644 index 0000000000..a1555d22fe --- /dev/null +++ b/cli/test/fork_branch_id_unit.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { + branchToForkId, + validateForkWorkspaceId, +} from "../src/commands/workspace/fork.ts"; + +describe("branchToForkId — branch name → fork workspace id slug", () => { + test.each<[string, string]>([ + ["feature-x", "feature-x"], + ["my_feature", "my_feature"], + // `/` (common in branch names) must not survive — it would break the + // wm-fork// branch-name parsing. + ["feat/foo", "feat-foo"], + ["feature/JIRA-123", "feature-JIRA-123"], + ["a/b/c", "a-b-c"], + // Other invalid characters collapse to a single dash. + ["hot fix!", "hot-fix"], + ["weird@@name", "weird-name"], + // Leading/trailing separators are trimmed. + ["/leading", "leading"], + ["trailing/", "trailing"], + ["--dashes--", "dashes"], + // Degenerate input falls back to a usable id. + ["///", "fork"], + ["", "fork"], + ])("%p → %p", (branch, expected) => { + expect(branchToForkId(branch)).toBe(expected); + }); + + test("result never contains a slash (would break fork branch parsing)", () => { + for (const branch of ["a/b", "x/y/z", "feat/foo/bar"]) { + expect(branchToForkId(branch)).not.toContain("/"); + } + }); + + test("caps the slug so wm-fork- stays within the backend's 50-char limit", () => { + const long = "feature/TICKET-1234-" + "a".repeat(80); + const slug = branchToForkId(long); + expect(slug.length).toBeLessThanOrEqual(42); + // The full id the backend validates is `wm-fork-`. + expect(`wm-fork-${slug}`.length).toBeLessThanOrEqual(50); + // Truncation must not leave a trailing dash. + expect(slug.endsWith("-")).toBe(false); + }); +}); + +describe("validateForkWorkspaceId — mirrors backend validate_fork_workspace_id", () => { + test("accepts a normal slugged id", () => { + expect(() => validateForkWorkspaceId("wm-fork-feature-x")).not.toThrow(); + }); + + test.each<[string, string]>([ + ["too long (> 50 chars)", "wm-fork-" + "a".repeat(60)], + ["ends with '.'", "wm-fork-foo."], + ["ends with '.lock'", "wm-fork-foo.lock"], + ["contains '..'", "wm-fork-foo..bar"], + ["contains '//'", "wm-fork-foo//bar"], + ["contains a space", "wm-fork-foo bar"], + ["contains a forbidden char", "wm-fork-foo~bar"], + ])("rejects: %s", (_label, id) => { + expect(() => validateForkWorkspaceId(id)).toThrow(); + }); + + test("a branchToForkId slug always passes validation (with the prefix)", () => { + for (const branch of [ + "feat/foo", + "feature/TICKET-1234-" + "a".repeat(80), + "weird@@name", + "///", + ]) { + const id = `wm-fork-${branchToForkId(branch)}`; + expect(() => validateForkWorkspaceId(id)).not.toThrow(); + } + }); +}); diff --git a/cli/test/git_unit.test.ts b/cli/test/git_unit.test.ts index ebbc838f97..eb4772c6ee 100644 --- a/cli/test/git_unit.test.ts +++ b/cli/test/git_unit.test.ts @@ -219,8 +219,10 @@ describe("composeGitSyncCommitHeader", () => { // ============================================================================= describe("gitSyncIncludePattern", () => { - test("script falls through to .*", () => { - expect(gitSyncIncludePattern("script", "f/foo/bar")).toBe("f/foo/bar.*"); + test("script includes __mod/ folder for module scripts", () => { + expect(gitSyncIncludePattern("script", "f/foo/bar")).toBe( + "f/foo/bar.*,f/foo/bar__mod/**" + ); }); test("flow/app expand to dotted + __ folder patterns", () => { expect(gitSyncIncludePattern("flow", "f/x")).toBe("f/x.flow/*,f/x__flow/*"); diff --git a/cli/test/gitsync_promotion.test.ts b/cli/test/gitsync_promotion.test.ts index e33968d0fa..3d362442ac 100644 --- a/cli/test/gitsync_promotion.test.ts +++ b/cli/test/gitsync_promotion.test.ts @@ -576,6 +576,177 @@ test.skipIf(shouldSkipOnCI())( }, ); +/** + * Regression test for WIN-2052: a script with companion modules + * (workflows-as-code) is stored on disk under a `__mod/` folder + * (`__mod/script.ts`, `__mod/script.yaml`, `__mod/`) + * — NOT under the dotted `.script.*` layout a module-less script uses. + * + * Root cause: `gitSyncIncludePattern`'s script (default) case returned only + * `.*`, which the `ignoreF` extra-includes filter (minimatch) does NOT + * match against any `__mod/` file (literal dot, no dot after the name). So the + * pull filtered out every module file, the wm_deploy branch was created with + * nothing staged, and production's `git add '**'` failed with "pathspec + * did not match any files". The fix adds `__mod/**` to the pattern. + * + * The nested `utils/math.ts` module guards the `**` (vs `*`) choice: a single + * star would not match files in module subdirectories. + * + * Without the fix this test fails: the branch exists but the `__mod/` files + * (entry point + modules) are absent from it. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync promotion: use_individual_branch lands a module script's __mod/ files on the wm_deploy branch", + async () => { + await withTestBackend(async (backend) => { + const ws = backend.workspace; // "test" + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: ws, + name: ws, + token: backend.token, + } as any, + { force: true, configDir: backend.testConfigDir }, + ); + + // --- 1. Bare "remote" seeded with an initial `main` commit --- + const bareDir = await mkdtemp(join(tmpdir(), "wmill_promo_mod_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_promo_mod_seed_")); + git(seedDir, "init", "--initial-branch=main"); + git(seedDir, "config", "user.email", "seed@windmill.dev"); + git(seedDir, "config", "user.name", "seed"); + await writeFile(join(seedDir, "README.md"), "# promo module test\n"); + git(seedDir, "add", "-A"); + git(seedDir, "commit", "-m", "seed"); + git(seedDir, "remote", "add", "origin", `file://${bareDir}`); + git(seedDir, "push", "-u", "origin", "main"); + const seedMain = remoteHead(bareDir, "main"); + + // --- 2. Workspace content: a script WITH companion modules (one flat, + // one nested) — this is what triggers the `__mod/` folder layout. --- + await backend.apiRequest!(`/api/w/${ws}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "promo", owners: [], extra_perms: {} }), + }); + const scriptRes = await backend.apiRequest!(`/api/w/${ws}/scripts/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/mod_script", + summary: "", + description: "", + content: "export async function main() { return 1 }", + language: "bun", + modules: { + "helper.ts": { + content: "export function help() { return 2 }\n", + language: "bun", + }, + "utils/math.ts": { + content: "export function add(a: number, b: number) { return a + b }\n", + language: "bun", + }, + }, + }), + }); + expect(scriptRes.status).toBe(201); + + // --- 3. git_repository resource + git-sync config (scripts included) --- + await backend.apiRequest!(`/api/w/${ws}/resources/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "u/test/promo_repo", + resource_type: "git_repository", + value: { url: `file://${bareDir}`, branch: "main", token: "" }, + }), + }); + await backend.updateGitSyncConfig!({ + git_sync_settings: { + repositories: [ + { + git_repo_resource_path: "u/test/promo_repo", + script_path: "f/**", + use_individual_branch: true, + group_by_folder: false, + settings: { + include_path: ["f/**"], + include_type: ["script"], + }, + }, + ], + }, + }); + + // A script always has path_type "script" — modules are a property of the + // script, not a distinct object kind — so this hits gitSyncIncludePattern's + // default case, the one the fix amends. + const deployItems = JSON.stringify([ + { + path_type: "script", + path: "f/promo/mod_script", + commit_msg: "deploy mod_script", + }, + ]); + + const work = await mkdtemp(join(tmpdir(), "wmill_promo_mod_work_")); + git(work, "clone", `file://${bareDir}`, "."); + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\n", + ); + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/promo_repo", + "--use-individual-branch", + "--git-deploy-items", + deployItems, + ], + work, + ); + expect(res.code).toBe(0); + + // Caller-half (mirrors the hub script): stage what the pull wrote, commit + // on the checked-out wm_deploy branch, push. + git(work, "config", "user.email", "test@windmill.dev"); + git(work, "config", "user.name", "test"); + git(work, "add", "-A"); + try { + git(work, "diff", "--cached", "--quiet"); + } catch { + git(work, "commit", "-m", "deploy mod_script"); + } + git(work, "push", "--porcelain", "-u", "origin", "HEAD"); + + const branch = `wm_deploy/${ws}/script/f__promo__mod_script`; + expect(remoteBranches(bareDir)).toContain(`refs/heads/${branch}`); + // The regression: every `__mod/` file MUST be present on the branch — the + // entry point, the flat module, and the nested module. Without the fix the + // extra-includes pattern matches none of them, the pull writes nothing, and + // these files are absent (and `git add` would have failed in production). + for (const f of [ + "f/promo/mod_script__mod/script.ts", + "f/promo/mod_script__mod/helper.ts", + "f/promo/mod_script__mod/utils/math.ts", + ]) { + expect(fileExistsOnBranch(bareDir, branch, f)).toBe(true); + } + // Base branch untouched (individual-branch never pushes to the base). + expect(remoteHead(bareDir, "main")).toBe(seedMain); + + await rm(bareDir, { recursive: true, force: true }); + await rm(seedDir, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + }, +); + /** * Regression test for WIN-1997: forking a workspace with git sync configured * must publish a `wm-fork//` branch to the remote. diff --git a/cli/test/guidance_writer_unit.test.ts b/cli/test/guidance_writer_unit.test.ts index cbeb494d1c..4ee068c83c 100644 --- a/cli/test/guidance_writer_unit.test.ts +++ b/cli/test/guidance_writer_unit.test.ts @@ -143,7 +143,7 @@ Copied from source bundle. }); }); - test("AGENTS.cli.md gets the skills reference from copied directory names", async () => { + test("AGENTS.wmill.md gets the skills reference from copied directory names", async () => { await withTempDir(async (tempDir) => { const sourceSkillsDir = join(tempDir, "source-skills"); await writeSkill( @@ -163,7 +163,7 @@ Copied from source bundle. skillsSourcePath: sourceSkillsDir, }); - const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8"); expect(agentsCli).toContain(".agents/skills/custom-folder/SKILL.md"); expect(agentsCli).not.toContain(".agents/skills/write-flow/SKILL.md"); // The skill reference points at the .agents/ tree — not .claude/ — @@ -172,7 +172,7 @@ Copied from source bundle. }); }); - test("AGENTS.cli.md and CLAUDE.md are written even if skills creation fails", async () => { + test("AGENTS.wmill.md and CLAUDE.md are written even if skills creation fails", async () => { await withTempDir(async (tempDir) => { // Create a file at .claude so mkdir of .claude/skills throws. await writeFile(join(tempDir, ".claude"), "not a directory\n", "utf8"); @@ -181,11 +181,11 @@ Copied from source bundle. writeAiGuidanceFiles({ targetDir: tempDir }) ).rejects.toThrow(); - expect(await readFile(join(tempDir, "AGENTS.cli.md"), "utf8")).toContain( + expect(await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8")).toContain( ".agents/skills/" ); expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain( - "@AGENTS.cli.md" + "@AGENTS.wmill.md" ); expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain( "@AGENTS.md" @@ -195,20 +195,20 @@ Copied from source bundle. }); describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => { - test("creates a skeleton AGENTS.md (with @AGENTS.cli.md include) when none exists", async () => { + test("creates a skeleton AGENTS.md (with @AGENTS.wmill.md include) when none exists", async () => { await withTempDir(async (tempDir) => { const result = await writeAiGuidanceFiles({ targetDir: tempDir }); expect(result.agentsCreated).toBe(true); expect(result.agentsMigration).toBe("not-applicable"); const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8"); - expect(agentsMd).toContain("@AGENTS.cli.md"); + expect(agentsMd).toContain("@AGENTS.wmill.md"); }); }); - test("leaves an existing AGENTS.md alone when it already references @AGENTS.cli.md", async () => { + test("leaves an existing AGENTS.md alone when it already references @AGENTS.wmill.md", async () => { await withTempDir(async (tempDir) => { - const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n"; + const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.wmill.md\n"; await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); const result = await writeAiGuidanceFiles({ targetDir: tempDir }); @@ -219,7 +219,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => { }); }); - test("appends @AGENTS.cli.md when the resolver returns 'append'", async () => { + test("appends @AGENTS.wmill.md when the resolver returns 'append'", async () => { await withTempDir(async (tempDir) => { const original = "# Existing custom AGENTS.md\n\nproject rules here.\n"; await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); @@ -233,7 +233,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => { const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); expect(updated).toStartWith(original); - expect(updated).toContain("@AGENTS.cli.md"); + expect(updated).toContain("@AGENTS.wmill.md"); }); }); @@ -251,7 +251,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => { const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); expect(updated).not.toBe(original); - expect(updated).toContain("@AGENTS.cli.md"); + expect(updated).toContain("@AGENTS.wmill.md"); }); }); @@ -281,7 +281,7 @@ describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => { const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); expect(updated).toStartWith(original); - expect(updated).toContain("@AGENTS.cli.md"); + expect(updated).toContain("@AGENTS.wmill.md"); }); }); }); @@ -385,19 +385,78 @@ describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => { }); }); +describe("writeAiGuidanceFiles — legacy AGENTS.cli.md migration", () => { + test("removes a legacy AGENTS.cli.md and rewrites the @AGENTS.cli.md include", async () => { + await withTempDir(async (tempDir) => { + // Simulate a project initialized by an older CLI. + await writeFile( + join(tempDir, "AGENTS.cli.md"), + "# old managed file\n", + "utf8" + ); + await writeFile( + join(tempDir, "AGENTS.md"), + "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n", + "utf8" + ); + + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + + // Legacy file is gone; the new managed file is present. + expect(result.legacyManagedRemoved).toBe(true); + await expect( + readFile(join(tempDir, "AGENTS.cli.md"), "utf8") + ).rejects.toThrow(); + expect( + await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8") + ).toContain(".agents/skills/"); + + // The include was rewritten in place (so it reads as already-linked, + // not a duplicate append). + const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(agentsMd).toContain("@AGENTS.wmill.md"); + expect(agentsMd).not.toContain("@AGENTS.cli.md"); + expect(result.agentsMigration).toBe("already-linked"); + }); + }); + + test("legacyManagedRemoved is false when there is no legacy file", async () => { + await withTempDir(async (tempDir) => { + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.legacyManagedRemoved).toBe(false); + }); + }); + + test("does not rewrite a @AGENTS.cli.md.backup lookalike token", async () => { + await withTempDir(async (tempDir) => { + await writeFile( + join(tempDir, "AGENTS.md"), + "see @AGENTS.cli.md.backup\n\n@AGENTS.wmill.md\n", + "utf8" + ); + + await writeAiGuidanceFiles({ targetDir: tempDir }); + + const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + // The standalone backup reference (a different file) is preserved. + expect(agentsMd).toContain("@AGENTS.cli.md.backup"); + }); + }); +}); + describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () => { test.each([ - ["bare line", "@AGENTS.cli.md"], - ["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"], - ["leading whitespace then include", " @AGENTS.cli.md\n"], - ["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"], + ["bare line", "@AGENTS.wmill.md"], + ["between blank lines", "before\n\n@AGENTS.wmill.md\n\nafter"], + ["leading whitespace then include", " @AGENTS.wmill.md\n"], + ["CRLF line endings", "line one\r\n@AGENTS.wmill.md\r\nline three"], // Mid-sentence include: this is how our own CLAUDE.md default looks // ("Instructions are in @AGENTS.md"). A strict line-equality check made // `wmill refresh prompts` re-prompt every run on files wmill wrote. - ["mid-sentence include", "Instructions are in @AGENTS.cli.md\n"], + ["mid-sentence include", "Instructions are in @AGENTS.wmill.md\n"], // `>` blockquote prefix doesn't disable Claude's `@`-import expansion, // so we treat it as a reference too. - ["blockquoted include", "> @AGENTS.cli.md"], + ["blockquoted include", "> @AGENTS.wmill.md"], ])("treats %s as a reference (no append)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); @@ -408,11 +467,11 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () }); test.each([ - ["@AGENTS.cli.md.backup", "@AGENTS.cli.md.backup"], - ["@AGENTS.cli.mdx", "@AGENTS.cli.mdx"], + ["@AGENTS.wmill.md.backup", "@AGENTS.wmill.md.backup"], + ["@AGENTS.wmill.mdx", "@AGENTS.wmill.mdx"], ["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"], - ["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"], - ["commented-out include", ""], + ["@AGENTS.wmill.md without surrounding whitespace", "foo@AGENTS.wmill.md"], + ["commented-out include", ""], ])("does not treat %s as a reference (append happens)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); @@ -426,10 +485,10 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () }); describe("prompts freshness — hash marker", () => { - test("AGENTS.cli.md written by writeAiGuidanceFiles carries a hash marker", async () => { + test("AGENTS.wmill.md written by writeAiGuidanceFiles carries a hash marker", async () => { await withTempDir(async (tempDir) => { await writeAiGuidanceFiles({ targetDir: tempDir }); - const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8"); const hash = extractPromptsHash(agentsCli); expect(hash).not.toBeNull(); expect(hash).toMatch(/^[0-9a-f]{12}$/); @@ -441,7 +500,7 @@ describe("prompts freshness — hash marker", () => { // writeAiGuidanceFiles defaults nonDottedPaths to `false` (matching // core/conf.ts's missing-key default). await writeAiGuidanceFiles({ targetDir: tempDir }); - const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8"); expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false)); }); }); @@ -516,9 +575,9 @@ describe("prompts freshness — additional invariants", () => { test("warnIfPromptsStale writes to stderr (never stdout)", async () => { await withTempDir(async (tempDir) => { - // Write a tampered AGENTS.cli.md so the freshness check trips. + // Write a tampered AGENTS.wmill.md so the freshness check trips. await writeFile( - join(tempDir, "AGENTS.cli.md"), + join(tempDir, "AGENTS.wmill.md"), "# Windmill CLI Agent Instructions\n\nbody\n", "utf8" ); @@ -555,4 +614,64 @@ describe("prompts freshness — additional invariants", () => { expect(stdoutJoined).not.toContain("out of date"); }); }); + + // Back-compat: a legacy AGENTS.cli.md is still hash-checked, and we do NOT + // warn merely because of the old filename — only when it's actually stale. + test("warnIfPromptsStale stays silent for an up-to-date legacy AGENTS.cli.md", async () => { + await withTempDir(async (tempDir) => { + const content = injectPromptsHashMarker( + "# Windmill CLI Agent Instructions\nbody\n", + currentPromptsHash(false) + ); + await writeFile(join(tempDir, "AGENTS.cli.md"), content, "utf8"); + + const stderrWrites: string[] = []; + const originalStderr = process.stderr.write.bind(process.stderr); + // @ts-expect-error — overriding write for the test + process.stderr.write = (chunk: any) => { + stderrWrites.push(String(chunk)); + return true; + }; + try { + await warnIfPromptsStale({ + cwd: tempDir, + nonDottedPaths: false, + argv: ["node", "wmill", "sync", "push"], + }); + } finally { + process.stderr.write = originalStderr; + } + expect(stderrWrites.join("")).toBe(""); + }); + }); + + test("warnIfPromptsStale warns (naming the legacy file) for a stale AGENTS.cli.md", async () => { + await withTempDir(async (tempDir) => { + await writeFile( + join(tempDir, "AGENTS.cli.md"), + "# Windmill CLI Agent Instructions\n\nbody\n", + "utf8" + ); + + const stderrWrites: string[] = []; + const originalStderr = process.stderr.write.bind(process.stderr); + // @ts-expect-error — overriding write for the test + process.stderr.write = (chunk: any) => { + stderrWrites.push(String(chunk)); + return true; + }; + try { + await warnIfPromptsStale({ + cwd: tempDir, + nonDottedPaths: false, + argv: ["node", "wmill", "sync", "push"], + }); + } finally { + process.stderr.write = originalStderr; + } + const out = stderrWrites.join(""); + expect(out).toContain("out of date"); + expect(out).toContain("AGENTS.cli.md"); + }); + }); }); diff --git a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts index a5d1298f15..136fc2c413 100644 --- a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts +++ b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts @@ -8,7 +8,7 @@ */ import { expect, test, describe } from "bun:test"; -import { extractInlineScripts, extractCurrentMapping } from "../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { extractInlineScripts, extractCurrentMapping, legacyLockPathForContent } from "../windmill-utils-internal/src/inline-scripts/extractor.ts"; import { replaceInlineScripts } from "../windmill-utils-internal/src/inline-scripts/replacer.ts"; import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts"; @@ -573,6 +573,69 @@ describe("extractInlineScripts with mapping preserves file paths", () => { const lockScript = scripts.find((s) => s.is_lock); expect(lockScript!.path).toBe("my.inline_script.lock"); }); + + test("lock path strips compound language extension from mapped path (deno)", () => { + const mod = makeRawscriptModule("a", "code", "deno", "lock-content"); + + const mapping = { a: "my.inline_script.deno.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("my.inline_script.lock"); + expect((mod.value as any).lock).toBe("!inline my.inline_script.lock"); + }); + + test("deno lock name is identical with and without mapping", () => { + const summary = "My Step"; + const makeMod = () => { + const m = makeRawscriptModule("a", "code", "deno", "lock-content"); + m.summary = summary; + return m; + }; + + const unmapped = extractInlineScripts([makeMod()], {}, "/", "bun"); + const contentPath = unmapped.find((s) => !s.is_lock)!.path; + const unmappedLock = unmapped.find((s) => s.is_lock)!.path; + + // Re-extract with the content path mapped (as flow generate-locks does) + const mapped = extractInlineScripts([makeMod()], { a: contentPath }, "/", "bun"); + const mappedLock = mapped.find((s) => s.is_lock)!.path; + + expect(contentPath).toBe("my_step.inline_script.deno.ts"); + expect(mappedLock).toBe(unmappedLock); + expect(mappedLock).toBe("my_step.inline_script.lock"); + }); + + test("collapsed ts extension (language == defaultTs) still strips correctly", () => { + const mod = makeRawscriptModule("a", "code", "deno", "lock-content"); + + // defaultTs=deno: content file uses plain .ts + const mapping = { a: "my.inline_script.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "deno"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("my.inline_script.lock"); + }); +}); + +// --------------------------------------------------------------------------- +// legacyLockPathForContent — migration helper for pre-fix lock names +// --------------------------------------------------------------------------- + +describe("legacyLockPathForContent", () => { + test("returns the last-segment-stripped name for compound extensions", () => { + expect(legacyLockPathForContent("my.inline_script.deno.ts", "deno")).toBe( + "my.inline_script.deno.lock", + ); + expect(legacyLockPathForContent("step.inline_script.pg.sql", "postgresql")).toBe( + "step.inline_script.pg.lock", + ); + }); + + test("returns undefined when legacy and canonical names coincide", () => { + expect(legacyLockPathForContent("my.inline_script.ts", "deno")).toBeUndefined(); + expect(legacyLockPathForContent("my.inline_script.py", "python3")).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/cli/test/lock_cache_unit.test.ts b/cli/test/lock_cache_unit.test.ts index 6225087540..4a48c858f1 100644 --- a/cli/test/lock_cache_unit.test.ts +++ b/cli/test/lock_cache_unit.test.ts @@ -110,10 +110,77 @@ function extractWorkspaceDepsAnnotation( return { mode, external, inline }; } +const LANG_COMMENT_LIT: Record = { + python3: "#", + ansible: "#", + powershell: "#", + bun: "//", + nativets: "//", + deno: "//", + go: "//", + php: "//", + rust: "//!", +}; + +function extractLockRelevantHeader(scriptContent: string, language: string): string { + const comment = LANG_COMMENT_LIT[language]; + if (!comment) return scriptContent; + const headerLines: string[] = []; + for (const line of scriptContent.split("\n")) { + if (line.trim() === "" || line.startsWith(comment)) { + headerLines.push(line); + } else { + break; + } + } + return headerLines.join("\n"); +} + +const workspaceDependenciesLanguages: { language: string; filename: string }[] = [ + { language: "bun", filename: "package.json" }, + { language: "python3", filename: "requirements.in" }, + { language: "php", filename: "composer.json" }, + { language: "go", filename: "go.mod" }, + { language: "powershell", filename: "modules.json" }, +]; + +function workspaceDependenciesPathToLanguageAndFilename( + path: string, +): { name: string | undefined; language: string } | undefined { + const relativePath = path.replace("dependencies/", ""); + for (const { filename, language } of workspaceDependenciesLanguages) { + if (relativePath.endsWith(filename)) { + return { + name: relativePath === filename ? undefined : relativePath.replace("." + filename, ""), + language, + }; + } + } +} + +function filterWorkspaceDependencies( + rawWorkspaceDependencies: Record, + scriptContent: string, + language: string, +): Record { + const wda = extractWorkspaceDepsAnnotation(scriptContent, language); + const filtered: Record = {}; + for (const [depPath, depContent] of Object.entries(rawWorkspaceDependencies)) { + const depInfo = workspaceDependenciesPathToLanguageAndFilename(depPath); + if (depInfo && depInfo.language === language) { + if ((wda && wda.external.includes(depInfo.name ?? "default")) || (wda == null && depInfo.name == undefined)) { + filtered[depPath] = depContent; + } + } + } + return filtered; +} + async function computeLockCacheKey( scriptContent: string, language: string, rawWorkspaceDependencies: Record, + tempScriptRefs?: Record, ): Promise { const annotation = extractWorkspaceDepsAnnotation(scriptContent, language); const annotationStr = annotation @@ -123,7 +190,18 @@ async function computeLockCacheKey( const depsStr = sortedDepsKeys .map((k) => `${k}=${rawWorkspaceDependencies[k]}`) .join(";"); - const content = `${language}|${annotationStr}|${depsStr}`; + const tempRefsStr = tempScriptRefs + ? Object.keys(tempScriptRefs).sort().map((k) => `${k}=${tempScriptRefs[k]}`).join(";") + : ""; + const isManualWorkspaceDeps = annotation + ? annotation.mode === "manual" + : Object.keys( + filterWorkspaceDependencies(rawWorkspaceDependencies, scriptContent, language), + ).length > 0; + const contentStr = isManualWorkspaceDeps + ? extractLockRelevantHeader(scriptContent, language) + : scriptContent; + const content = `${language}|${annotationStr}|${depsStr}|${tempRefsStr}|${contentStr}`; const buf = new TextEncoder().encode(content); return Buffer.from(await crypto.subtle.digest("SHA-256", buf)).toString("hex"); } @@ -339,6 +417,148 @@ test("dep key order does not matter", async () => { expect(await computeLockCacheKey(code, "python3", { a: "1", b: "2" })).toEqual(await computeLockCacheKey(code, "python3", { b: "2", a: "1" })); }); +// -- Lock-relevant header (GIT-891 regression) ------------------------------- +// Annotations in the leading comment block (python version pins, //npm, ...) +// change the generated lockfile even when workspace deps are identical, so +// they must be part of the cache key. + +test("header extraction: leading comments + blanks, stops at code", () => { + const code = `# py311 + +# no_cache +import pandas +# trailing comment`; + expect(extractLockRelevantHeader(code, "python3")).toEqual("# py311\n\n# no_cache"); +}); + +test("header extraction: no leading comments → empty header", () => { + expect(extractLockRelevantHeader("import pandas\n# py311", "python3")).toEqual(""); +}); + +test("python: different py version annotation, same deps → different key", async () => { + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const codeA = `# py310 +# requirements: default +print("hello")`; + const codeB = `# requirements: default +print("hello")`; + expect(await computeLockCacheKey(codeA, "python3", deps)).not.toEqual( + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + +test("python: different '# py:' specifier, same deps → different key", async () => { + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const codeA = `# py: >=3.12 +print("hello")`; + const codeB = `# py: >=3.10 +print("hello")`; + expect(await computeLockCacheKey(codeA, "python3", deps)).not.toEqual( + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + +test("bun: //npm annotation, same deps → different key", async () => { + const deps = { "dependencies/package.json": '{"dependencies":{"axios":"^1.6.0"}}' }; + const codeA = `//npm +export function main() {}`; + const codeB = `export function main() {}`; + expect(await computeLockCacheKey(codeA, "bun", deps)).not.toEqual( + await computeLockCacheKey(codeB, "bun", deps), + ); +}); + +test("same header, different body → same key (cache still shared)", async () => { + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const codeA = `# py311 +# requirements: default +print("hello")`; + const codeB = `# py311 +# requirements: default +print("world")`; + expect(await computeLockCacheKey(codeA, "python3", deps)).toEqual( + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + +// -- tempScriptRefs: full content is part of the key ------------------------- +// With tempScriptRefs the backend resolves the script's own imports, so two +// scripts with different bodies must not share a lock. + +test("tempScriptRefs: different body, same refs → different key", async () => { + const refs = { "f/lib/util": "abc123" }; + const codeA = `import { a } from "./util.ts"\nexport function main() {}`; + const codeB = `import axios from "axios"\nexport function main() {}`; + expect(await computeLockCacheKey(codeA, "bun", {}, refs)).not.toEqual( + await computeLockCacheKey(codeB, "bun", {}, refs), + ); +}); + +test("tempScriptRefs: same content + refs → same key", async () => { + const refs = { "f/lib/util": "abc123" }; + const code = `import { a } from "./util.ts"\nexport function main() {}`; + expect(await computeLockCacheKey(code, "bun", {}, refs)).toEqual( + await computeLockCacheKey(code, "bun", {}, refs), + ); +}); + +test("tempScriptRefs: different ref hash → different key", async () => { + const code = `import { a } from "./util.ts"\nexport function main() {}`; + expect(await computeLockCacheKey(code, "bun", {}, { "f/lib/util": "abc123" })).not.toEqual( + await computeLockCacheKey(code, "bun", {}, { "f/lib/util": "def456" }), + ); +}); + +test("matching deps + tempScriptRefs: same header, different body → same key", async () => { + // Manual workspace-deps mode ignores the script body (and relative imports), + // so the cache can still be shared across scripts. + const deps = { "dependencies/package.json": '{"dependencies":{"axios":"^1.6.0"}}' }; + const refs = { "dependencies/package.json": "abc123" }; + const codeA = `import axios from "axios"\nexport function main() { return 1 }`; + const codeB = `import axios from "axios"\nexport function main() { return 2 }`; + expect(await computeLockCacheKey(codeA, "bun", deps, refs)).toEqual( + await computeLockCacheKey(codeB, "bun", deps, refs), + ); +}); + +// -- Non-matching deps: the backend scans the script content ----------------- +// updateModuleLocks passes the unfiltered workspace deps map, so e.g. a deno +// module reaches the cache with a python requirements.in in the map. The +// backend ignores those deps and locks from the script's own imports, so the +// content must be in the key. + +test("deno + non-matching deps: different content → different key", async () => { + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const codeA = `import { x } from "npm:left-pad"\nexport function main() {}`; + const codeB = `import { y } from "npm:axios"\nexport function main() {}`; + expect(await computeLockCacheKey(codeA, "deno", deps)).not.toEqual( + await computeLockCacheKey(codeB, "deno", deps), + ); +}); + +test("bun + only python deps in map: different content → different key", async () => { + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const codeA = `import leftPad from "left-pad"\nexport function main() {}`; + const codeB = `import axios from "axios"\nexport function main() {}`; + expect(await computeLockCacheKey(codeA, "bun", deps)).not.toEqual( + await computeLockCacheKey(codeB, "bun", deps), + ); +}); + +test("python extra mode with external ref: different imports → different key", async () => { + // Extra mode appends the script's scanned imports to the workspace deps. + const deps = { "dependencies/utils.requirements.in": "rich==13.0.0" }; + const codeA = `# extra_requirements: utils +import pandas +def main(): pass`; + const codeB = `# extra_requirements: utils +import numpy +def main(): pass`; + expect(await computeLockCacheKey(codeA, "python3", deps)).not.toEqual( + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + // ============================================================================= // Part 3 — Multi-script fetch behavior: old logic vs new logic // ============================================================================= diff --git a/cli/test/mixed_case_paths.test.ts b/cli/test/mixed_case_paths.test.ts index 872c6f94ff..47b1e4e272 100644 --- a/cli/test/mixed_case_paths.test.ts +++ b/cli/test/mixed_case_paths.test.ts @@ -14,7 +14,7 @@ import { expect, test } from "bun:test"; import * as path from "node:path"; -import { writeFile, readFile, stat } from "node:fs/promises"; +import { writeFile, readFile, stat, rename } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; @@ -686,6 +686,173 @@ excludes: [] }); }); +// The core WIN-2020 reproduction. The real failure is NOT a user authoring +// both f/Caps and f/caps — it is a SINGLE capitalized folder whose on-disk +// casing drifts on a case-insensitive filesystem (Windows stores/reports the +// case the directory was first created with). The diff then sees a brand-new +// lowercase path plus a destructive delete of the real one. +// +// This test runs on BOTH the Linux and Windows CI jobs: +// - On the Windows runner (real case-insensitive NTFS) the CLI auto-detects +// case-insensitivity via its filesystem probe — no override, real behavior. +// - On Linux (case-sensitive) we reproduce the drift with an explicit rename +// and force the same code path with WMILL_CASE_INSENSITIVE_FS=true, and we +// additionally assert that WITHOUT the fix the destructive phantom appears +// (which can only be observed on a case-sensitive FS). +test("Mixed Case Paths: case-only folder drift reconciles to server casing (WIN-2020)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await createFolder(backend, "Caps"); + await createScript( + backend, + "f/Caps/MyScript", + 'export async function main() { return "drift repro"; }', + "Drift Script" + ); + + await writeFile( + path.join(tempDir, "wmill.yaml"), + `defaultTs: bun +includes: + - "**" +excludes: [] +`, + "utf-8" + ); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + const upperDir = path.join(tempDir, "f", "Caps"); + const lowerDir = path.join(tempDir, "f", "caps"); + const pulledExists = await stat(path.join(upperDir, "MyScript.ts")) + .then(() => true) + .catch(() => false); + expect(pulledExists).toBeTruthy(); + + // Simulate the on-disk casing drift (a no-op-in-spirit case-only rename). + // On Windows this is a real case-only rename of the same directory; on + // Linux it produces a genuinely lowercase sibling. + await rename(upperDir, lowerDir); + + const onWindows = process.platform === "win32"; + + // Without case-insensitive handling, the drift is a destructive + // delete(f/Caps) + add(f/caps) phantom. Only observable on a + // case-sensitive FS (Linux), where the override defaults off. + if (!onWindows) { + const buggy = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(buggy.code).toEqual(0); + const buggyChanges = parseJsonFromCLIOutput(buggy.stdout).changes || []; + const hasDelete = buggyChanges.some( + (c: any) => c.type === "deleted" && c.path.replace(/\\/g, "/").startsWith("f/Caps/") + ); + const hasAdd = buggyChanges.some( + (c: any) => c.type === "added" && c.path.replace(/\\/g, "/").startsWith("f/caps/") + ); + expect(hasDelete).toBeTruthy(); + expect(hasAdd).toBeTruthy(); + } + + // With case-insensitive handling in effect (auto on Windows via the FS + // probe, forced via env on Linux) the drifted local casing is reconciled + // to the server's casing, so the push is a clean no-op. + if (!onWindows) process.env.WMILL_CASE_INSENSITIVE_FS = "true"; + try { + const fixed = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(fixed.code).toEqual(0); + const fixedChanges = parseJsonFromCLIOutput(fixed.stdout).changes || []; + if (fixedChanges.length !== 0) { + console.error( + "Expected no changes after reconciliation, got:", + JSON.stringify(fixedChanges, null, 2) + ); + } + expect(fixedChanges.length).toEqual(0); + + // P1 regression: a brand-new item added under the drifted (lowercase) + // folder must be pushed under the server's folder casing (f/Caps), not + // recreate the case-only collision as f/caps. A self-contained variable + // YAML is used so the assertion targets path canonicalization without + // dragging in script lock/metadata generation. + await writeFile( + path.join(lowerDir, "NewVar.variable.yaml"), + `value: hello\nis_secret: false\ndescription: new under drifted folder\nis_oauth: false\n`, + "utf-8" + ); + const added = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(added.code).toEqual(0); + const addedChanges = parseJsonFromCLIOutput(added.stdout).changes || []; + const addedPaths = addedChanges.map((c: any) => + c.path.replace(/\\/g, "/") + ); + expect( + addedPaths.some((p: string) => p === "f/Caps/NewVar.variable.yaml") + ).toBeTruthy(); + expect( + addedPaths.some((p: string) => p.startsWith("f/caps/")) + ).toBeFalsy(); + } finally { + if (!onWindows) delete process.env.WMILL_CASE_INSENSITIVE_FS; + } + }); +}); + +// A genuine, unrepresentable server-side collision: two DISTINCT server folders +// that differ only by case (f/Caps and f/caps). These cannot both exist on a +// case-insensitive filesystem, so the CLI must warn. Skipped on Windows, where +// the pull physically cannot lay both folders down; the warning string itself +// is exercised platform-independently by the unit tests. +const collisionTest = process.platform === "win32" ? test.skip : test; +collisionTest("Mixed Case Paths: distinct server folders differing only by case warn", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await createFolder(backend, "Caps"); + await createFolder(backend, "caps"); + await createScript( + backend, + "f/Caps/upper", + 'export async function main() { return "upper"; }', + "Upper" + ); + await createScript( + backend, + "f/caps/lower", + 'export async function main() { return "lower"; }', + "Lower" + ); + + await writeFile( + path.join(tempDir, "wmill.yaml"), + `defaultTs: bun +includes: + - "**" +excludes: [] +`, + "utf-8" + ); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + const combinedOutput = pullResult.stdout + pullResult.stderr; + expect(combinedOutput.includes("differ only by letter case")).toBeTruthy(); + expect(combinedOutput.includes("f/Caps")).toBeTruthy(); + expect(combinedOutput.includes("f/caps")).toBeTruthy(); + }); +}); + test("Mixed Case Paths: CamelCase folder names with numbers", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/cli/test/pending_script_lock_unit.test.ts b/cli/test/pending_script_lock_unit.test.ts new file mode 100644 index 0000000000..c7ff35f7ad --- /dev/null +++ b/cli/test/pending_script_lock_unit.test.ts @@ -0,0 +1,102 @@ +/** + * Pending-lock preservation (issue #9588) + * + * A git-sync deploy mirror must not delete a committed `.script.lock` when the + * server reports the script's lock as NULL — that NULL is transient (a relock + * is mid-flight), not a real "no dependencies" state. A genuinely lock-free + * script serializes `lock: ''` and must still drop its obsolete lock. + */ + +import { expect, test } from "bun:test"; +import { stringify as yamlStringify } from "yaml"; +import { yamlParseContent } from "../src/utils/yaml.ts"; +import { preservePendingScriptLocks } from "../src/commands/sync/sync.ts"; + +const META = "f/foo/bar.script.yaml"; +const LOCK = "f/foo/bar.script.lock"; +const LOCK_REF = "!inline f/foo/bar.script.lock"; +const LOCK_CONTENT = "//deps\nsome-lock-content\n"; + +function localMaps() { + return { + [META]: yamlStringify({ summary: "bar", lock: LOCK_REF }), + [LOCK]: LOCK_CONTENT, + }; +} + +test("NULL remote lock preserves the committed lock file and metadata line", () => { + // Remote during the race: lock key absent (NULL), no lock file emitted. + const remote: Record = { + [META]: yamlStringify({ summary: "bar" }), + }; + const local = localMaps(); + + preservePendingScriptLocks(remote, local); + + // Lock file is carried onto the remote, so the diff sees no deletion. + expect(remote[LOCK]).toBe(LOCK_CONTENT); + // Metadata regains the inline lock reference, so no metadata edit either. + const parsed = yamlParseContent(META, remote[META]) as Record; + expect(parsed["lock"]).toBe(LOCK_REF); +}); + +test("empty-string remote lock still removes the obsolete committed lock", () => { + // Dependencies genuinely removed: lock is '' (present, computed, no deps). + const remote: Record = { + [META]: yamlStringify({ summary: "bar", lock: "" }), + }; + const local = localMaps(); + + preservePendingScriptLocks(remote, local); + + // Unchanged: no lock file injected, so the diff still deletes the local lock. + expect(remote[LOCK]).toBeUndefined(); + const parsed = yamlParseContent(META, remote[META]) as Record; + expect(parsed["lock"]).toBe(""); +}); + +test("NULL lock without a committed local lock file is a no-op", () => { + const remote: Record = { + [META]: yamlStringify({ summary: "bar" }), + }; + // Local has the metadata but no committed lock file (nothing to preserve). + const local: Record = { + [META]: yamlStringify({ summary: "bar", lock: LOCK_REF }), + }; + + preservePendingScriptLocks(remote, local); + + expect(remote[LOCK]).toBeUndefined(); +}); + +test("NULL remote lock preserves a multi-module (__mod) script lock", () => { + // Multi-module layout: metadata at /script.yaml, lock at /script.lock, + // and the inline reference points into the __mod folder. + const meta = "f/foo/bar__mod/script.yaml"; + const lock = "f/foo/bar__mod/script.lock"; + const lockRef = "!inline f/foo/bar__mod/script.lock"; + const remote: Record = { + [meta]: yamlStringify({ summary: "bar" }), + }; + const local: Record = { + [meta]: yamlStringify({ summary: "bar", lock: lockRef }), + [lock]: LOCK_CONTENT, + }; + + preservePendingScriptLocks(remote, local); + + expect(remote[lock]).toBe(LOCK_CONTENT); + const parsed = yamlParseContent(meta, remote[meta]) as Record; + expect(parsed["lock"]).toBe(lockRef); +}); + +test("deleted remote script does not resurrect its lock", () => { + // Script removed remotely: no remote metadata key at all. + const remote: Record = {}; + const local = localMaps(); + + preservePendingScriptLocks(remote, local); + + expect(remote[LOCK]).toBeUndefined(); + expect(remote[META]).toBeUndefined(); +}); diff --git a/cli/test/variable_resource_push.test.ts b/cli/test/variable_resource_push.test.ts index c348a31e66..e8b0dc9e26 100644 --- a/cli/test/variable_resource_push.test.ts +++ b/cli/test/variable_resource_push.test.ts @@ -133,6 +133,92 @@ describe("variable", () => { }); }); + test("add creates secret by default and preserves fields on update", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const varPath = `f/test/add_var_${uniqueId}`; + + // Create: secret by default, --description sets the description + const createResult = await backend.runCLICommand( + ["variable", "add", "v1", varPath, "--description", "first desc"], + tempDir + ); + expect(createResult.code).toEqual(0); + + let apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + let varData = await apiResp.json(); + expect(varData.is_secret).toBe(true); + expect(varData.description).toBe("first desc"); + + // Update with --yes only: no prompt, is_secret and description preserved + const updateResult = await backend.runCLICommand( + ["variable", "add", "v2", varPath, "--yes"], + tempDir + ); + expect(updateResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(true); + expect(varData.description).toBe("first desc"); + expect(varData.value).toBe("v2"); + + // Update with --no-secret: flips to non-secret + const noSecretResult = await backend.runCLICommand( + ["variable", "add", "v3", varPath, "--yes", "--no-secret"], + tempDir + ); + expect(noSecretResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(false); + expect(varData.value).toBe("v3"); + + // Update the non-secret variable with no secret flags: is_secret must + // stay false (preserved, not re-defaulted to secret) + const preserveResult = await backend.runCLICommand( + ["variable", "add", "v4", varPath, "--yes"], + tempDir + ); + expect(preserveResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(false); + expect(varData.value).toBe("v4"); + + // Explicit --secret flips it back + const secretResult = await backend.runCLICommand( + ["variable", "add", "v5", varPath, "--yes", "--secret"], + tempDir + ); + expect(secretResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(true); + expect(varData.value).toBe("v5"); + }); + }); + test("pull retrieves variables into local files", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index f556b32c57..e29d0167ba 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -1,4 +1,4 @@ -import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner"; +import { LANGUAGE_EXTENSIONS, newPathAssigner, PathAssigner } from "../path-utils/path-assigner"; import { FlowModule, RawScript, ScriptLang } from "../gen/types.gen"; /** @@ -48,9 +48,8 @@ function extractRawscriptInline( if (lock && lock != "") { // Derive lock path base from the mapped content path when available, // so lock files are named consistently with their content files. - const dotIdx = mappedPath ? mappedPath.lastIndexOf('.') : -1; const lockBasePath = mappedPath - ? (dotIdx > 0 ? mappedPath.substring(0, dotIdx + 1) : mappedPath + '.') + ? lockBasePathForContent(mappedPath, language) : basePath; const lockPath = lockBasePath + "lock"; rawscript.lock = "!inline " + lockPath.replaceAll(separator, "/"); @@ -59,6 +58,38 @@ function extractRawscriptInline( return r; } +/** + * Derive the lock path base (including trailing dot) from an inline script's + * content path. The full language extension must be stripped — for compound + * extensions like "deno.ts", stripping only the last dot segment would yield + * "x.inline_script.deno.lock" while the assigner-based branch (no mapping, + * e.g. fresh sync pull) yields "x.inline_script.lock", flip-flopping the lock + * filename between operations. See legacyLockPathForContent for the old name. + */ +function lockBasePathForContent(contentPath: string, language: ScriptLang): string { + const langExt = LANGUAGE_EXTENSIONS[language]; + if (langExt && contentPath.endsWith("." + langExt)) { + return contentPath.substring(0, contentPath.length - langExt.length); + } + // Collapsed "ts" (language == defaultTs) or a custom extension: a single + // dot segment is the whole extension. + const dotIdx = contentPath.lastIndexOf("."); + return dotIdx > 0 ? contentPath.substring(0, dotIdx + 1) : contentPath + "."; +} + +/** + * Lock path that CLI versions between #8561 and this fix produced for a given + * content path (last dot segment stripped, e.g. "x.inline_script.deno.lock"). + * Returns undefined when it matches the canonical name. Callers use this to + * clean up the stale legacy file after writing the canonical one. + */ +export function legacyLockPathForContent(contentPath: string, language: ScriptLang): string | undefined { + const dotIdx = contentPath.lastIndexOf("."); + const legacy = (dotIdx > 0 ? contentPath.substring(0, dotIdx + 1) : contentPath + ".") + "lock"; + const canonical = lockBasePathForContent(contentPath, language) + "lock"; + return legacy === canonical ? undefined : legacy; +} + /** * Options for extractInlineScripts function */ diff --git a/docs/autonomous-mode.md b/docs/autonomous-mode.md index 2f58260274..dea2aa24ee 100644 --- a/docs/autonomous-mode.md +++ b/docs/autonomous-mode.md @@ -73,4 +73,9 @@ In your final summary, provide: - **Terminal recordings** of CLI changes (via asciinema) - Any known limitations or follow-up work needed -Upload images via pastebin (e.g., `curl -F 'file=@screenshot.png' https://0x0.st`) and include the URLs in the PR description or comments. +### Attaching images to a PR + +Embed screenshots, Mermaid diagrams, and asciinema frames by uploading them to the +public `windmill-labs/agent-screenshots-internal` repo and pasting the +`raw.githubusercontent.com` URL into the PR. The upload recipe lives in the `pr` +skill (`.agents/skills/pr/SKILL.md` → "Screenshots"); reuse it for any image type. diff --git a/examples/usecase/ssh-execution-wrapper/README.md b/examples/usecase/ssh-execution-wrapper/README.md new file mode 100644 index 0000000000..61304cc332 --- /dev/null +++ b/examples/usecase/ssh-execution-wrapper/README.md @@ -0,0 +1,254 @@ +SSH execution +============= + +Run a self-contained script on a remote host that Windmill cannot place a worker +on, but can reach over SSH (a jump box / utility node). + +> ⚠️ **Read this first: prefer agent workers.** For almost every "run code in an +> isolated/segmented environment" need, the recommended answer is an **agent +> worker**. See [When to use what](#when-to-use-what) below. + +There are two ways to do this, sharing the same `ssh_target` resource type: + +1. **The `#ssh` directive (recommended, enterprise).** Write a *normal* bash + script and add one line — `#ssh ` — at the top. The worker + reroutes execution to the remote host with **full parity**: typed positional + args in, structured result out, live streamed logs, cancellation, and the + remote exit code fails the job. This is a first-class backend feature; see + [The `#ssh` directive](#the-ssh-directive) below. +2. **The userland wrapper (no license required).** A reusable Windmill script + (`ssh_exec.sh` / `ssh_exec.py`) that you *call*, passing your remote code as a + string argument. No backend changes, no license — but you lose the editor + experience and structured results. Use it when you can't run the enterprise + image. Documented in [The userland wrapper](#the-userland-wrapper). + +What's here +----------- + +| File | Purpose | +| --- | --- | +| `ssh_target.resource-type.json` | Resource type: `host`, `port`, `user`, `private_key` (secret), `host_pubkey`, `accept_unknown_host`. Shared by both approaches. | +| `ssh_exec.sh` | The userland wrapper as a Windmill **bash** script. | +| `ssh_exec.py` | Userland wrapper as a Windmill **python** script (interpreter-dispatch table). | + +The `#ssh` directive +-------------------- + +**Enable it (once, as a superadmin):** the feature is enterprise-gated and off by +default. Turn on the `ssh_execution_enabled` instance setting (Superadmin +settings), which requires a valid enterprise license. + +**Use it:** create an `ssh_target` resource (see [Setup](#setup)), then write a +bash script with the directive on a leading comment line: + +```bash +#ssh f/infra/jump_node +# ^ reroutes this script to run on the host described by the +# ssh_target resource at f/infra/jump_node + +Service="$1" # typed positional args work as usual + +systemctl is-active "$Service" +echo "{\"service\": \"$Service\", \"checked\": true}" # last stdout line = result +``` + +The script runs on the remote host exactly as a local bash script would: the +arguments come from the run form, the result is collected the same way +(`result.json` > `result.out` > last stdout line), logs stream live, and a +non-zero remote exit fails the job. Only the *execution location* changes. + +**Dynamic target (`#ssh $`):** instead of hardcoding a path, the +directive can name a job argument that supplies the target at call time — for +picking the host from the run form, or fanning out over hosts in a flow forloop: + +```bash +#ssh $jump_host + +target="$1" # jump_host's position: always received as an empty string +df -h +``` + +The argument must be an `ssh_target` resource **path string** (with or without +the `$res:` prefix) — inline `ssh_target` objects are rejected, so the target is +always resolved through the runner's resource permissions and a caller can only +route execution to hosts whose resource they can read. Two things to note: the +target argument itself is forwarded to the remote script as an **empty string** +(its resolved value embeds the private key, which must never reach the remote +command line — its position is kept so the other `$1..$n` stay aligned), and +with a dynamic target the *runner* chooses where the code executes (bounded by +those resource permissions), whereas a hardcoded path lets the script author +pin it. + +**Host-key pinning** is enforced (`StrictHostKeyChecking=yes`) whenever the +resource's `host_pubkey` is set. An empty `host_pubkey` refuses to run unless the +resource explicitly sets `accept_unknown_host: true`, which falls back to weaker +TOFU (`accept-new`) and logs a warning — development only. + +**Parity boundary:** the remote receives the script body and its positional args +only. The Windmill runtime is *not* forwarded — `BASE_INTERNAL_URL`, the `wmill` +client, and reserved `WM_*` variables are unavailable remotely, so in-script +Windmill API callbacks won't work. The same trade-offs as the wrapper apply: +no remote dependency management, no nsjail sandbox, no S3 cache, per-job SSH +overhead. v1 is bash-only. + +The userland wrapper +-------------------- + +The wrapper takes an `ssh_target` resource, a `script_content` string, and a +`language`, then: + +1. writes the private key to a `0600` temp file (and a job-local `known_hosts`), +2. opens a single SSH connection (no TTY), +3. streams the script body to the remote host's stdin, where a small bootstrap + `mktemp`s a file, `trap`s its removal on `EXIT`, runs it with the right + interpreter, and exits with the script's exit code, +4. streams stdout/stderr back live and **propagates the remote exit code** so a + failed remote script fails the Windmill job. + +``` +Windmill worker Remote jump node +┌────────────────────┐ ┌─────────────────────────────┐ +│ ssh_exec.sh │ ssh (no -t) │ sh -c │ +│ key → 0600 tmp │ ───────────────▶ │ f=$(mktemp) │ +│ known_hosts pin │ body on stdin │ trap 'rm -f $f' EXIT │ +│ printf body | ssh │ ────────────────▶│ cat > $f │ +│ │ ◀─────────────── │ $f (live logs) │ +│ exit = ssh rc │ remote rc │ exit $? │ +└────────────────────┘ └─────────────────────────────┘ +``` + +Setup +----- + +1. **Create the resource type.** Push it with the CLI: + + ```bash + wmill resource-type push ssh_target.resource-type.json + ``` + + or recreate it in the UI (Resources → Resource Types) with the same schema. + `private_key` is marked secret (`"password": true`); `host_pubkey` is optional. + +2. **Create an `ssh_target` resource** for your jump node. Get `host_pubkey` from + the server (the `keytype key` portion, comment optional): + + ```bash + ssh-keyscan -t ed25519 your.jump.host # → ssh-ed25519 AAAAC3Nz... + ``` + +3. **Create a script** from `ssh_exec.sh` (bash) or `ssh_exec.py` (python). Mark + the first argument as a resource of type `ssh_target`. + +Usage +----- + +Call the wrapper with the target, the remote script body, and its language: + +```jsonc +{ + "ssh_target": "$res:u/me/my_jump_node", + "script_content": "set -euo pipefail\ndf -h\nsystemctl is-active nginx", + "language": "bash" +} +``` + +```jsonc +{ + "ssh_target": "$res:u/me/my_jump_node", + "script_content": "import platform\nprint(platform.platform())", + "language": "python" +} +``` + +Supported `language` keys: `bash`, `sh`, `python`/`python3`, `node`/`javascript`, +`ruby`, `php`, `perl`. Any other value is passed through as a raw remote +interpreter command. The remote host must already have that interpreter and any +dependencies installed (see tradeoffs). + +Design notes (the details that make or break it) +------------------------------------------------ + +These are deliberate and worth preserving if you adapt the wrapper: + +- **Exit-code propagation.** `ssh host cmd` returns the *remote* exit code. The + bash wrapper reads it via `${PIPESTATUS[1]}` and re-`exit`s it; the python + wrapper raises on non-zero. A failed remote script fails the Windmill job. +- **No TTY.** We never pass `-t`/`-tt`. A TTY merges stdout and stderr and + mangles log capture. Enable `-tt` **only** for interactive remote prompts + (e.g. `sudo` asking for a password). +- **Live, unbuffered logs.** `python -u` is used for python; for chatty bash that + buffers when piped, wrap the remote interpreter with `stdbuf -oL` (edit the + dispatch table, e.g. `interp="stdbuf -oL bash"`). +- **Remote cleanup survives failure.** The `trap 'rm -f "$f"' EXIT` is set on the + **remote** side, inside the streamed bootstrap, so the temp file is removed + even if the script errors out. +- **Host-key pinning.** With `host_pubkey` set, the wrapper pins it into a + job-local `known_hosts` and enforces `StrictHostKeyChecking=yes` (non-default + ports use the `[host]:port` form). With it empty, the wrapper refuses to run + unless `accept_unknown_host: true` is set on the resource, which falls back to + `accept-new` (TOFU) and warns — weaker; pin in production. +- **Quoted heredoc.** The remote bootstrap is built with `<<'REMOTE'` so `$f`, + `$?`, `$TMPDIR` are evaluated *remotely*, not expanded on the worker. +- **Body via stdin.** The script body is streamed over stdin, never written to a + local temp file or interpolated into the command line. +- **`--` before the destination.** OpenSSH parses a destination starting with + `-` as an option, so without the separator a crafted `user` like + `-oProxyCommand=...` in the resource would execute a local command on the + worker before host-key validation. Keep the `--` if you adapt the wrapper. +- **Multiple round-trips?** This wrapper makes a single SSH connection. If you + extend it to several `ssh` calls, add + `-o ControlMaster=auto -o ControlPersist=60 -o ControlPath=` to + reuse one connection instead of re-authenticating each time. + +When to use what +---------------- + +**Default: agent workers.** A Windmill *agent worker* is a lightweight worker +that runs *inside* the target environment and connects back to the Windmill +server over **outbound HTTP only** (using an `jwt_agent_*` token) — no inbound +ports, no DB access. It keeps everything Windmill workers normally give you: +automatic dependency management, nsjail sandboxing, the S3 binary cache, native +secrets, all languages, and no per-job connection overhead. If you can run a +process in the target environment, use an agent worker. + +**Worker-group tags** are the right tool when you *can* place a full worker in +the environment and want to route specific scripts to it. + +**This SSH wrapper** is for the narrow case where **both** are true: + +- you can only reach a **jump/utility node** over SSH (you cannot place any + worker or agent process there), and +- the scripts are **simple and self-contained** (no Windmill-managed deps). + +What you lose with the SSH path +------------------------------- + +- **No dependency management.** The remote host must already have the interpreter + *and* every library/tool the script uses. Nothing is installed or locked. +- **No nsjail sandboxing.** The script runs as the SSH user with that user's full + privileges. The jump node becomes a high-value target — scope the key and user + tightly. +- **No S3 / binary cache.** No shared cache of dependencies or artifacts. +- **Per-job SSH overhead.** Each run pays connection + auth latency (mitigable + with ControlMaster only if you make multiple round-trips). +- **No native Windmill integrations on the remote side** — no resource/variable + injection, no `wmill` client, no flow step context beyond what you pass in. + +Limitations of this prototype +----------------------------- + +- Requires an `ssh` client (and `jq` for the bash variant) on the worker. +- Assumes a self-contained, non-interactive script. No stdin is forwarded to the + remote script (stdin carries the script body). +- Unknown `language` values are passed through verbatim as the remote + interpreter — keep `language` author-controlled, not end-user input. + +Tested +------ + +Both wrappers were exercised against a local `sshd`: success path, remote +exit-code propagation (bash `${PIPESTATUS[1]}`, python raises), clean +stdout/stderr separation, `python -u` interpreter dispatch, host-key pinning +rejecting a wrong key (script never runs), the TOFU opt-in +(`accept_unknown_host: true`) and the refusal without it, and confirmed remote +*and* local temp-file cleanup. diff --git a/examples/usecase/ssh-execution-wrapper/ssh_exec.py b/examples/usecase/ssh-execution-wrapper/ssh_exec.py new file mode 100644 index 0000000000..6bd99be2a4 --- /dev/null +++ b/examples/usecase/ssh-execution-wrapper/ssh_exec.py @@ -0,0 +1,116 @@ +# Windmill SSH execution wrapper (python) +# -------------------------------------------------------------------------- +# Python variant of ssh_exec.sh. Same contract: run a self-contained script on +# a remote host over SSH, stream stdout/stderr live, and fail the Windmill job +# when the remote script fails. Userland prototype, no backend changes. +# +# Arguments: +# ssh_target resource of type `ssh_target` (received as a dict) +# script_content the body of the script to run on the remote host +# language interpreter key: bash|sh|python|node|ruby|php|perl +# (default: bash; anything else is treated as a raw remote +# interpreter command) +# +# Worker requirements: an `ssh` client installed on the worker. + +import os +import subprocess +import sys +import tempfile + +# `python3 -u` forces unbuffered output so logs stream live. +INTERPRETERS = { + "bash": "bash", + "sh": "sh", + "python": "python3 -u", + "python3": "python3 -u", + "node": "node", + "javascript": "node", + "ruby": "ruby", + "php": "php", + "perl": "perl", +} + +# Single-quoted (raw) so $f / $? / $TMPDIR are evaluated remotely, not here. +# @@INTERP@@ is replaced with the chosen interpreter before sending. +REMOTE_BOOTSTRAP = ( + "set -u\n" + 'f=$(mktemp "${TMPDIR:-/tmp}/wmssh_job.XXXXXX") || exit 1\n' + "trap 'rm -f \"$f\"' EXIT\n" # remote-side cleanup, survives script failure + 'cat >"$f"\n' # read the streamed script body from stdin + '@@INTERP@@ "$f"\n' # execute with the chosen interpreter + "exit $?\n" # propagate the remote exit code +) + + +def main(ssh_target: dict, script_content: str, language: str = "bash"): + host = ssh_target["host"] + user = ssh_target["user"] + private_key = ssh_target["private_key"] + port = str(ssh_target.get("port") or 22) + host_pubkey = (ssh_target.get("host_pubkey") or "").strip() + accept_unknown_host = bool(ssh_target.get("accept_unknown_host")) + + interp = INTERPRETERS.get(language, language) # passthrough for unknown keys + + # 0600 temp files for the key and a job-local known_hosts. + keyfile = tempfile.NamedTemporaryFile("w", delete=False) + known_hosts = tempfile.NamedTemporaryFile("w", delete=False) + try: + keyfile.write(private_key.rstrip("\n") + "\n") # trailing newline required by some keys + keyfile.close() + os.chmod(keyfile.name, 0o600) + + ssh_opts = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=15", + "-o", f"UserKnownHostsFile={known_hosts.name}", + "-p", port, + "-i", keyfile.name, + ] + + if host_pubkey: + # Pin the server key; non-default ports use the [host]:port form. + entry = f"{host} {host_pubkey}" if port == "22" else f"[{host}]:{port} {host_pubkey}" + known_hosts.write(entry + "\n") + known_hosts.close() + ssh_opts += ["-o", "StrictHostKeyChecking=yes"] + elif accept_unknown_host: + known_hosts.close() + print( + "WARN: ssh_target.host_pubkey is empty; using TOFU (accept-new) " + "because accept_unknown_host=true. Pin host_pubkey for production.", + file=sys.stderr, + flush=True, + ) + ssh_opts += ["-o", "StrictHostKeyChecking=accept-new"] + else: + known_hosts.close() + raise ValueError( + "ssh_target.host_pubkey is empty. Pin the host key " + f"(ssh-keyscan -t ed25519 {host}) or set accept_unknown_host=true " + "to allow TOFU (insecure against MITM)." + ) + + remote = REMOTE_BOOTSTRAP.replace("@@INTERP@@", interp) + # no -t/-tt: stdout and stderr stay separate for clean log capture. + # `--` so a crafted user/host (e.g. "-oProxyCommand=...") can never be + # parsed as an ssh option + cmd = ["ssh", *ssh_opts, "--", f"{user}@{host}", remote] + + # stdout/stderr are inherited from this process so they stream live to + # the Windmill job log; only stdin is a pipe for the script body. + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) + proc.communicate(input=(script_content + "\n").encode()) + rc = proc.returncode + finally: + for path in (keyfile.name, known_hosts.name): + try: + os.unlink(path) + except OSError: + pass + + # Raise on non-zero so the Windmill job fails with the remote exit code. + if rc != 0: + raise RuntimeError(f"Remote script exited with code {rc}") + return {"ok": True, "exit_code": 0, "host": host} diff --git a/examples/usecase/ssh-execution-wrapper/ssh_exec.sh b/examples/usecase/ssh-execution-wrapper/ssh_exec.sh new file mode 100755 index 0000000000..5c23402d1d --- /dev/null +++ b/examples/usecase/ssh-execution-wrapper/ssh_exec.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Windmill SSH execution wrapper (bash) +# -------------------------------------------------------------------------- +# Runs a self-contained script on a remote host reachable over SSH from the +# Windmill worker, streaming stdout/stderr back live and propagating the +# REMOTE exit code so a failed remote script fails the Windmill job. +# +# This is a userland prototype: it makes NO backend changes. See README.md for +# when to use it (only-a-jump-node reachable, self-contained scripts) and why +# agent workers are the recommended default. +# +# Windmill positional bash arguments: +# 1. ssh_target resource of type `ssh_target` (Windmill passes it as JSON) +# 2. script_content the body of the script to run on the remote host +# 3. language interpreter key: bash|sh|python|node|ruby|php|perl +# (default: bash; anything else is treated as a raw remote +# interpreter command) +# +# Worker requirements: `ssh` client and `jq` must be installed on the worker. + +ssh_target="$1" +script_content="$2" +language="${3:-bash}" + +set -euo pipefail +umask 077 + +command -v jq >/dev/null || { echo "FATAL: jq is required on the worker" >&2; exit 127; } +command -v ssh >/dev/null || { echo "FATAL: ssh client is required on the worker" >&2; exit 127; } + +# --- parse the ssh_target resource ----------------------------------------- +# `jq -e` exits non-zero when the field is null/absent, so a missing required +# field fails fast with a clear message. +host=$(jq -er '.host' <<<"$ssh_target") || { echo "FATAL: ssh_target.host missing" >&2; exit 2; } +user=$(jq -er '.user' <<<"$ssh_target") || { echo "FATAL: ssh_target.user missing" >&2; exit 2; } +private_key=$(jq -er '.private_key' <<<"$ssh_target") || { echo "FATAL: ssh_target.private_key missing" >&2; exit 2; } +port=$(jq -r '.port // 22' <<<"$ssh_target") +host_pubkey=$(jq -r '.host_pubkey // empty' <<<"$ssh_target") +accept_unknown_host=$(jq -r '.accept_unknown_host // false' <<<"$ssh_target") + +# --- interpreter dispatch table -------------------------------------------- +# `python3 -u` forces unbuffered output so logs stream live. For chatty bash +# scripts that buffer, wrap the remote interpreter with `stdbuf -oL` (see README). +case "$language" in + bash) interp="bash" ;; + sh) interp="sh" ;; + python|python3) interp="python3 -u" ;; + node|javascript) interp="node" ;; + ruby) interp="ruby" ;; + php) interp="php" ;; + perl) interp="perl" ;; + *) interp="$language" ;; # passthrough: raw remote interpreter command +esac + +# --- private key -> 0600 temp file + job-local known_hosts ------------------ +keyfile=$(mktemp "${TMPDIR:-/tmp}/wmssh_key.XXXXXX") +known_hosts=$(mktemp "${TMPDIR:-/tmp}/wmssh_kh.XXXXXX") +cleanup() { rm -f "$keyfile" "$known_hosts"; } +trap cleanup EXIT + +printf '%s\n' "$private_key" >"$keyfile" # trailing newline: some keys require it +chmod 600 "$keyfile" + +# --- host-key handling ------------------------------------------------------ +ssh_opts=( + -o BatchMode=yes + -o ConnectTimeout=15 + -o "UserKnownHostsFile=$known_hosts" + -p "$port" + -i "$keyfile" +) +if [ -n "$host_pubkey" ]; then + # Pin the server key: non-default ports use the `[host]:port` known_hosts form. + if [ "$port" = "22" ]; then + printf '%s %s\n' "$host" "$host_pubkey" >"$known_hosts" + else + printf '[%s]:%s %s\n' "$host" "$port" "$host_pubkey" >"$known_hosts" + fi + ssh_opts+=( -o StrictHostKeyChecking=yes ) +elif [ "$accept_unknown_host" = "true" ]; then + echo "WARN: ssh_target.host_pubkey is empty; using TOFU (accept-new) because accept_unknown_host=true. Pin host_pubkey for production." >&2 + ssh_opts+=( -o StrictHostKeyChecking=accept-new ) +else + echo "FATAL: ssh_target.host_pubkey is empty. Pin the host key (ssh-keyscan -t ed25519 $host) or set accept_unknown_host=true to allow TOFU (insecure against MITM)." >&2 + exit 2 +fi + +# --- remote bootstrap (runs ON the remote host) ---------------------------- +# Single-quoted heredoc: NOTHING is expanded locally. $f / $? / $TMPDIR are +# all evaluated remotely. The script body arrives on the remote's stdin. +remote_bootstrap=$(cat <<'REMOTE' +set -u +f=$(mktemp "${TMPDIR:-/tmp}/wmssh_job.XXXXXX") || exit 1 +trap 'rm -f "$f"' EXIT # remote-side cleanup, survives script failure +cat >"$f" # read the streamed script body from stdin +@@INTERP@@ "$f" # execute with the chosen interpreter +exit $? # propagate the remote exit code +REMOTE +) +# NOTE: `interp` is only controlled for the known dispatch keys above — the +# `*)` case passes `language` through verbatim into the remote bootstrap, so +# keep `language` author-controlled, never end-user input (see README). +remote_bootstrap=${remote_bootstrap//@@INTERP@@/$interp} + +# --- execute --------------------------------------------------------------- +# - no -t/-tt: keep stdout and stderr separate for clean log capture +# (add `-tt` ONLY when you need an interactive TTY, e.g. sudo password prompts) +# - body is streamed via stdin so it never touches a local temp file +# - PIPESTATUS[1] is ssh's exit code == the remote script's exit code +set +e +printf '%s\n' "$script_content" | ssh "${ssh_opts[@]}" -- "$user@$host" "$remote_bootstrap" +rc=${PIPESTATUS[1]} +exit "$rc" diff --git a/examples/usecase/ssh-execution-wrapper/ssh_target.resource-type.json b/examples/usecase/ssh-execution-wrapper/ssh_target.resource-type.json new file mode 100644 index 0000000000..163a554623 --- /dev/null +++ b/examples/usecase/ssh-execution-wrapper/ssh_target.resource-type.json @@ -0,0 +1,38 @@ +{ + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Hostname or IP of the SSH jump/utility node to execute on." + }, + "port": { + "type": "integer", + "description": "SSH port.", + "default": 22 + }, + "user": { + "type": "string", + "description": "SSH login user." + }, + "private_key": { + "type": "string", + "description": "PEM-encoded private key used for authentication. Stored as a secret.", + "password": true + }, + "host_pubkey": { + "type": "string", + "description": "Server host public key line for known_hosts pinning, e.g. 'ssh-ed25519 AAAAC3Nz...' (get it with `ssh-keyscan -t ed25519 `). When set, the wrapper enforces StrictHostKeyChecking=yes. When empty, execution is refused unless accept_unknown_host is true.", + "default": "" + }, + "accept_unknown_host": { + "type": "boolean", + "description": "Allow connecting without a pinned host_pubkey by trusting the host key on first use (StrictHostKeyChecking=accept-new). Insecure against MITM — development only.", + "default": false + } + }, + "required": ["host", "user", "private_key"] + }, + "description": "Target host reachable over SSH for the lightweight SSH execution wrapper. See examples/usecase/ssh-execution-wrapper/README.md." +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74054d1632..552b593ba2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.719.0", + "version": "1.726.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.719.0", + "version": "1.726.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -129,6 +129,7 @@ "eslint": "^8.47.0", "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.45.1", + "fake-indexeddb": "^6.2.5", "json-refs": "^3.0.15", "json-schema-to-zod": "^2.7.0", "path-browserify": "^1.0.1", @@ -5559,6 +5560,16 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 583d39886a..1772425f02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.719.0", + "version": "1.726.1", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", @@ -13,7 +13,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. .", - "package": "svelte-package -o package && node scripts/package-system-prompts.js", + "package": "svelte-package -o package && node scripts/package-system-prompts.js && node scripts/fix-svelte-module-imports.js", "generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false", "generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript", "pretest": "tsc --incremental -p tests/tsconfig.json", @@ -48,6 +48,7 @@ "eslint": "^8.47.0", "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.45.1", + "fake-indexeddb": "^6.2.5", "json-refs": "^3.0.15", "json-schema-to-zod": "^2.7.0", "path-browserify": "^1.0.1", diff --git a/frontend/publish.sh b/frontend/publish.sh index 9c374f0191..77419bf38b 100755 --- a/frontend/publish.sh +++ b/frontend/publish.sh @@ -1,2 +1,8 @@ +#!/usr/bin/env bash +# Abort if the package build fails — otherwise npm publish ships the stale +# package/ directory left over from the last successful build. +set -euo pipefail +cd "$(dirname "$0")" + npm run package npm publish diff --git a/frontend/scripts/fix-svelte-module-imports.js b/frontend/scripts/fix-svelte-module-imports.js new file mode 100644 index 0000000000..9cec6265a0 --- /dev/null +++ b/frontend/scripts/fix-svelte-module-imports.js @@ -0,0 +1,87 @@ +// Make the svelte-package output self-contained for consumers. +// +// 1. Runes-module imports: source files import runes modules (`foo.svelte.ts`) +// as `$lib/foo.svelte`, which Vite resolves inside this repo by appending +// `.ts`. svelte-package emits those modules as `foo.svelte.js` but keeps +// the import specifier as `../foo.svelte`, which consumers of +// @windmill-labs/components cannot resolve (their bundler looks for a +// `.svelte` component file that does not exist). Append `.js` to any +// relative `.svelte` specifier whose target only exists as +// `.js` in the package output. +// +// 2. OAuth connect registry: the `$oauth_connect_registry` alias +// (svelte.config.js) points at `backend/oauth_connect.json`, so the +// packaged output imports `../../../../backend/oauth_connect.json` — a +// path that escapes the package root and does not exist in the published +// tarball. Copy the registry into the package and rewrite those imports +// to point at it (mirrors how package-system-prompts.js bundles +// `$system_prompts`). +import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, join, relative, resolve } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const packageDir = resolve(__dirname, '..', 'package') +const oauthConnectSrc = resolve(__dirname, '..', '..', 'backend', 'oauth_connect.json') +const oauthConnectDest = join(packageDir, 'oauth_connect.json') + +copyFileSync(oauthConnectSrc, oauthConnectDest) + +const IMPORT_SPECIFIER_RE = /(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.\.?\/[^'"]*\.(?:svelte|json))\2/g +const OAUTH_CONNECT_RE = /^(\.\.\/)+backend\/oauth_connect\.json$/ + +function* walk(dir) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + yield* walk(full) + } else if (/\.(js|svelte|ts)$/.test(entry)) { + yield full + } + } +} + +function relativeSpecifier(fromFile, toFile) { + const spec = relative(dirname(fromFile), toFile).replaceAll('\\', '/') + return spec.startsWith('.') ? spec : `./${spec}` +} + +let rewrittenFiles = 0 +let rewrittenImports = 0 + +for (const file of walk(packageDir)) { + const content = readFileSync(file, 'utf-8') + let changed = false + const updated = content.replace(IMPORT_SPECIFIER_RE, (match, prefix, quote, specifier) => { + if (OAUTH_CONNECT_RE.test(specifier)) { + changed = true + rewrittenImports++ + return `${prefix}${quote}${relativeSpecifier(file, oauthConnectDest)}${quote}` + } + if (!specifier.endsWith('.svelte')) { + return match + } + const target = resolve(dirname(file), specifier) + // A real `.svelte` component file: leave the import alone. + if (existsSync(target)) { + return match + } + // A runes module emitted as `.svelte.js`: make the extension explicit. + if (existsSync(target + '.js')) { + changed = true + rewrittenImports++ + return `${prefix}${quote}${specifier}.js${quote}` + } + return match + }) + if (changed) { + writeFileSync(file, updated) + rewrittenFiles++ + } +} + +console.log( + `fix-svelte-module-imports: rewrote ${rewrittenImports} import(s) in ${rewrittenFiles} file(s)` +) diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 2863ec67ab..aa09b8730f 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "fe13d03", - "sha256": "c588569103ce065a26f334d9b625f616cd23d4204deaacd6fadcfbf16f23462c" + "version": "ad76918", + "sha256": "4c940570936a391c217a054b74c691587d7f27d012ccddbf5e3b3d25e7c1fe4a" } diff --git a/frontend/src/lib/aiStore.test.ts b/frontend/src/lib/aiStore.test.ts new file mode 100644 index 0000000000..fed6147b62 --- /dev/null +++ b/frontend/src/lib/aiStore.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { get } from 'svelte/store' + +// aiStore pulls in the (heavy) copilot lib for workspaceAIClients and the gen +// client for WorkspaceService; neither is exercised by setCopilotInfo. +vi.mock('./components/copilot/lib', () => ({ + workspaceAIClients: { init: () => {} } +})) +vi.mock('./gen', () => ({ + WorkspaceService: { getCopilotInfo: async () => ({}) } +})) + +import { isWebSearchEnabledForProvider, setCopilotInfo, copilotInfo } from './aiStore' + +describe('setCopilotInfo legacy /thinking migration', () => { + it('strips the /thinking suffix from configured model slots', () => { + setCopilotInfo({ + providers: { + anthropic: { + resource_path: 'u/admin/anthropic', + models: ['claude-sonnet-4-6', 'claude-sonnet-4-6/thinking'] + } + }, + default_model: { model: 'claude-sonnet-4-6/thinking', provider: 'anthropic' }, + metadata_model: { model: 'claude-sonnet-4-6/thinking', provider: 'anthropic' }, + code_completion_model: { model: 'claude-3-5-haiku-latest', provider: 'anthropic' } + }) + + const info = get(copilotInfo) + // configured slots no longer carry the deprecated suffix + expect(info.defaultModel?.model).toBe('claude-sonnet-4-6') + expect(info.metadataModel?.model).toBe('claude-sonnet-4-6') + expect(info.codeCompletionModel?.model).toBe('claude-3-5-haiku-latest') + // the model list is stripped and deduped + expect(info.aiModels.map((m) => m.model)).toEqual(['claude-sonnet-4-6']) + }) + + it('defaults provider web search on unless explicitly disabled', () => { + setCopilotInfo({ + providers: { + openai: { + resource_path: 'u/admin/openai', + models: ['gpt-4.1'], + web_search_enabled: false + }, + anthropic: { + resource_path: 'u/admin/anthropic', + models: ['claude-sonnet-4-6'] + } + } + }) + + expect(isWebSearchEnabledForProvider('openai')).toBe(false) + expect(isWebSearchEnabledForProvider('anthropic')).toBe(true) + }) +}) diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index f2bddb8874..cb34b72be0 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -1,18 +1,31 @@ import { writable, get } from 'svelte/store' import { workspaceAIClients } from './components/copilot/lib' import { type AIProviderModel, type AIProvider, WorkspaceService, type AIConfig } from './gen' -import { COPILOT_SESSION_MODEL_SETTING_NAME, COPILOT_SESSION_PROVIDER_SETTING_NAME } from './stores' -import { getLocalSetting } from './utils' +import { + aiUserDisabled, + COPILOT_SESSION_MODEL_SETTING_NAME, + COPILOT_SESSION_PROVIDER_SETTING_NAME, + COPILOT_SESSION_REASONING_SETTING_NAME +} from './stores' +import { getLocalSetting, storeLocalSetting } from './utils' +import { + type ReasoningProviderModel, + stripLegacyThinkingSuffix +} from './components/copilot/reasoningRegistry' const USER_CUSTOM_PROMPTS_KEY = 'userCustomAIPrompts' const sessionModel = getLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME) const sessionProvider = getLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME) -export const copilotSessionModel = writable( +const sessionReasoning = getLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME) +export const copilotSessionModel = writable( sessionModel && sessionProvider ? { - model: sessionModel, - provider: sessionProvider as AIProvider + // Strip the deprecated /thinking suffix on read; the default effort + // (resolved later) restores reasoning for migrated selections. + model: stripLegacyThinkingSuffix(sessionModel), + provider: sessionProvider as AIProvider, + ...(sessionReasoning ? { reasoning: sessionReasoning } : {}) } : undefined ) @@ -25,6 +38,7 @@ export const copilotInfo = writable<{ aiModels: AIProviderModel[] customPrompts?: Record maxTokensPerModel?: Record + webSearchEnabledProviders?: Partial> }>({ enabled: false, codeCompletionModel: undefined, @@ -32,9 +46,37 @@ export const copilotInfo = writable<{ metadataModel: undefined, aiModels: [], customPrompts: {}, - maxTokensPerModel: {} + maxTokensPerModel: {}, + webSearchEnabledProviders: {} }) +// Apply the per-user opt-out live: toggling it flips `enabled` without re-fetching. +// Only enable when providers exist (aiModels is populated whenever the config has any). +aiUserDisabled.subscribe((disabled) => { + copilotInfo.update((info) => ({ + ...info, + enabled: info.aiModels.length > 0 && !disabled + })) +}) + +/** Strip the deprecated /thinking suffix from a configured model slot, if present. */ +function stripModelSuffix(model: AIProviderModel | undefined): AIProviderModel | undefined { + return model ? { ...model, model: stripLegacyThinkingSuffix(model.model) } : model +} + +/** Dedupe model entries by provider+model (legacy /thinking entries collapse onto the plain model). */ +function dedupeModels(models: AIProviderModel[]): AIProviderModel[] { + const seen = new Set() + return models.filter((m) => { + const key = `${m.provider}:${m.model}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + export async function loadCopilot(workspace: string) { workspaceAIClients.init(workspace) try { @@ -48,10 +90,21 @@ export async function loadCopilot(workspace: string) { export function setCopilotInfo(aiConfig: AIConfig) { if (Object.keys(aiConfig.providers ?? {}).length > 0) { - const aiModels = Object.entries(aiConfig.providers ?? {}).flatMap( - ([provider, providerConfig]) => - providerConfig.models.map((m) => ({ model: m, provider: provider as AIProvider })) + const aiModels = dedupeModels( + Object.entries(aiConfig.providers ?? {}).flatMap(([provider, providerConfig]) => + providerConfig.models.map((m) => ({ + // Strip the deprecated /thinking suffix from workspace-configured models. + model: stripLegacyThinkingSuffix(m), + provider: provider as AIProvider + })) + ) ) + const webSearchEnabledProviders = Object.fromEntries( + Object.entries(aiConfig.providers ?? {}).map(([provider, providerConfig]) => [ + provider, + providerConfig.web_search_enabled !== false + ]) + ) as Partial> copilotSessionModel.update((model) => { if ( @@ -64,13 +117,17 @@ export function setCopilotInfo(aiConfig: AIConfig) { }) copilotInfo.set({ - enabled: true, - codeCompletionModel: aiConfig.code_completion_model, - defaultModel: aiConfig.default_model, - metadataModel: aiConfig.metadata_model, + // Providers are configured; the per-user opt-out is the only thing that can gate it off. + enabled: !get(aiUserDisabled), + // Strip the deprecated /thinking suffix from the configured model slots too, + // otherwise a workspace whose default still carries it sends an invalid model id. + codeCompletionModel: stripModelSuffix(aiConfig.code_completion_model), + defaultModel: stripModelSuffix(aiConfig.default_model), + metadataModel: stripModelSuffix(aiConfig.metadata_model), aiModels: aiModels, customPrompts: aiConfig.custom_prompts ?? {}, - maxTokensPerModel: aiConfig.max_tokens_per_model ?? {} + maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}, + webSearchEnabledProviders }) } else { copilotSessionModel.set(undefined) @@ -82,12 +139,20 @@ export function setCopilotInfo(aiConfig: AIConfig) { metadataModel: undefined, aiModels: [], customPrompts: {}, - maxTokensPerModel: {} + maxTokensPerModel: {}, + webSearchEnabledProviders: {} }) } } -export function getCurrentModel(): AIProviderModel { +export function isWebSearchEnabledForProvider(provider: AIProvider | undefined): boolean { + if (!provider) { + return false + } + return get(copilotInfo).webSearchEnabledProviders?.[provider] ?? true +} + +export function getCurrentModel(): ReasoningProviderModel { const model = get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0] if (!model) { @@ -105,7 +170,7 @@ export function getMetadataModel(): AIProviderModel { return model } -export function tryGetCurrentModel(): AIProviderModel | undefined { +export function tryGetCurrentModel(): ReasoningProviderModel | undefined { return get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0] } @@ -122,6 +187,10 @@ export function getUserCustomPrompts(): Record { return {} } +export function setUserCustomPrompts(prompts: Record) { + storeLocalSetting(USER_CUSTOM_PROMPTS_KEY, JSON.stringify(prompts)) +} + export function getCombinedCustomPrompt(mode: string): string | undefined { const workspacePrompt = get(copilotInfo).customPrompts?.[mode] const userPrompts = getUserCustomPrompts() diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 2ddf79fd2c..ae7efaaf60 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -71,6 +71,26 @@ list-style-type: '- '; padding-left: 3rem; } + + /* The '- ' list markers, horizontal rules and blockquote bars otherwise + fall through to Tailwind Typography's default bullet/border colors, which + are nearly invisible on dark backgrounds (e.g. the AI chat). Use + theme-aware tokens so they stay readable in both light and dark mode. */ + .prose-xs ul > li::marker, + .prose ul > li::marker { + color: rgb(var(--color-text-secondary)); + } + + .prose-xs hr, + .prose hr { + border-top-color: rgb(var(--color-border-normal)); + } + + .prose-xs blockquote, + .prose blockquote { + border-left-color: rgb(var(--color-border-normal)); + } + .prose a { @apply text-accent no-underline; } diff --git a/frontend/src/lib/autosize.ts b/frontend/src/lib/autosize.ts index b47a2e57b7..f9c1e21152 100644 --- a/frontend/src/lib/autosize.ts +++ b/frontend/src/lib/autosize.ts @@ -2,7 +2,28 @@ import { tick } from 'svelte' type TextArea = HTMLTextAreaElement -export const autosize = (node: TextArea) => { +/** + * Optional parameters for the `autosize` action. + * + * `maxHeight` caps how tall the textarea may grow. Once the content exceeds it, + * the textarea stops growing and scrolls internally (overflow-y: auto) instead. + * Accepts a number (px) or a CSS-ish string ending in `vh`/`px` (e.g. `'40vh'`). + * When omitted the textarea grows without bound (the historical behaviour). + */ +export type AutosizeParams = { maxHeight?: number | string } | undefined + +/** Resolve a `maxHeight` param to a pixel value, or null when uncapped/invalid. */ +function resolveMaxHeight(maxHeight: number | string | undefined): number | null { + if (maxHeight == null) return null + if (typeof maxHeight === 'number') return maxHeight + const s = maxHeight.trim() + const v = parseFloat(s) + if (isNaN(v)) return null + if (s.endsWith('vh')) return (v / 100) * window.innerHeight + return v // 'px' or bare number → pixels +} + +export const autosize = (node: TextArea, params?: AutosizeParams) => { /* ------------------------------------------------------------------ * Constants * ---------------------------------------------------------------- */ @@ -11,13 +32,32 @@ export const autosize = (node: TextArea) => { const EXTRA = 2 // px added to scrollHeight let width = 0 + let maxHeight = params?.maxHeight + let capped = maxHeight != null /* ------------------------------------------------------------------ * Core resize routine * ---------------------------------------------------------------- */ const resize = () => { node.style.height = 'auto' - node.style.height = `${Math.max(node.scrollHeight, MIN_HEIGHT) + EXTRA}px` + let height = Math.max(node.scrollHeight, MIN_HEIGHT) + EXTRA + + const maxPx = resolveMaxHeight(maxHeight) + if (maxPx != null) { + if (height > maxPx) { + height = maxPx + node.style.overflowY = 'auto' + } else { + node.style.overflowY = 'hidden' + } + } else { + // Uncapped — including after a capped→uncapped toggle: drop any inline + // overflow we set while capped so the textarea returns to its default + // (class-driven) behaviour rather than keeping a stale `auto`/`hidden`. + node.style.overflowY = '' + } + + node.style.height = `${height}px` } /* ------------------------------------------------------------------ @@ -46,6 +86,13 @@ export const autosize = (node: TextArea) => { node.addEventListener('input', onInput) node.addEventListener('update', resize) + // A `vh`-based cap depends on the viewport height, so recompute on window + // resize. Only attached when a cap is configured to avoid adding listeners + // for the many uncapped textareas across the app. + if (capped) { + window.addEventListener('resize', resize) + } + /* ------------------------------------------------------------------ * Inline styling * ---------------------------------------------------------------- */ @@ -79,10 +126,24 @@ export const autosize = (node: TextArea) => { * Action lifecycle * ---------------------------------------------------------------- */ return { + update(newParams?: AutosizeParams) { + maxHeight = newParams?.maxHeight + const nowCapped = maxHeight != null + if (nowCapped && !capped) { + window.addEventListener('resize', resize) + } else if (!nowCapped && capped) { + window.removeEventListener('resize', resize) + } + capped = nowCapped + resize() + }, destroy() { ro.disconnect() node.removeEventListener('input', onInput) node.removeEventListener('update', resize) + if (capped) { + window.removeEventListener('resize', resize) + } } } } diff --git a/frontend/src/lib/coalescingRunner.svelte.ts b/frontend/src/lib/coalescingRunner.svelte.ts new file mode 100644 index 0000000000..0f687d4829 --- /dev/null +++ b/frontend/src/lib/coalescingRunner.svelte.ts @@ -0,0 +1,118 @@ +/** + * Per-key coalescing async runner: at most one task running and one pending per + * key. `submit` runs `fn` now if idle, else REPLACES the pending task (the + * displaced one never runs); the pending task starts when the running one + * settles. Collapses bursts of "save latest" to the in-flight call plus the + * most recent. Keys are independent. + */ +import { SvelteSet } from 'svelte/reactivity' + +export type CoalescingTask = () => T | Promise + +/** Rejection reason for a `submitAndWait` (or `cancel`-dropped) task discarded + * before it ran. Only awaiters see it, not fire-and-forget `submit` callers. */ +export class CoalescingDisplacedError extends Error { + constructor() { + super('coalescingRunner: pending task displaced before it could run') + this.name = 'CoalescingDisplacedError' + } +} + +export type CoalescingKeyedRunner = { + /** Fire-and-forget schedule per the policy above. */ + submit(key: string, fn: CoalescingTask): void + /** Like `submit`, but the promise resolves/rejects with `fn`'s outcome, or + * rejects with `CoalescingDisplacedError` if dropped before running. */ + submitAndWait(key: string, fn: CoalescingTask): Promise + /** Drop the pending task for `key` (returns whether there was one). Can't + * abort an in-flight task. A dropped `submitAndWait` rejects with + * `CoalescingDisplacedError`. */ + cancel(key: string): boolean + /** Reactively whether `key`'s chain is running (SvelteSet-backed). */ + isRunning(key: string): boolean +} + +type PendingTask = { + fn: CoalescingTask + resolve?: (value: unknown) => void + reject?: (reason: unknown) => void +} + +type Entry = { pending: PendingTask | undefined } + +/** + * @example + * runner.submit('k', f) // runs immediately + * runner.submit('k', g) // f running: g pending + * runner.submit('k', h) // g discarded, h pending; h runs once f settles + */ +export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { + const state = new Map() + // Reactive mirror of keys with a running chain, kept in lock-step with + // `state` (SvelteSet for per-key `isRunning` subscriptions). + const runningKeys = new SvelteSet() + + async function chain(key: string, first: PendingTask): Promise { + let current: PendingTask | undefined = first + while (current) { + try { + const result = await current.fn() + current.resolve?.(result) + } catch (e) { + // Don't kill the chain on failure — later submissions must + // still run. Awaiters get the error via their promise; + // fire-and-forget callers get a console.error. + if (current.reject) current.reject(e) + else console.error('coalescingRunner: task failed', e) + } + const entry = state.get(key)! + current = entry.pending + entry.pending = undefined + } + state.delete(key) + runningKeys.delete(key) + } + + /** Set `task` pending for `key`, displacing (and rejecting) any prior + * pending. If the key is idle, start the chain. */ + function setOrDisplace(key: string, task: PendingTask): void { + const entry = state.get(key) + if (entry) { + entry.pending?.reject?.(new CoalescingDisplacedError()) + entry.pending = task + return + } + state.set(key, { pending: undefined }) + runningKeys.add(key) + void chain(key, task) + } + + function submit(key: string, fn: CoalescingTask): void { + setOrDisplace(key, { fn }) + } + + function submitAndWait(key: string, fn: CoalescingTask): Promise { + return new Promise((resolve, reject) => { + setOrDisplace(key, { + fn: fn as CoalescingTask, + resolve: resolve as (v: unknown) => void, + reject + }) + }) + } + + function cancel(key: string): boolean { + const entry = state.get(key) + if (!entry?.pending) return false + const dropped = entry.pending + entry.pending = undefined + dropped.reject?.(new CoalescingDisplacedError()) + return true + } + + function isRunning(key: string): boolean { + return runningKeys.has(key) + } + + return { submit, submitAndWait, cancel, isRunning } +} diff --git a/frontend/src/lib/coalescingRunner.test.ts b/frontend/src/lib/coalescingRunner.test.ts new file mode 100644 index 0000000000..49cca4688e --- /dev/null +++ b/frontend/src/lib/coalescingRunner.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest' +import { createCoalescingKeyedRunner, CoalescingDisplacedError } from './coalescingRunner.svelte' + +/** A promise plus its resolve/reject, so a test can decide exactly when a + * task settles. */ +function deferred() { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('createCoalescingKeyedRunner', () => { + it('runs a submitted task immediately when the key is idle', () => { + const runner = createCoalescingKeyedRunner() + const fn = vi.fn(() => Promise.resolve()) + runner.submit('k', fn) + expect(fn).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(true) + }) + + it('clears isRunning once the in-flight task settles', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(runner.isRunning('k')).toBe(false) + }) + + it('coalesces a burst down to in-flight + latest-pending', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const f = vi.fn(() => d.promise) + const g = vi.fn(() => Promise.resolve()) + const h = vi.fn(() => Promise.resolve()) + + runner.submit('k', f) // runs now + runner.submit('k', g) // pending + runner.submit('k', h) // displaces g; h is the only pending + + expect(f).toHaveBeenCalledTimes(1) + expect(g).not.toHaveBeenCalled() + expect(h).not.toHaveBeenCalled() + + d.resolve() + await d.promise + await Promise.resolve() + await Promise.resolve() + + expect(g).not.toHaveBeenCalled() // displaced — never ran + expect(h).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(false) + }) + + it('keeps different keys independent', () => { + const runner = createCoalescingKeyedRunner() + const a = vi.fn(() => new Promise(() => {})) + const b = vi.fn(() => new Promise(() => {})) + runner.submit('a', a) + runner.submit('b', b) // different key → runs immediately too + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledTimes(1) + expect(runner.isRunning('a')).toBe(true) + expect(runner.isRunning('b')).toBe(true) + }) + + it('submitAndWait resolves with the task result', async () => { + const runner = createCoalescingKeyedRunner() + await expect(runner.submitAndWait('k', () => Promise.resolve(42))).resolves.toBe(42) + }) + + it('submitAndWait rejects with the task error', async () => { + const runner = createCoalescingKeyedRunner() + await expect( + runner.submitAndWait('k', () => Promise.reject(new Error('boom'))) + ).rejects.toThrow('boom') + }) + + it('rejects a displaced submitAndWait with CoalescingDisplacedError', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) // hold the key busy + const dropped = runner.submitAndWait('k', () => Promise.resolve('a')) // pending + runner.submit('k', () => Promise.resolve('b')) // displaces it + await expect(dropped).rejects.toBeInstanceOf(CoalescingDisplacedError) + d.resolve() + }) + + it('cancel drops the pending task and rejects its awaiter', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const pending = vi.fn(() => Promise.resolve()) + runner.submit('k', () => d.promise) // in flight + const awaited = runner.submitAndWait('k', pending) // pending + + expect(runner.cancel('k')).toBe(true) + await expect(awaited).rejects.toBeInstanceOf(CoalescingDisplacedError) + + d.resolve() + await d.promise + await Promise.resolve() + expect(pending).not.toHaveBeenCalled() // cancelled before it could run + }) + + it('cancel returns false when there is no pending task', () => { + const runner = createCoalescingKeyedRunner() + expect(runner.cancel('k')).toBe(false) + runner.submit('k', () => new Promise(() => {})) // running, nothing pending + expect(runner.cancel('k')).toBe(false) + }) + + it('does not abort the in-flight task on cancel', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const inflight = vi.fn(() => d.promise) + runner.submit('k', inflight) + runner.cancel('k') // only affects pending; nothing pending here + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(inflight).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index ed9d11eaa7..368cfa6d8a 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -46,7 +46,7 @@ import AIProviderPicker from './AIProviderPicker.svelte' import TextInput from './text_input/TextInput.svelte' import FileInput from './common/fileInput/FileInput.svelte' - import { randomUUID } from './flows/conversations/FlowChatManager.svelte' + import { randomUUID } from '$lib/utils/uuid' interface Props { label?: string diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 31706433f6..335b64277c 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -84,7 +84,8 @@ 'xero', 'apify', 'docusign', - 'salesforce' + 'salesforce', + 'outreach' ] // Providers whose registry entry (`backend/oauth_connect.json`) carries a // `sandbox` URL block. Each one gets a sibling `_sandbox` dropdown diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte new file mode 100644 index 0000000000..168f68ebd7 --- /dev/null +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -0,0 +1,339 @@ + + +
+ {#if flashActive} + + {#key flashKey} + + {/key} + {/if} + + {#snippet trigger()} +
+ {#if syncState === 'saving' || syncState === 'pending'} + + {:else if syncState === 'failed'} + + {:else if !autosaveEnabled} + + + {:else} + + {/if} +
+ {/snippet} + + {#snippet content()} +
+ {#if syncState === 'failed'} +
+

Save failed

+ {#if failureMessage} +
{failureMessage}
+ {/if} +
+ {/if} + {#if autosaveEnabled} +

+ All changes are saved as a draft on the server. The draft is per-user — your teammates' + editors keep their own. +

+ {:else} +

+ Auto-save is off — changes only persist when you press Ctrl/Cmd+S. The draft is per-user + — your teammates' editors keep their own. +

+ {/if} + { + UserDraftDbSyncer.autosaveEnabled = e.detail + }} + /> + {#if othersDraftsCount > 0} +
+

+ Other users are working on this {kindLabel}. +

+ +
+ {/if} + {#if showResetAction} + + {/if} +
+ {/snippet} +
+ {#if label} + {label} + {/if} +
+ + diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte new file mode 100644 index 0000000000..7269e54a3b --- /dev/null +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -0,0 +1,525 @@ + + +
+
+ deploymentStatus[item.key]?.status !== 'deployed'} + onToggleItem={toggleItem} + onSelectAll={selectAll} + onDeselectAll={deselectAll} + emptyMessage={draftsLoading ? 'Loading drafts…' : 'No drafts in this workspace'} + > + {#snippet header()} + {#if isFork} +
+ onModeSelected?.(v)} + /> + +
+ + deploy: + + draft + + + + into: + + {currentWorkspaceId} + +
+
+ {/if} + {/snippet} + + {#snippet itemSummary(item)} + {@const draftItem = item as unknown as Row} + {@const editUrl = draftEditUrl(draftItem)} + {@const cache = summaryCache[draftItem.key]} + {@const oldSummary = cache?.deployed ?? draftItem.summary} + {@const newSummary = cache?.draft ?? draftItem.summary} + + {/snippet} + + {#snippet itemActions(item)} + {@const draftItem = item as unknown as Row} + {kindLabel(draftItem.draftKind)} + {#if draftItem.draft_only} + New + {/if} + {#if draftItem.legacy_draft} + + Legacy draft + {#snippet text()} + A legacy draft predates the per-user drafts migration: it isn't tied to any user + (workspace-level, email NULL), so everyone with access to this path sees it. + {/snippet} + + {/if} + {#if deploymentStatus[draftItem.key]?.status !== 'deployed'} + + + {/if} + {/snippet} + + {#snippet footer()} +
+ +
+ {/snippet} +
+
+ + +
+ + (discardTarget = undefined)} +> + {#if discardTarget?.draft_only} +

+ {discardTarget?.path} exists only as a + draft. Discarding it will permanently delete the item. This cannot be undone. +

+ {:else} +

+ Discard the draft of + {discardTarget?.path}? The deployed + version is unaffected. +

+ {/if} +
diff --git a/frontend/src/lib/components/CompareModeToggle.svelte b/frontend/src/lib/components/CompareModeToggle.svelte new file mode 100644 index 0000000000..c7728ba090 --- /dev/null +++ b/frontend/src/lib/components/CompareModeToggle.svelte @@ -0,0 +1,61 @@ + + + + + onSelected(v as CompareMode)} noWFull> + {#snippet children({ item })} + {#if isFork} + + + {/if} + + {/snippet} + diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 95d36c4551..06d787095e 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -1,10 +1,8 @@ -{#if has_draft} - {#if draft_only} - - {#snippet text()} - Never deployed and is only a draft - {/snippet} - Draft only - - {:else} - - {#snippet text()} - Is deployed and has a draft - {/snippet} - +Draft - - {/if} +{#if showBadge} + + {#snippet trigger()} + + {#if orderedUsers.length > 0} + + + {#each visibleUsers as u, i (i)} + + {initials(u)} + + {/each} + {#if overflowCount > 0} + + +{overflowCount} + + {/if} + + {/if} + {draft_only ? 'Draft only' : 'Draft'} + + {/snippet} + {#snippet content()} +
+

+ {#if draft_users.length > 0} + {draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'} + {:else if draft_only} + Never deployed and is only a draft + {:else} + Is deployed and has a draft + {/if} + + {#if onlyOwnDraft} +
+ Only you can see this {kindLabel}. + {/if} +

+ {#if draft_users.length > 0} +
    + {#each orderedUsers as u, i (i)} + {@const isSelf = !!currentUsername && u.username === currentUsername} +
  • + + {initials(u)} + + + {fullLabel(u)}{isSelf ? ' (you)' : ''} + {#if !u.username} + + {#snippet text()} + A legacy draft predates the per-user drafts migration: it isn't tied to any + user (workspace-level, email NULL), so everyone with access to this path + sees it. + {/snippet} + + {/if} + + {#if actionsEnabled && !isSelf} + + + {#if !$userStore?.operator} + + {/if} + {/if} +
  • + {/each} +
+ {/if} +
+ {/snippet} +
{/if} + + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonValue ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 698e799aad..476869900d 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -136,7 +136,17 @@ lineNumbersMinChars?: number files?: Record | undefined extraLib?: string | undefined + /** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. + * Each keystroke schedules (or reschedules) an `updateCode` call this + * far in the future. */ changeTimeout?: number + /** Hard ceiling (ms) on how long `updateCode` can be deferred while + * the user is typing continuously — measured from the FIRST + * keystroke of the burst (the leading fire). Without this cap, + * uninterrupted typing would hold the bindable `code` prop stale + * indefinitely and downstream consumers (autosave, lint, live + * preview) would never see the latest text. */ + maxChangeTimeout?: number loadAsync?: boolean key?: string | undefined class?: string | undefined @@ -177,6 +187,7 @@ files = {}, extraLib = undefined, changeTimeout = 500, + maxChangeTimeout = 1000, loadAsync = false, key = undefined, class: clazz = undefined, @@ -417,6 +428,22 @@ dispatch('change', ncode) } + /** Force-materialize the latest Monaco content into the bindable + * `code` prop right now, bypassing the trailing debounce. Use for + * explicit "save now" shortcuts (Ctrl/Cmd+S) — without this, anything + * the user typed within the last `changeTimeout` ms is still sitting + * in Monaco's buffer and downstream consumers (autosave, lint) won't + * see it. Clears the chain state so the next keystroke after this + * flush is a fresh leading fire. */ + export function flushPendingChanges(): void { + if (timeoutModel !== undefined) { + clearTimeout(timeoutModel) + timeoutModel = undefined + } + changeChainStart = undefined + updateCode() + } + export function append(code: string): void { if (editor) { const lineCount = editor.getModel()?.getLineCount() || 0 @@ -1327,6 +1354,10 @@ } let timeoutModel: number | undefined = undefined + /** Wall-clock start (ms) of the current debounce chain. Reset whenever + * the trailing fire lands — so a typing burst → pause → typing burst + * gets a fresh leading fire instead of inheriting the previous cap. */ + let changeChainStart: number | undefined = undefined async function loadMonaco() { setMonacoTypescriptOptions() console.log('path', uri) @@ -1433,10 +1464,29 @@ let ataModel: number | undefined = undefined editor?.onDidChangeModelContent((event) => { - timeoutModel && clearTimeout(timeoutModel) - timeoutModel = setTimeout(() => { + // Leading fire on the first keystroke of a burst: every + // downstream consumer (autosave's 1.5s debouncer, the + // `bind:code` chain, change listeners) sees text within the + // same tick instead of after `changeTimeout` ms of silence. + // Subsequent keystrokes within the burst are trailing-only + // (debounced by `changeTimeout`), with a hard ceiling at + // `chainStart + maxChangeTimeout` so continuous typing still + // materializes at least once per `maxChangeTimeout` window. + const now = Date.now() + if (changeChainStart === undefined) { updateCode() - }, changeTimeout) + changeChainStart = now + } + timeoutModel && clearTimeout(timeoutModel) + const fireAt = Math.min(now + changeTimeout, changeChainStart + maxChangeTimeout) + timeoutModel = setTimeout( + () => { + updateCode() + timeoutModel = undefined + changeChainStart = undefined + }, + Math.max(0, fireAt - now) + ) ataModel && clearTimeout(ataModel) ataModel = setTimeout(() => { @@ -1479,6 +1529,12 @@ editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () { updateCode() shouldBindKey && format && format() + // Monaco swallows the keydown (addCommand prevents default and + // stops propagation), so page-level Ctrl/Cmd+S handlers never + // see it. Re-broadcast as a window event so editors that flush + // a draft on the shortcut (raw apps) can react regardless of + // which Monaco has focus. + window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')) }) editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () { @@ -1788,6 +1844,7 @@ resultCollectionCompletor && resultCollectionCompletor.dispose() preprocessorCompletor && preprocessorCompletor.dispose() timeoutModel && clearTimeout(timeoutModel) + changeChainStart = undefined loadTimeout && clearTimeout(loadTimeout) aiChatEditorHandler?.clear() absolutePathExtraLibs.forEach((d) => d.dispose()) @@ -1924,7 +1981,7 @@ $copilotInfo.enabled && initialized && editor && untrack(() => editor && addChatHandler(editor)) }) $effect(() => { - !$codeCompletionSessionEnabled && autocompletor?.dispose() + ;(!$codeCompletionSessionEnabled || !$copilotInfo.enabled) && autocompletor?.dispose() }) $effect(() => { if (yContent && awareness && model && editor) { diff --git a/frontend/src/lib/components/EncryptedDraftField.svelte b/frontend/src/lib/components/EncryptedDraftField.svelte new file mode 100644 index 0000000000..645cf27fae --- /dev/null +++ b/frontend/src/lib/components/EncryptedDraftField.svelte @@ -0,0 +1,48 @@ + + + + + {#snippet trigger()} + + {/snippet} + {#snippet content()} +
+

+ This secret was encrypted when your draft was saved and cannot be loaded back. The last + saved value will be used as-is when you save. +

+ {#if !disabled} + + {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/ExternalEditLink.svelte b/frontend/src/lib/components/ExternalEditLink.svelte new file mode 100644 index 0000000000..e38fa658fc --- /dev/null +++ b/frontend/src/lib/components/ExternalEditLink.svelte @@ -0,0 +1,34 @@ + + + e.stopPropagation()} + class="group inline-flex items-center gap-1 max-w-full hover:underline {klass}" +> + {@render children()} + + diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 24c0071a5e..595b52c82d 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -2,7 +2,6 @@ import { FlowService, type Flow, - DraftService, type PathScript, type OpenFlow, type InputTransform, @@ -13,7 +12,6 @@ import { initHistory, redo, undo } from '$lib/history.svelte' import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores' import { - cleanValueProperties, generateRandomString, orderedJsonStringify, readFieldsRecursively, @@ -25,11 +23,12 @@ type Value } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { Drawer } from '$lib/components/common' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte' - import { createRawSnippet, onMount, setContext, untrack } from 'svelte' + import { createRawSnippet, setContext, untrack } from 'svelte' import { writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Button } from './common' @@ -46,7 +45,6 @@ import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { cleanFlow } from './flows/utils.svelte' import { - Save, DiffIcon, HistoryIcon, FileJson, @@ -71,18 +69,15 @@ import { tutorialsToDo } from '$lib/stores' import { getTutorialIndex } from '$lib/tutorials/config' import EditorHeader from './EditorHeader.svelte' + import AutosaveIndicator from './AutosaveIndicator.svelte' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte' import { type TriggerContext, type ScheduleTrigger } from './triggers' import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes' import DeployButton from './DeployButton.svelte' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' - import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils' - import { - deployTriggers, - filterDraftTriggers, - handleSelectTriggerFromKind - } from './triggers/utils' + import type { Trigger } from './triggers/utils' + import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils' import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte' import { Triggers } from './triggers/triggers.svelte' import { StepsInputArgs } from './flows/stepsInputArgs.svelte' @@ -118,22 +113,21 @@ disabledFlowInputs = false, savedPrimarySchedule = undefined, version = undefined, - setSavedraftCb = undefined, draftTriggersFromUrl = undefined, selectedTriggerIndexFromUrl = undefined, children, loadedFromHistoryFromUrl, noInitial = false, liveEditorDraftStoragePath = undefined, - onSaveInitial, - onSaveDraft, onDeploy, onDeployError, onDetails, - onSaveDraftError, - onSaveDraftOnlyAtNewPath, onHistoryRestore, onNavigate, + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts, onTestJob }: FlowBuilderProps = $props() @@ -262,129 +256,28 @@ } let loadingSave = $state(false) - let loadingDraft = $state(false) - export async function saveDraft(forceSave = false): Promise { - withAIChangesWarning(async () => { - await saveDraftInternal(forceSave) + // Ctrl/Cmd+S forces an immediate save of whatever the page-level + // autosave has pending. Unlike ScriptBuilder we don't have a direct + // Monaco ref to flush — any focused module Monaco's pending text is + // constrained by the editor's own ~1s max-wait cap, so the flush + // here picks up whatever's already in `pendingSaveOpts`. Worst case + // the user's very last keystroke (<1s ago) isn't in this POST and + // follows in the next autosave round. + // + // No toast — the AutosaveIndicator narrates the flush (Saving... → + // Saved / Save failed). A toast here would also lie on network + // 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 + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'flow', + path: liveEditorDraftStoragePath }) } - async function saveDraftInternal(forceSave = false): Promise { - if (!newFlow && !savedFlow) { - return - } - - if (savedFlow) { - const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow) - const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot()) - const current = cleanValueProperties( - $state.snapshot({ - ...flowStore.val, - path: $pathStore, - draft_triggers: currentDraftTriggers - }) - ) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraftInternal(true) - } - } - ]) - return - } - } - loadingDraft = true - try { - const flow = cleanFlow(flowStore.val) - if (newFlow || savedFlow?.draft_only) { - if (savedFlow?.draft_only) { - await FlowService.deleteFlowByPath({ - workspace: $workspaceStore!, - path: initialPath, - keepCaptures: true - }) - } - if (!initialPath || $pathStore != initialPath) { - await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, - path: initialPath || fakeInitialPath, - requestBody: { - new_path: $pathStore - }, - runnableKind: 'flow' - }) - } - await FlowService.createFlow({ - workspace: $workspaceStore!, - requestBody: { - path: $pathStore, - summary: flow.summary ?? '', - description: flow.description ?? '', - value: flow.value, - schema: flow.schema, - tag: flow.tag, - draft_only: true, - ws_error_handler_muted: flow.ws_error_handler_muted, - visible_to_runner_only: flow.visible_to_runner_only, - on_behalf_of_email: flow.on_behalf_of_email, - labels: (flow as any).labels - } - }) - } - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath, - typ: 'flow', - value: { - ...flow, - path: $pathStore, - draft_triggers: triggersState.getDraftTriggersSnapshot() - } - } - }) - - savedFlow = { - ...(newFlow || savedFlow?.draft_only - ? { - ...structuredClone($state.snapshot(flowStore.val)), - path: $pathStore, - draft_only: true - } - : savedFlow), - draft: { - ...structuredClone($state.snapshot(flowStore.val)), - path: $pathStore, - draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot()) - } - } as FlowWithDraftAndDraftTriggers - - let savedAtNewPath = false - if (newFlow) { - onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' }) - } else if (savedFlow?.draft_only && $pathStore !== initialPath) { - savedAtNewPath = true - initialPath = $pathStore - onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' }) - // this is so we can use the flow builder outside of sveltekit - } - onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow }) - sendUserToast('Saved as draft') - } catch (error) { - sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true) - onSaveDraftError?.({ error }) - } - loadingDraft = false - } - - onMount(() => { - setSavedraftCb?.(() => saveDraft()) - }) - export function computeUnlockedSteps(flow: Flow) { return Object.fromEntries( getAllModules(flow.value.modules, flow.value.failure_module) @@ -401,7 +294,7 @@ async function handleSaveFlowInternal(deploymentMsg?: string) { await compareVersions() - if (onLatest || initialPath == '' || savedFlow?.draft_only) { + if (onLatest || initialPath == '' || newFlow) { // Handle directly await saveFlow(deploymentMsg) } else { @@ -678,7 +571,7 @@ [ { type: 'webhook', path: '', isDraft: false }, { type: 'default_email', path: '', isDraft: false }, - ...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? []) + ...(untrack(() => draftTriggersFromUrl) ?? []) ], untrack(() => selectedTriggerIndexFromUrl) ) @@ -707,10 +600,6 @@ $primaryScheduleStore, $userStore ) - - if (savedFlow && savedFlow.draft) { - savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers - } } function handleUndo() { @@ -812,7 +701,7 @@ }> = [] if (untrack(() => customUi).topBar?.extraDeployOptions != false) { - if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) { + if (!newFlow) { dropdownItems.push({ label: 'Exit & see details', // Use the deployed path, not the live `$pathStore` — the latter @@ -846,7 +735,6 @@ diffDrawer?.setDiff({ mode: 'normal', deployed: deployedValue ?? savedFlow, - draft: savedFlow?.draft, current: { ...currentFlow, path: $pathStore, @@ -891,25 +779,7 @@ const mod = isMac() ? '⌘' : 'Ctrl+' function getMoreItems(): Item[] { - // When the top bar is compact, fold the inline Diff + Save draft buttons - // in here so they stay reachable. Save draft keeps its keyboard shortcut. - const compactExtras: Item[] = compactTopbar - ? [ - ...(customUi?.topBar?.draft !== false - ? [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: (!newFlow && !savedFlow) || loading - } - ] - : []) - ] - : [] return [ - ...compactExtras, ...baseMenuItems, { displayName: 'Undo', @@ -917,7 +787,7 @@ action: () => handleUndo(), disabled: $history.index === 0, shortcut: `${mod}Z`, - separatorTop: compactExtras.length > 0 || baseMenuItems.length > 0 + separatorTop: baseMenuItems.length > 0 }, { displayName: 'Redo', @@ -1030,17 +900,7 @@ ] } - function handleDeployTrigger(trigger: Trigger) { - const { id, path, type } = trigger - //Update the saved flow to remove the draft trigger that is deployed - if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) { - const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter( - (t) => t.id !== id || t.path !== path || t.type !== type - ) - savedFlow.draft.draft_triggers = - newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined - } - } + function handleDeployTrigger(_trigger: Trigger) {} let forceTestTab: Record = $state({}) let highlightArg: Record = $state({}) @@ -1070,6 +930,29 @@ if (p) untrack(() => ($pathStore = p)) }) + // Persist the user-typed path into the draft JSON as `draft_path` + // when it differs from the deployed/seeded `flow.path`. The Path + // widget binds `$pathStore` one-way to the popover input — without + // this, the friendly auto-name on `/flows/add` and any in-place + // rename never reach the autosaved Flow, so the home-list draft row + // kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop + // the field once it matches the baseline again so it doesn't + // linger after a revert; deploy clears the whole draft, so the + // field naturally disappears post-deploy too. + $effect(() => { + const typed = $pathStore + const baseline = (flowStore.val as Flow | undefined)?.path ?? '' + const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined + if (!flow) return + untrack(() => { + if (typed && typed !== baseline) { + flow.draft_path = typed + } else if (flow.draft_path !== undefined) { + delete (flow as any).draft_path + } + }) + }) + $effect.pre(() => { selectedId && untrack(() => select(selectedId)) }) @@ -1187,13 +1070,13 @@ -
+
-
+
onNavigate?.(item)} /> + {#if $workspaceStore && liveEditorDraftStoragePath !== undefined} + + {/if}
{#if $enterpriseLicense && !newFlow} @@ -1219,9 +1114,11 @@ variant="default" unifiedSize="md" on:click={() => openDiffDrawer()} - disabled={!savedFlow} + disabled={!savedFlow || newFlow} iconOnly={compactTopbar} - title="Diff" + title={newFlow + ? 'Deploy this flow once to compare against the deployed version' + : 'Diff'} startIcon={{ icon: DiffIcon }} > Diff @@ -1230,19 +1127,6 @@ {#if !compactTopbar} {@render previewButtons()} {/if} - {#if customUi?.topBar?.draft !== false && !compactTopbar} - - {/if} await handleSaveFlow(detail)} diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index eb9bbea4e7..bf06628cc0 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -132,7 +132,8 @@ initialPathStore, fakeInitialPath, customUi, - executionCount + executionCount, + devTempScriptRefs } = $state(getContext('FlowEditorContext')) const dispatch = createEventDispatcher() @@ -193,7 +194,14 @@ flowProgressBar?.reset() const newFlow = extractFlow(previewMode) args = await processSecretArgs(args, flowStore.val.schema as any) - newJobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom, conversationId) + newJobId = await runFlowPreview( + args, + newFlow, + $pathStore, + restartedFrom, + conversationId, + devTempScriptRefs?.() + ) jobId = newJobId isRunning = true if (inputSelected) { diff --git a/frontend/src/lib/components/FolderEditor.svelte b/frontend/src/lib/components/FolderEditor.svelte index d3a0df4e90..685743254a 100644 --- a/frontend/src/lib/components/FolderEditor.svelte +++ b/frontend/src/lib/components/FolderEditor.svelte @@ -25,6 +25,8 @@ import { Minimatch } from 'minimatch' import Tooltip from './Tooltip.svelte' import CollapseLink from './CollapseLink.svelte' + import LabelsInput from './LabelsInput.svelte' + import Badge from './common/badge/Badge.svelte' interface Props { name: string @@ -76,6 +78,7 @@ try { folder = await FolderService.getFolder({ workspace: $workspaceStore!, name }) summary = folder.summary ?? '' + labels = [...(folder.labels ?? [])] defaultPermissionedAs = (folder.default_permissioned_as ?? []).map((r) => ({ ...r })) can_write = $userStore != undefined && @@ -198,6 +201,22 @@ let groupCreated: string | undefined = $state(undefined) let newGroupName: string = $state('') let summary: string = $state('') + let labels: string[] | undefined = $state(undefined) + + async function saveLabels() { + try { + await FolderService.updateFolder({ + workspace: $workspaceStore ?? '', + name, + requestBody: { labels: labels ?? [] } + }) + sendUserToast('Folder labels updated') + dispatch('update') + } catch (e) { + sendUserToast(e.body ?? String(e), true) + loadFolder() + } + } async function addGroup() { await GroupService.createGroup({ @@ -274,6 +293,26 @@
+ +
{/snippet} @@ -134,13 +138,22 @@ bind:value={editSummary} /> - { - labelsDirty = true - }} - /> +
+ { + labelsDirty = true + }} + /> + {#if inheritedLabels?.length} + + {/if} +
+ {#if inheritedLabels?.length} +

+ Gray labels are inherited from the folder and can only be edited there. +

+ {/if}
{#if $mode !== 'preview'} @@ -1210,19 +1073,6 @@ - {#if !compactTopbar} - - {/if} - {/if} {/snippet} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 64b08d621e..8eef469472 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -149,11 +149,9 @@ export interface AppEditorProps { savedApp?: | { value: App - draft?: any path: string summary: string policy: any - draft_only?: boolean custom_path?: string } | undefined @@ -162,20 +160,18 @@ export interface AppEditorProps { newPath?: string | undefined replaceStateFn?: (path: string) => void gotoFn?: (path: string, opt?: Record | undefined) => void - unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void - /** - * Backend revs at the load that produced `app`. Used as the seed - * `UserDraft` meta on the first local autosave: until the handle has - * its own meta (set on a previous reload, or by route backfill), the - * mirror `$effect` injects these revs so the next reload's staleness - * check has something to compare the current backend rev against. - * Without this, the first deploy-after-edit can't be detected as - * drift — `previousMeta` would be empty and the modal wouldn't fire. - */ - initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta + // Threaded through `AppEditorHeader` to the `AutosaveIndicator` + // popover so its "Reset to deployed" button can do the same thing + // the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the app editor's + // indicator. Threaded through AppEditorHeader. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void } export type App = { @@ -198,6 +194,15 @@ export type App = { hideLegacyTopBar?: boolean | undefined mobileViewOnSmallerScreens?: boolean | undefined version?: number + /** + * User-typed path persisted on the autosaved App when it differs from + * the deployed/seeded baseline. The home list renders it so a friendly + * name shows up instead of the autogenerated `u/{user}/draft_{uuid}` + * URL slot for renames-in-progress and brand-new drafts. Dropped from + * the JSON once the typed path matches the baseline again, and deploy + * clears the whole draft. + */ + draft_path?: string } export type ConnectingInput = { diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index 9dee3b6e87..1c7e6052eb 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -41,14 +41,21 @@ function onKeyDown(event: KeyboardEvent) { if (open && keyListen) { - event.stopPropagation() - event.preventDefault() + // Only intercept Enter/Escape (without modifiers) so shortcuts like + // Cmd/Ctrl+C and Cmd/Ctrl+V keep working inside the modal. + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } switch (event.key) { case 'Enter': + event.stopPropagation() + event.preventDefault() dispatch('confirmed') onConfirmed?.() break case 'Escape': + event.stopPropagation() + event.preventDefault() dispatch('canceled') onCanceled?.() break diff --git a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte new file mode 100644 index 0000000000..8bd9eeebd1 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte @@ -0,0 +1,102 @@ + + +{#if enabled && workspace && path} + + {#if otherDraftsUsers.length > 0} + {#key path} + + {/key} + {/if} + {#if onLoadLatestDeploy} + + {/if} +{/if} diff --git a/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte new file mode 100644 index 0000000000..ca467fd571 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte @@ -0,0 +1,92 @@ + + + +
+
+ +
+

+ Another tab, browser, or AI agent saved a newer version of this draft. Your autosave was + rejected to avoid overwriting their work. +

+ {#if conflictHandle.conflict} +

+ Server timestamp: {new Date(conflictHandle.conflict.serverTimestamp).toLocaleString()} +

+ {/if} +
+
+ +
+ + + +
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte b/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte deleted file mode 100644 index 94782f176e..0000000000 --- a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - - -{#if open} - -{/if} diff --git a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte new file mode 100644 index 0000000000..493931e640 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte @@ -0,0 +1,169 @@ + + + +
+
+ +

+ Their drafts are independent of yours. For advanced collaboration, consider using workspace forks (EE) +

+
+ +
    + {#each otherDraftsUsers as owner (ownerKey(owner))} +
  • +
    + + {ownerLabel(owner)} + + {#if !owner.username} + + Pre-migration workspace-scoped draft (no owner). Saved before drafts became per-user + — kept around so you can recover the content, but no current user owns it. + + {/if} +
    + + +
  • + {/each} +
+ +
+ +
+
+
+ + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonValue ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte b/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte new file mode 100644 index 0000000000..cc027c6f12 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte @@ -0,0 +1,86 @@ + + + +
+
+ +
+

+ A newer version was deployed after you started editing. Your draft is based on the older + deploy. +

+

+ Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)} +

+
+
+ +
+ + +
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte index 1305d611c1..b39a718572 100644 --- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte @@ -2,8 +2,6 @@ import ConfirmationModal from './ConfirmationModal.svelte' import { beforeNavigate } from '$app/navigation' import { goto as gotoUrl } from '$app/navigation' - import Button from '../button/Button.svelte' - import type DiffDrawer from '$lib/components/DiffDrawer.svelte' import { cleanValueProperties, orderedJsonStringify, @@ -16,7 +14,6 @@ interface Props { getInitialAndModifiedValues?: GetInitialAndModifiedValues - diffDrawer?: DiffDrawer | undefined additionalExitAction?: () => void triggerOnSearchParamsChange?: boolean onDiscardChanges?: () => void @@ -25,7 +22,6 @@ let { getInitialAndModifiedValues = undefined, - diffDrawer = undefined, additionalExitAction = () => {}, triggerOnSearchParamsChange = false, onDiscardChanges = undefined, @@ -38,6 +34,26 @@ let open = $state(false) let goingTo: URL | undefined = $state(undefined) + // Mirrors the modal condition: dirty when values differ, or when either + // value is missing (e.g. a never-saved draft). Also refreshes + // savedValue/modifiedValue for the diff drawer. + function hasUnsavedChanges(): boolean { + const state = getInitialAndModifiedValues?.() + savedValue = state?.savedValue + modifiedValue = state?.modifiedValue + + if (savedValue && modifiedValue) { + const draftOrDeployed = cleanValueProperties((savedValue.draft || savedValue) ?? {}) + const current = cleanValueProperties(modifiedValue ?? {}) + + return ( + orderedJsonStringify(replaceFalseWithUndefined(draftOrDeployed)) !== + orderedJsonStringify(replaceFalseWithUndefined(current)) + ) + } + return true + } + beforeNavigate(async (newNavigationState) => { if ( !bypassBeforeNavigate && @@ -49,38 +65,32 @@ ) { goingTo = newNavigationState.to.url - const state = getInitialAndModifiedValues?.() - savedValue = state?.savedValue - modifiedValue = state?.modifiedValue - - async function openModal() { + if (hasUnsavedChanges()) { newNavigationState.cancel() open = true - } - if (savedValue && modifiedValue) { - const draftOrDeployed = cleanValueProperties((savedValue.draft || savedValue) ?? {}) - const current = cleanValueProperties(modifiedValue ?? {}) - - if ( - orderedJsonStringify(replaceFalseWithUndefined(draftOrDeployed)) === - orderedJsonStringify(replaceFalseWithUndefined(current)) - ) { - if (!tabMode) { - bypassBeforeNavigate = true - } - additionalExitAction?.() - } else { - await openModal() - } } else { - await openModal() + if (!tabMode) { + bypassBeforeNavigate = true + } + additionalExitAction?.() } } else if (bypassBeforeNavigate) { bypassBeforeNavigate = false } }) + + function onBeforeUnload(event: BeforeUnloadEvent) { + if (!bypassBeforeNavigate && getInitialAndModifiedValues && hasUnsavedChanges()) { + // Triggers the browser's native "leave site?" confirmation + event.preventDefault() + // Required by some browsers (legacy mechanism) + event.returnValue = true + } + } + + {#if open}
Are you sure you want to discard the changes you have made? - {#if savedValue && modifiedValue && diffDrawer} - - {/if}
diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index 36fbb23c5f..fcce008ad7 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -15,8 +15,15 @@ target?: string isOpen?: boolean fixedWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' - fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + /** `adaptive` sizes the modal to its content (no fixed height, + * still capped by max-h-screen-80). */ + fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'adaptive' contentClasses?: string + /** Close when the user clicks outside the modal body. Default + * true. Set false when the caller stacks a child modal on top + * and clicks "outside" the child would otherwise propagate + * here and close the underlying modal. */ + closeOnOutsideClick?: boolean headerLeft?: import('svelte').Snippet headerRight?: import('svelte').Snippet children?: import('svelte').Snippet @@ -25,11 +32,15 @@ let { title, css = {}, - target = '', + // Forwarded to `Portal`. An empty string would hit + // `document.querySelector('')` and throw "The provided selector + // is empty" — match `Portal`'s own default instead. + target = 'body', isOpen = $bindable(false), fixedWidth = 'md', fixedHeight = 'md', contentClasses = '', + closeOnOutsideClick = true, headerLeft, headerRight, children @@ -49,7 +60,9 @@ md: '500px', lg: '720px', xl: '800px', - xxl: '1000px' + xxl: '1000px', + // Content-driven height — emit no `height:` rule at all. + adaptive: undefined } export function close() { @@ -61,6 +74,7 @@ } function handleKeyDown(event: KeyboardEvent) { + if (!isOpen) return if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() @@ -83,15 +97,17 @@ >
close() }} + use:clickOutside={{ + onClickOutside: () => closeOnOutsideClick && close() + }} >
diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 1c0c5b08d1..5682bd47e0 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -3,13 +3,15 @@ import Dropdown from '$lib/components/DropdownV2.svelte' import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { AppService, DraftService, type ListableApp } from '$lib/gen' + import { AppService, type ListableApp } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' + import InheritedLabels from '$lib/components/InheritedLabels.svelte' import Badge from '../badge/Badge.svelte' import { ExternalLink, @@ -28,7 +30,7 @@ import { goto as gotoUrl } from '$app/navigation' import { page } from '$app/state' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import { DELETE, copyToClipboard } from '$lib/utils' + import { copyToClipboard } from '$lib/utils' import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte' import { isDeployable } from '$lib/utils_deployable' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' @@ -37,7 +39,7 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } + app: ListableApp & { draft_only?: boolean; canWrite: boolean } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -64,11 +66,31 @@ const dispatch = createEventDispatcher() - let appExport: { open: (path: string) => void } | undefined = $state(undefined) + let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined) let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined) async function loadAppJson() { - appExport?.open(app.path) + // Thread the row's `raw_app` flag so the JSON drawer's backend + // fetch picks the right draft kind on draft-only items (no + // deployed row to read the kind from server-side). + appExport?.open(app.path, !!app.raw_app) + } + + async function deleteApp(path: string): Promise { + // Draft-only items have no deployed row — the regular route would + // 404. Route the delete through the syncer instead; the `app` vs + // `raw_app` choice mirrors the row's own `raw_app` flag. + if (app.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore ?? '', + itemKind: app.raw_app ? 'raw_app' : 'app', + path, + value: null, + immediate: true + }) + } else { + await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + } } @@ -83,8 +105,8 @@ href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}" kind="app" {marked} - path={app.path} - summary={app.summary} + path={(app as any).draft_path ?? app.path} + summary={app.is_draft ? `${app.summary || (app as any).draft_path || app.path}*` : app.summary} workspaceId={app.workspace_id ?? $workspaceStore ?? ''} canFavorite={!app.draft_only} {depth} @@ -98,7 +120,15 @@ Raw {/if} - + {#if app.labels?.length}
{#each app.labels.slice(0, 3) as label} @@ -117,6 +147,7 @@ {/if}
{/if} +
{/snippet} {#snippet actions()} @@ -130,7 +161,7 @@ variant="subtle" wrapperClasses="w-20" startIcon={{ icon: Pen }} - href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}?nodraft=true" + href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}" > Edit @@ -155,7 +186,7 @@ aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`} aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`} items={async () => { - let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app + let { draft_only, canWrite, summary, execution_mode, path } = app const canEdit = canWrite && showEditButton if (draft_only) { @@ -167,11 +198,11 @@ // TODO // @ts-ignore if (event?.shiftKey) { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } else { deleteConfirmedCallback = async () => { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } } @@ -271,25 +302,6 @@ } ] : []), - ...(has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path, - kind: 'app' - }) - dispatch('change') - }, - type: DELETE, - disabled: !canWrite, - hide: $userStore?.operator - } - ] - : []), { displayName: 'Delete', icon: Trash, @@ -297,11 +309,11 @@ // TODO // @ts-ignore if (event?.shiftKey) { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } else { deleteConfirmedCallback = async () => { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } } diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 14ee1a3287..3dca4ac60d 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -5,16 +5,17 @@ import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { FlowService, type Flow, DraftService } from '$lib/gen' + import { FlowService, type Flow } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { DELETE, copyToClipboard, isOwner } from '$lib/utils' + import { copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -33,13 +34,20 @@ HistoryIcon } from 'lucide-svelte' import FlowHistory from '$lib/components/flows/FlowHistory.svelte' + import InheritedLabels from '$lib/components/InheritedLabels.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' interface Props { - flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } + flow: Flow & { + draft_only?: boolean + is_draft?: boolean + draft_path?: string + draft_users?: { username?: string | null }[] + canWrite: boolean + } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -84,7 +92,20 @@ async function deleteFlow(path: string): Promise { try { - await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path }) + // Draft-only items have no deployed row to delete — the regular + // route would 404. Route the delete through the syncer so the + // per-user draft row is removed instead. + if (flow.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore!, + itemKind: 'flow', + path, + value: null, + immediate: true + }) + } else { + await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path }) + } dispatch('change') sendUserToast(`Deleted flow ${path}`) } catch (err) { @@ -104,13 +125,13 @@ aiId={`flow-row-${flow.path}`} aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`} href={flow.draft_only - ? `${base}/flows/edit/${flow.path}?nodraft=true` + ? `${base}/flows/edit/${flow.path}` : `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`} kind="flow" workspaceId={flow.workspace_id ?? $workspaceStore ?? ''} {marked} - path={flow.path} - summary={flow.summary} + path={flow.draft_path ?? flow.path} + summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary} {errorHandlerMuted} canFavorite={!flow.draft_only} {depth} @@ -121,7 +142,15 @@ archived {/if} - + {#if flow.labels?.length}
{#each flow.labels.slice(0, 3) as label} @@ -140,6 +169,7 @@ {/if}
{/if} +
{/snippet} {#snippet actions()} @@ -152,7 +182,7 @@ wrapperClasses="w-20" unifiedSize="md" startIcon={{ icon: Pen }} - href="{base}/flows/edit/{flow.path}?nodraft=true" + href="{base}/flows/edit/{flow.path}" aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`} > @@ -180,7 +210,7 @@ aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`} items={async () => { - let { draft_only, path, archived, has_draft } = flow + let { draft_only, path, archived } = flow let owner = isOwner(path, $userStore, $workspaceStore) const canEdit = flow.canWrite && showEditButton if (draft_only) { @@ -293,25 +323,6 @@ disabled: !owner || !canEdit, hide: $userStore?.operator }, - ...(has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path, - kind: 'flow' - }) - dispatch('change') - }, - type: DELETE, - disabled: !owner, - hide: $userStore?.operator - } - ] - : []), { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 939519522f..b90fb385d8 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -14,6 +14,10 @@ disabled?: boolean canFavorite?: boolean isSelectable?: boolean + /** When true, clicking anywhere on the row card (except interactive + * children — checkbox, buttons, links) toggles selection. Opt-in so + * existing tables that don't want it are unaffected. */ + selectOnRowClick?: boolean alignWithSelectable?: boolean errorHandlerMuted?: boolean aiId?: string | undefined @@ -62,6 +66,7 @@ disabled = false, canFavorite = true, isSelectable = false, + selectOnRowClick = false, alignWithSelectable = false, errorHandlerMuted = false, aiId = undefined, @@ -92,6 +97,32 @@ rowEl?.scrollIntoView({ block: 'nearest' }) } }) + + const clickToSelect = $derived(selectOnRowClick && isSelectable && !disabled) + + // Interactive children that handle their own activation — selecting the row on + // top of them would double-fire (mouse) or hijack their keyboard activation. + function fromInteractiveChild(e: Event): boolean { + return !!(e.target as HTMLElement | null)?.closest('a, button, input, [data-row-actions]') + } + + function handleRowClick(e: MouseEvent) { + if (!clickToSelect) return + // Don't double-toggle when the click originated from an interactive child + // (the checkbox itself, action buttons, or the title link). + if (fromInteractiveChild(e)) return + onSelect?.(e as unknown as Event & { currentTarget: EventTarget & HTMLInputElement }) + } + + function handleRowKeydown(e: KeyboardEvent) { + if (!clickToSelect) return + if (e.key !== 'Enter' && e.key !== ' ') return + // Same guard as the click path: activating a child (checkbox / action button + // / title link) via Enter/Space must not also toggle the row's selection. + if (fromInteractiveChild(e)) return + e.preventDefault() + onSelect?.(e as unknown as Event & { currentTarget: EventTarget & HTMLInputElement }) + } {#if href} @@ -112,9 +143,14 @@ 'w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0', depth > 0 ? '!rounded-none' : '', disabled ? 'opacity-25' : 'hover:bg-surface-hover', + clickToSelect ? 'cursor-pointer select-none' : '', selected ? 'bg-surface-accent-selected' : keyboardSelected ? 'bg-gray-200 dark:bg-gray-700' : '' )} style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''} + role={clickToSelect ? 'button' : undefined} + tabindex={clickToSelect ? 0 : undefined} + onclick={handleRowClick} + onkeydown={clickToSelect ? handleRowKeydown : undefined} > {#if isSelectable} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 3d0aec796b..be7f63dace 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -5,18 +5,19 @@ import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { ScriptService, type Script, DraftService } from '$lib/gen' + import { ScriptService, type Script } from '$lib/gen' import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { capitalize, copyToClipboard, DELETE, isOwner } from '$lib/utils' + import { capitalize, copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -42,6 +43,7 @@ import WacExportDrawer from '$lib/components/scripts/WacExportDrawer.svelte' import { Drawer, DrawerContent } from '..' import NoMainFuncBadge from '$lib/components/NoMainFuncBadge.svelte' + import InheritedLabels from '$lib/components/InheritedLabels.svelte' import Popover from '$lib/components/Popover.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' @@ -51,7 +53,13 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - script: Script & { canWrite: boolean; use_codebase: boolean } + script: Script & { + canWrite: boolean + use_codebase: boolean + is_draft?: boolean + draft_path?: string + draft_users?: { username?: string | null }[] + } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -103,7 +111,20 @@ } async function deleteScript(path: string): Promise { - await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path }) + // Draft-only items have no deployed row to delete — the regular + // route would 404. Route the delete through the syncer so the + // per-user draft row is removed instead. + if (script.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore!, + itemKind: 'script', + path, + value: null, + immediate: true + }) + } else { + await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path }) + } dispatch('change') sendUserToast(`Deleted script ${path}`) } @@ -126,8 +147,10 @@ : `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`} kind="script" {marked} - path={script.path} - summary={script.summary} + path={script.draft_path ?? script.path} + summary={script.is_draft + ? `${script.summary || script.draft_path || script.path}*` + : script.summary} {errorHandlerMuted} workspaceId={$workspaceStore ?? ''} canFavorite={!script.draft_only} @@ -168,7 +191,15 @@ > {/if} - + {#if script.labels?.length}
{#each script.labels.slice(0, 3) as label} @@ -187,6 +218,7 @@ {/if}
{/if} +
@@ -404,25 +436,6 @@ hide: $userStore?.operator }, - ...(script.has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path: script.path, - kind: 'script' - }) - dispatch('change') - }, - type: DELETE, - disabled: !owner, - hide: $userStore?.operator - } - ] - : []), ...($userStore?.is_admin || $userStore?.is_super_admin ? [ { diff --git a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts index b2484ff909..0ee2ad3f78 100644 --- a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts +++ b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts @@ -3,7 +3,8 @@ import { sleep } from '$lib/utils' import { editor as meditor, Position, languages, type IDisposable } from 'monaco-editor' import { LRUCache } from 'lru-cache' import { autocompleteRequest } from './request' -import { FIM_MAX_TOKENS, getModelContextWindow } from '../lib' +import { FIM_MAX_TOKENS } from '../lib' +import { getModelContextWindow } from '../modelConfig' import { setGlobalCSS } from '../shared' import { supportsAutocomplete } from '../utils' import { get } from 'svelte/store' diff --git a/frontend/src/lib/components/copilot/chat/AIButton.svelte b/frontend/src/lib/components/copilot/chat/AIButton.svelte index 139c524230..0234810a8b 100644 --- a/frontend/src/lib/components/copilot/chat/AIButton.svelte +++ b/frontend/src/lib/components/copilot/chat/AIButton.svelte @@ -1,6 +1,7 @@ -
+ +
{#if !hideHeader}
{#if waitingForUserAction} Waiting for your input {:else} - + {/if}
{/if} @@ -503,6 +536,8 @@ {#if inputPreface} {@render inputPreface()} {/if} + + {/if} + {#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} {#if appContext.inspectorElement} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 2780930b79..4fb15b224b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -13,6 +13,8 @@ import { ArrowUp, Square } from 'lucide-svelte' import { Button } from '$lib/components/common' import { sendUserToast } from '$lib/toast' + import { type PasteAttachment } from './pasteTokens' + import { chatDraft, expanded } from './chatDraft' const aiChatManager = getAiChatManager() @@ -23,6 +25,7 @@ disabled?: boolean placeholder?: string initialInstructions?: string + initialPastes?: PasteAttachment[] editingMessageIndex?: number | null onEditEnd?: () => void className?: string @@ -47,6 +50,7 @@ isFirstMessage = false, placeholder, initialInstructions = '', + initialPastes = undefined, editingMessageIndex = null, onEditEnd = () => {}, className = '', @@ -113,6 +117,8 @@ let contextTextareaComponent: ContextTextarea | undefined = $state() let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state() let instructions = $state(untrack(() => initialInstructions)) + // Collapsed big-paste blobs referenced by tokens in `instructions`. + let pastes = $state(untrack(() => initialPastes ?? [])) // App mode @ mention state let showAppContextTooltip = $state(false) @@ -173,6 +179,15 @@ } } + // Restore composer contents after a rolled-back turn. No-op when the user + // already typed a new draft — restoring would clobber it. + export function restoreInstructions(value: string, restoredPastes: PasteAttachment[] = []) { + if (instructions.trim()) return + instructions = value + pastes = restoredPastes + focusInput() + } + function clickOutside(node: HTMLElement) { function handleClick(event: MouseEvent) { if (node && !node.contains(event.target as Node)) { @@ -258,10 +273,10 @@ return } if (editingMessageIndex !== null) { - aiChatManager.restartGeneration(editingMessageIndex, instructions) + aiChatManager.restartGeneration(editingMessageIndex, instructions, pastes) onEditEnd() } else { - aiChatManager.sendRequest({ instructions }) + aiChatManager.sendRequest({ instructions, pastes }) // clearForSend() pre-zaps the textarea's mention-sync so the wipe // doesn't drop `selectedContext` before `AIChatManager.beforeSend` // snapshots it. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the @@ -269,6 +284,18 @@ // reset (no `@`-mention state to coordinate). contextTextareaComponent?.clearForSend() instructions = '' + pastes = [] + } + } + + // A custom `onSendRequest` consumer (e.g. the inline ⌘K widget) has no chip + // display, so it gets the fully expanded text; the default path keeps tokens + // for the conversation bubble and expands them for the LLM inside the manager. + function submitRequest() { + if (onSendRequest) { + onSendRequest(expanded(chatDraft(instructions, pastes))) + } else { + sendRequest() } } @@ -485,7 +512,7 @@ if (isLoading) { onCancel ? onCancel() : aiChatManager.cancel() } else if (!sendDisabled) { - onSendRequest ? onSendRequest(instructions) : sendRequest() + submitRequest() } }} /> @@ -529,6 +556,7 @@ { setTimeout(() => { @@ -625,7 +653,7 @@
@@ -437,4 +751,12 @@ width: 100%; min-height: 2.25rem; } + + /* Hide the textarea's own glyphs (the highlight overlay renders the text) + while keeping the caret visible. Toggled via a class rather than an inline + `style` so it never clobbers the inline height set by the autosize action. */ + .transparent-text { + color: transparent; + -webkit-text-fill-color: transparent; + } diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte new file mode 100644 index 0000000000..4fe1c342f0 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -0,0 +1,45 @@ + + +{#if visible} +
+ + context window usage: ~{formatTokenCount(usedTokens)}{contextWindow + ? ` / ${formatTokenCount(contextWindow)}` + : ''} + +
+{/if} diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index d07f6943bd..c11c0c3907 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -1,7 +1,17 @@ -import { openDB, type DBSchema as IDBSchema, type IDBPDatabase } from 'idb' +import { type DBSchema as IDBSchema, type IDBPDatabase } from 'idb' import type { DisplayMessage } from './shared' +import { expanded, messageDraft } from './chatDraft' import { createLongHash } from '$lib/editorLangUtils' +import { userScopedDb, type UserScopedDbMigrateDeps } from '$lib/userScopedDb' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' +import type { PersistedContextUsage } from './tokenUsage' + +// Base IndexedDB name; userScopedDb namespaces the effective DB by the logged-in +// user's email so chat messages are never physically shared across users on a +// shared browser. The bare name is also the legacy (pre-namespacing) DB, claimed +// once on first login. +const DB_NAME = 'copilot-chat-history' + interface ChatSchema extends IDBSchema { chats: { key: string @@ -12,12 +22,73 @@ interface ChatSchema extends IDBSchema { title: string lastModified: number sessionId?: string + // New writes store the plain reported token count; chats persisted by + // earlier versions may still hold the legacy anchor object. + contextUsage?: PersistedContextUsage } } } +function createChatStore(db: IDBPDatabase): void { + if (!db.objectStoreNames.contains('chats')) { + db.createObjectStore('chats', { keyPath: 'id' }) + } +} + +// Shared across all HistoryManager instances. Each instance owns its own +// userScopedDb handle (see below), so without this the legacy claim could run +// concurrently in several instances on first login — and racing `deleteDB`s can +// block on each other's still-open legacy connections. Deduping to a single +// session-wide promise restores the "runs exactly once" guarantee the pre-factory +// module-level guard had. Reset on failure so a later instance can retry. +let legacyChatClaim: Promise | undefined + +async function migrateLegacyChatDb( + scopedDb: IDBPDatabase, + deps: UserScopedDbMigrateDeps +): Promise { + legacyChatClaim ??= claimLegacyChatDb(scopedDb, deps).catch((e) => { + legacyChatClaim = undefined + throw e + }) + return legacyChatClaim +} + +// One-shot claim of the pre-namespacing chat-history DB: when the user-scoped +// DB has no chats yet (first login on a previously single-user browser), copy +// every record from the legacy un-namespaced DB into it, then delete the legacy +// DB. Both session-tagged and untagged chats belong to the prior single browser +// user, so all are claimed. Subsequent users start with an empty DB. +async function claimLegacyChatDb( + scopedDb: IDBPDatabase, + { openDB, deleteDB }: UserScopedDbMigrateDeps +): Promise { + if ((await scopedDb.count('chats')) > 0) return + const legacy = await openDB(DB_NAME, 1, { upgrade: createChatStore }) + const legacyChats = await legacy.getAll('chats') + if (legacyChats.length > 0) { + const tx = scopedDb.transaction('chats', 'readwrite') + await Promise.all([...legacyChats.map((c) => tx.store.put(c)), tx.done]) + } + legacy.close() + await deleteDB(DB_NAME) +} + +// Test-only: reset the session-wide legacy-claim guard so suites can exercise +// the migration deterministically regardless of test order. +export function __resetLegacyChatClaimForTesting(): void { + legacyChatClaim = undefined +} + export default class HistoryManager { - private indexDB: IDBPDatabase | undefined = undefined + // Per-instance handle to the shared per-user DB lifecycle. There is one + // HistoryManager per AIChatManager (the singleton + one per session runtime), + // so the handle must be per-instance — not a module singleton. + private dbh = userScopedDb(DB_NAME, { + version: 1, + upgrade: createChatStore, + migrate: migrateLegacyChatDb + }) private savedChats: Record< string, @@ -28,6 +99,7 @@ export default class HistoryManager { id: string lastModified: number sessionId?: string + contextUsage?: PersistedContextUsage } > = $state({}) @@ -46,16 +118,13 @@ export default class HistoryManager { ) async init() { + // whenReady() is email-gated (returns undefined before the user is known — + // all callers run post-login, and the singleton re-inits via onUserChange), + // runs the legacy migration once, and reopens automatically on user change. + const db = await this.dbh.whenReady() + if (!db) return try { - this.indexDB = await openDB('copilot-chat-history', 1, { - upgrade(indexDB) { - if (!indexDB.objectStoreNames.contains('chats')) { - indexDB.createObjectStore('chats', { keyPath: 'id' }) - } - } - }) - - const chats = await this.indexDB.getAll('chats') + const chats = await db.getAll('chats') this.savedChats = chats.reduce( (acc, chat) => { acc[chat.id] = chat @@ -64,13 +133,12 @@ export default class HistoryManager { {} as typeof this.savedChats ) } catch (err) { - console.error('Could not open chat history database', err) - return {} + console.error('Could not load chat history', err) } } close() { - this.indexDB?.close() + this.dbh.close() } getCurrentChatId() { @@ -91,9 +159,10 @@ export default class HistoryManager { const snapshot = $state.snapshot(existing) const updated = { ...snapshot, sessionId } this.savedChats = { ...this.savedChats, [chatId]: updated } - if (this.indexDB) { - await this.indexDB.put('chats', updated) - } + // Resolve the DB via the handle (not a cached ref) so a write always lands + // in the current user's DB, even after an in-place user switch. + const db = await this.dbh.whenReady() + if (db) await db.put('chats', updated) } getPastChats() { @@ -104,8 +173,15 @@ export default class HistoryManager { return Object.values(this.savedChats) } - async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { + async saveChat( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextUsage?: number + ) { if (displayMessages.length > 0) { + // Expand any collapsed-paste tokens so the title is readable text, not + // the chip label + its zero-width id chars. + const title = expanded(messageDraft(displayMessages[0])).slice(0, 50) // we don't want to save the snapshot in the history const updatedChat = { actualMessages: $state.snapshot(messages), @@ -113,24 +189,28 @@ export default class HistoryManager { ...m, snapshot: undefined })), - title: displayMessages[0].content.slice(0, 50), + title, id: this.currentChatId, lastModified: Date.now(), - ...(this.sessionId ? { sessionId: this.sessionId } : {}) + ...(this.sessionId ? { sessionId: this.sessionId } : {}), + ...(contextUsage !== undefined ? { contextUsage } : {}) } this.savedChats = { ...this.savedChats, [updatedChat.id]: updatedChat } - if (this.indexDB) { - await this.indexDB.put('chats', updatedChat) - } + const db = await this.dbh.whenReady() + if (db) await db.put('chats', updatedChat) } } - async save(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { - await this.saveChat(displayMessages, messages) + async save( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextUsage?: number + ) { + await this.saveChat(displayMessages, messages, contextUsage) this.currentChatId = createLongHash() } @@ -138,7 +218,7 @@ export default class HistoryManager { this.savedChats = Object.fromEntries( Object.entries(this.savedChats).filter(([key]) => key !== id) ) - this.indexDB?.delete('chats', id) + void this.dbh.whenReady().then((db) => db?.delete('chats', id)) } loadPastChat(id: string) { diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts new file mode 100644 index 0000000000..96e304fa98 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { IDBFactory } from 'fake-indexeddb' +import { openDB } from 'idb' + +// scopedKey resolves the email from userStore via a BROWSER-gated subscription. +vi.mock('esm-env', async (importOriginal) => ({ + ...(await importOriginal()), + BROWSER: true +})) + +import { userStore, type UserExt } from '$lib/stores' +import HistoryManager, { __resetLegacyChatClaimForTesting } from './HistoryManager.svelte' +import type { DisplayMessage } from './shared' +import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' + +function asUser(email: string): UserExt { + return { email, username: email.split('@')[0] } as unknown as UserExt +} + +type LegacyChat = { + id: string + actualMessages: unknown[] + displayMessages: unknown[] + title: string + lastModified: number + sessionId?: string +} + +async function seedLegacyChatDb(records: LegacyChat[]) { + const db = await openDB('copilot-chat-history', 1, { + upgrade(d) { + if (!d.objectStoreNames.contains('chats')) d.createObjectStore('chats', { keyPath: 'id' }) + } + }) + for (const r of records) await db.put('chats' as never, r as never) + db.close() +} + +beforeEach(() => { + ;(globalThis as any).indexedDB = new IDBFactory() + __resetLegacyChatClaimForTesting() + userStore.set(asUser('admin@test')) +}) + +async function countChats(dbName: string): Promise { + const db = await openDB(dbName) + if (!db.objectStoreNames.contains('chats')) { + db.close() + return 0 + } + const n = await db.count('chats' as never) + db.close() + return n +} + +describe('HistoryManager legacy chat-history migration', () => { + it('claims the legacy un-namespaced DB into the per-user DB, then deletes it', async () => { + await seedLegacyChatDb([ + { id: 'c1', actualMessages: [], displayMessages: [], title: 'Old chat', lastModified: 1 }, + { + id: 'c2', + actualMessages: [], + displayMessages: [], + title: 'Tagged chat', + lastModified: 2, + sessionId: 's9' + } + ]) + + const hm = new HistoryManager() + await hm.init() + + // Both session-tagged and untagged legacy chats are claimed. + expect( + hm + .getAllSavedChats() + .map((c) => c.id) + .sort() + ).toEqual(['c1', 'c2']) + + const names = (await indexedDB.databases()).map((d) => d.name) + expect(names).toContain('copilot-chat-history::admin@test') + // Bare legacy DB is gone so a later different user does not re-claim it. + expect(names).not.toContain('copilot-chat-history') + }) + + it('starts empty and does not throw when there is no legacy DB', async () => { + const hm = new HistoryManager() + await hm.init() + expect(hm.getAllSavedChats()).toEqual([]) + }) + + it('writes land in the current user DB after an in-place user switch', async () => { + const hm = new HistoryManager() + await hm.init() + + // Save under user A. + await hm.save( + [{ role: 'user', content: 'hello A' }] as DisplayMessage[], + [] as ChatCompletionMessageParam[] + ) + expect(await countChats('copilot-chat-history::admin@test')).toBe(1) + + // Switch identity in-place (no reload), then save again. The write must go + // to user B's DB, not A's stale handle. + userStore.set(asUser('other@test')) + await hm.save( + [{ role: 'user', content: 'hello B' }] as DisplayMessage[], + [] as ChatCompletionMessageParam[] + ) + + expect(await countChats('copilot-chat-history::other@test')).toBe(1) + // A's DB is untouched by the post-switch write. + expect(await countChats('copilot-chat-history::admin@test')).toBe(1) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte b/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte index 7f9d993a6c..04449b1f11 100644 --- a/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte +++ b/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte @@ -1,24 +1,53 @@ {#if multipleModels} @@ -28,9 +57,19 @@ displayName: m.model, selected: m.model === providerModel.model, action: () => { - $copilotSessionModel = m + // Carry the effort onto the new model only if it supports that level + // ('off' only where the model can truly disable); otherwise drop it so + // the model's default applies. + const carried = providerModel.reasoning + const cap = getReasoningCapability(m.provider, m.model) + const keep = + carried === REASONING_OFF + ? cap.canDisable + : carried !== undefined && cap.levels.includes(carried) + $copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) } storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model) storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider) + storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined) } }))} placement="bottom-end" @@ -53,3 +92,33 @@ {providerModel.model} {/if} + +{#if capability.supported} + + [...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels].map((level) => ({ + displayName: level, + selected: + level === REASONING_OFF + ? providerModel.reasoning === REASONING_OFF + : currentEffort === level && providerModel.reasoning !== REASONING_OFF, + action: () => selectReasoning(level) + }))} + placement="bottom-end" + fixedHeight={false} + > + {#snippet buttonReplacement()} + + {/snippet} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte index a2ff24847d..c4b2d0cc21 100644 --- a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte @@ -104,14 +104,14 @@ {#if showWhileLoading || (!loading && hasContent) || streaming} -
+
- + {title}: {#if showCopy && hasContent && !streaming}
{#if loading && !streaming && !hasContent} -
+
Executing...
{:else if error} -
+
{error}
{:else if hasContent} -
+
{formatJson($state.snapshot(content))}
{#if showFade && canScrollDown}
{/if}
{:else} -
- No {title.toLowerCase()} yet -
+ No {title.toLowerCase()} yet {/if}
{/if} diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index b6d01c6b38..f0865cfde1 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -1,11 +1,12 @@ diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index 139673e446..ae848156f5 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -27,7 +27,7 @@ async function importRaw() { $importFlowStore = importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '') - await goto('/flows/add?nodraft=true') + await goto('/flows/add') drawer?.closeDrawer?.() } @@ -35,7 +35,7 @@ const parsed = wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') $importScriptStore = parsed - await goto(`${base}/scripts/add?import=true&nodraft=true`) + await goto(`${base}/scripts/add?import=true`) wacDrawer?.closeDrawer?.() } @@ -45,17 +45,17 @@ function selectFlowEditor() { flowModalOpen = false - goto(`${base}/flows/add?nodraft=true`) + goto(`${base}/flows/add`) } function selectWacPython() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=python`) + goto(`${base}/scripts/add?wac=python`) } function selectWacTypescript() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=typescript`) + goto(`${base}/scripts/add?wac=typescript`) } function selectPipeline() { diff --git a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte index 30311e2fa3..0ce0843bbf 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte @@ -24,23 +24,14 @@ flowEditorDrawer?.openDrawer?.() try { - const flowWithDraft = await FlowService.getFlowByPathWithDraft({ + const backendFlow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path }) - savedFlow = { - ...structuredClone(flowWithDraft), - draft: flowWithDraft.draft - ? { - ...structuredClone(flowWithDraft.draft), - path: flowWithDraft.draft.path ?? flowWithDraft.path - } - : undefined - } as Flow & { draft?: Flow } + savedFlow = structuredClone(backendFlow) as Flow - // Use the draft if available, otherwise the deployed flow - flow = flowWithDraft.draft ?? flowWithDraft + flow = backendFlow await initFlow(flow, flowStore, flowStateStore) loading = false @@ -53,11 +44,7 @@ let callback: (() => void) | undefined = undefined let flowPath: string = $state('') let flow: Flow | undefined = $state(undefined) - let savedFlow: - | (Flow & { - draft?: Flow | undefined - }) - | undefined = $state(undefined) + let savedFlow: Flow | undefined = $state(undefined) let loading = $state(true) const flowStore: StateStore = $state({ diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 56a594ff1e..1bf578a081 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -746,7 +746,7 @@ console.log('tagChange', e.detail) if (flowModule.value.type == 'script') { flowModule.value.tag_override = e.detail - } else if (flowModule.value.type == 'rawscript') { + } else if (flowModule.value.type == 'rawscript' || flowModule.value.type == 'aiagent') { flowModule.value.tag = e.detail } }} diff --git a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte index bb8490be5f..f7802d82ea 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte @@ -28,7 +28,8 @@ } let { module, tag }: Props = $props() - const { scriptEditorDrawer, flowEditorDrawer } = getContext('FlowEditorContext') + const { scriptEditorDrawer, flowEditorDrawer } = + getContext('FlowEditorContext') const dispatch = createEventDispatcher() let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi') @@ -176,6 +177,17 @@ /> {/if} + {#if module.value.type === 'aiagent' && customUi?.tagEdit != false} + dispatch('tagChange', e.detail)} + /> + {/if} + {#if module.value.type === 'rawscript'} ([]) diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index be0114ccc1..9b02f7224b 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -94,6 +94,10 @@ export type FlowEditorContext = { outputPickerOpenFns: Record void> preserveOnBehalfOf: Writable savedOnBehalfOfEmail: Writable + // Only set by the local dev page (Dev.svelte): path -> temp-storage hash of + // locally-edited workspace scripts, passed as temp_script_refs on preview + // runs so relative imports resolve from local (not-yet-deployed) content + devTempScriptRefs?: () => Record | undefined } export type FlowGraphAssetContext = StateStore<{ diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index ade76dc9ee..42ff5f57b9 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -104,7 +104,7 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) { } import { dfs as dfsApply } from './dfs' -import { randomUUID } from './conversations/FlowChatManager.svelte' +import { randomUUID } from '$lib/utils/uuid' export function cleanFlow(flow: OpenFlow | any): OpenFlow & { tag?: string @@ -184,7 +184,8 @@ export async function runFlowPreview( flow: OpenFlow & { tag?: string }, path: string, restartedFrom: RestartedFrom | undefined, - conversationId?: string | undefined + conversationId?: string | undefined, + tempScriptRefs?: Record ) { const newFlow = flow return await JobService.runFlowPreview({ @@ -194,7 +195,8 @@ export async function runFlowPreview( value: newFlow.value, path: path, tag: newFlow.tag, - restarted_from: restartedFrom + restarted_from: restartedFrom, + temp_script_refs: tempScriptRefs }, memoryId: conversationId }) diff --git a/frontend/src/lib/components/forkDraftToImport.ts b/frontend/src/lib/components/forkDraftToImport.ts new file mode 100644 index 0000000000..0ed9ea3efc --- /dev/null +++ b/frontend/src/lib/components/forkDraftToImport.ts @@ -0,0 +1,60 @@ +import { goto } from '$lib/navigation' +import { base } from '$app/paths' +import type { Flow, NewScript, UserDraftItemKind } from '$lib/gen' +import { importStore } from '$lib/components/apps/store' +import { importFlowStore } from '$lib/components/flows/flowStore.svelte' +import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' +import { getUsernameForNamespace } from '$lib/userNamespace' + +/** + * Re-home the source path into the forker's namespace: drop the first two + * segments, prefix `u/{me}`. `u/admin/myflow` → `u/me/myflow`. + */ +function forkSeedPath(sourcePath: string): string { + const rest = sourcePath.split('/').slice(2).join('/') + return `u/${getUsernameForNamespace()}/${rest}` +} + +/** + * Open a fetched draft value as a brand-new item of `itemKind`, via the same + * one-shot import handoff as "Import from YAML/JSON": stash the payload in the + * import store and route to the kind's `/add` page. The fork behaves like a new + * item of one's own — nothing saved until the first edit, no source identity + * carried over. The re-homed source path travels as `?seed_path=` (not `?path=`, + * which ScriptBuilder strips in transit) so the Path widget starts recognizable. + * Only the cross-user-visible kinds can be forked. + */ +export function forkDraftToImport( + itemKind: UserDraftItemKind, + value: unknown, + sourcePath: string +): void { + const seed = `?seed_path=${encodeURIComponent(forkSeedPath(sourcePath))}` + switch (itemKind) { + case 'script': + importScriptStore.set(value as NewScript) + goto(`${base}/scripts/add${seed}`) + return + case 'flow': + importFlowStore.set(value as Flow) + goto(`${base}/flows/add${seed}`) + return + case 'app': + // App drafts store the bare `App` value (no summary/policy + // wrapper) — the /apps/edit import branch accepts both shapes. + importStore.set(value as any) + goto(`${base}/apps/add${seed}`) + return + case 'raw_app': { + // Raw-app drafts bundle `{files, runnables, data, summary, + // policy, ...}` flat; wrap so the /apps_raw/edit import branch + // picks up summary and policy alongside the value. + const v = value as any + importStore.set({ summary: v?.summary ?? '', value: v, policy: v?.policy }) + goto(`${base}/apps_raw/add${seed}`) + return + } + default: + throw new Error(`Cannot fork drafts of kind ${itemKind}`) + } +} diff --git a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte index 97c9429a63..fecf82ee23 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte @@ -30,7 +30,9 @@ let resolvedContextMenuItems: ContextMenuItem[] | undefined = $derived( contextMenuItems ?? menuItems?.flatMap((item) => [ - ...(item.separatorTop ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] : []), + ...(item.separatorTop + ? [{ id: `${item.displayName}-divider`, label: '', divider: true }] + : []), { id: item.displayName, label: item.displayName, @@ -43,11 +45,11 @@ ]) ) - const { moveManager } = getGraphContext() + // NodeWrapper is reused outside the flow graph (e.g. the app decision-tree + // editor) where FlowGraphContext is never set, so guard against undefined. + const { moveManager } = getGraphContext() ?? {} - let faded = $derived( - nodeId != null && (moveManager?.draggedNodeIds?.has(nodeId) ?? false) - ) + let faded = $derived(nodeId != null && (moveManager?.draggedNodeIds?.has(nodeId) ?? false)) let darkMode: boolean = $state(false) @@ -72,18 +74,10 @@ {#snippet handles()} {#if enableSourceHandle} - + {/if} {#if enableTargetHandle} - + {/if} {/snippet} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 6391d63933..ca41e55349 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -65,7 +65,6 @@ type?: U time?: number starred?: boolean - has_draft?: boolean hash?: string } @@ -263,9 +262,13 @@ async function showCode(path: string, summary: string) { viewCodeTitle = summary || path await viewCodeDrawer?.openDrawer() + // `getDraft: true` so draft-only scripts (no deployed row at this + // path) still return their content via the per-user draft overlay + // instead of 404'ing. script = await ScriptService.getScriptByPath({ workspace: $workspaceStore!, - path + path, + getDraft: true }) } @@ -320,10 +323,11 @@ a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1 ) ) + function itemLabels(x: { labels?: string[]; inherited_labels?: string[] }): string[] { + return [...(x.labels ?? []), ...(x.inherited_labels ?? [])] + } let allLabels = $derived( - Array.from( - new Set(combinedItems?.flatMap((x) => ('labels' in x && x.labels) || []) ?? []) - ).sort() + Array.from(new Set(combinedItems?.flatMap((x) => itemLabels(x)) ?? [])).sort() ) $effect(() => { if ($workspaceStore) { @@ -338,13 +342,13 @@ x.path.startsWith(ownerFilter + '/') && (x.type == itemKind || itemKind == 'all') && filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) && - (labelFilter == undefined || ('labels' in x && x.labels?.includes(labelFilter))) + (labelFilter == undefined || itemLabels(x).includes(labelFilter)) ) : combinedItems?.filter( (x) => (x.type == itemKind || itemKind == 'all') && filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) && - (labelFilter == undefined || ('labels' in x && x.labels?.includes(labelFilter))) + (labelFilter == undefined || itemLabels(x).includes(labelFilter)) ) ) let items = $derived(filter !== '' ? filteredItems : preFilteredItems) diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index f566c1de06..59adf1aaa9 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -39,7 +39,7 @@
{:else}
- {#each groupedItems.slice(0, nbDisplayed) as item (item['folderName'] ?? 'user__' + item['username'])} + {#each groupedItems.slice(0, nbDisplayed) as item ('folderName' in item ? `f__${item.folderName}` : 'username' in item ? `u__${item.username}` : `i__${item.type}__${item.path}`)} {#if item} = T & { type?: U time?: number starred?: boolean - has_draft?: boolean } type TableScript = TableItem diff --git a/frontend/src/lib/components/icons/CoupaIcon.svelte b/frontend/src/lib/components/icons/CoupaIcon.svelte new file mode 100644 index 0000000000..4d4058fdac --- /dev/null +++ b/frontend/src/lib/components/icons/CoupaIcon.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/lib/components/icons/OutreachIcon.svelte b/frontend/src/lib/components/icons/OutreachIcon.svelte new file mode 100644 index 0000000000..a41d123ecd --- /dev/null +++ b/frontend/src/lib/components/icons/OutreachIcon.svelte @@ -0,0 +1,38 @@ + + + + outreach-svg + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 56d77c810c..3f8556a143 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -26,6 +26,7 @@ import SendflakeIcon from './SendflakeIcon.svelte' import QRCodeIcon from './QRCodeIcon.svelte' import LinkedinIcon from './LinkedinIcon.svelte' import HubspotIcon from './HubspotIcon.svelte' +import CoupaIcon from './CoupaIcon.svelte' import DatadogIcon from './DatadogIcon.svelte' import DatabricksIcon from './DatabricksIcon.svelte' import AdobeAcrobatSignIcon from './AdobeAcrobatSignIcon.svelte' @@ -70,6 +71,7 @@ import GraphqlIcon from './GraphqlIcon.svelte' import NocoDbIcon from './NocoDbIcon.svelte' import AzureIcon from './AzureIcon.svelte' import OktaIcon from './OktaIcon.svelte' +import OutreachIcon from './OutreachIcon.svelte' import Auth0Icon from './Auth0Icon.svelte' import MsSqlServerIcon from './MSSqlServerIcon.svelte' import AuthentikIcon from './AuthentikIcon.svelte' @@ -248,6 +250,7 @@ export const APP_TO_ICON_COMPONENT = { hubspot: HubspotIcon, datadog: DatadogIcon, databricks: DatabricksIcon, + coupa: CoupaIcon, adobe_acrobat_sign: AdobeAcrobatSignIcon, stripe: StripeIcon, telegram: TelegramIcon, @@ -293,6 +296,7 @@ export const APP_TO_ICON_COMPONENT = { nocodb: NocoDbIcon, azure: AzureIcon, okta: OktaIcon, + outreach: OutreachIcon, auth0: Auth0Icon, authentik: AuthentikIcon, authelia: AutheliaIcon, @@ -512,6 +516,7 @@ export { AzureIcon, MicrosoftIcon, OktaIcon, + OutreachIcon, Auth0Icon, AuthentikIcon, AutheliaIcon, diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 10a0519d7e..6af6926c4f 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -322,6 +322,16 @@ export const settings: Record = { '{\n "auths": {\n "myregistry.example.com": {\n "auth": "BASE64(username:password)"\n }\n }\n}', storage: 'setting' }, + { + label: 'SSH execution (#ssh)', + key: 'ssh_execution_enabled', + fieldType: 'boolean', + description: + 'Allow bash scripts starting with a #ssh <resource_path> directive to run on the remote host described by the referenced ssh_target resource instead of the worker. Off by default.', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true + }, { label: 'Default timeout', key: 'job_default_timeout', diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index 552773578a..73abdf6c34 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -28,6 +28,8 @@ }: Props = $props() let listener = async (event) => { + if (!iframe || event.source !== iframe.contentWindow) return + const data = event.data function respond(o: object) { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8121ca8b70..adafc8256a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -15,7 +15,15 @@ import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte' import { workspaceStore } from '$lib/stores' import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' - import { genWmillTs, type Runnable } from './utils' + import { + genWmillTs, + normalizeRawAppRuntimeLogs, + type Runnable, + type RawAppRuntimeLogEntry, + type RawAppRuntimeLogRequester, + type RawAppRunSummary, + type RawAppRunsProvider + } from './utils' import DarkModeObserver from '../DarkModeObserver.svelte' import RawAppSidebar from './RawAppSidebar.svelte' import type { Modules } from './RawAppModules.svelte' @@ -35,6 +43,7 @@ import { runScriptAndPollResult } from '../jobs/utils' import { RawAppHistoryManager } from './RawAppHistoryManager.svelte' import { sendUserToast } from '$lib/utils' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { buildDataTableWhitelist, parseDataTableRef, @@ -43,6 +52,7 @@ type RawAppData, DEFAULT_DATA } from './dataTableRefUtils' + import { randomUUID } from '$lib/utils/uuid' interface Props { files?: Record @@ -86,6 +96,17 @@ * still toggle the mode after mount; this prop only seeds the * initial state. */ defaultSplitWithPreview?: boolean + /** User-typed path when it differs from `savedApp.path`. The route injects + * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ + pendingDraftPath?: string | undefined + // Threaded to the AutosaveIndicator's "Reset to deployed" button. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same indicator semantics. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void + onRuntimeLogRequester?: (requester: RawAppRuntimeLogRequester | undefined) => void + onRunsProvider?: (provider: RawAppRunsProvider | undefined) => void } let { @@ -104,7 +125,14 @@ defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', liveEditorDraftStoragePath = undefined, - defaultSplitWithPreview = true + defaultSplitWithPreview = true, + pendingDraftPath = $bindable(undefined), + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts, + onRuntimeLogRequester = undefined, + onRunsProvider = undefined }: Props = $props() export const version: number | undefined = undefined @@ -983,6 +1011,11 @@ return } + if (fromPreview && e.data.type === 'runtimeLogsResponse') { + resolvePendingRuntimeLogRequest(e.data.requestId, normalizeRawAppRuntimeLogs(e.data.logs)) + return + } + // Inspector events come exclusively from the preview iframe. if (fromPreview && e.data.type === 'inspectorSelect') { inspectorElement = e.data.element as InspectorElementInfo @@ -1069,6 +1102,66 @@ }) } + const RUNTIME_LOGS_TIMEOUT_MS = 2000 + type PendingRuntimeLogRequest = { + resolve: (entries: RawAppRuntimeLogEntry[] | undefined) => void + timer: ReturnType + } + const pendingRuntimeLogReqs = new Map() + + function resolvePendingRuntimeLogRequest( + requestId: string, + entries: RawAppRuntimeLogEntry[] | undefined + ) { + const pending = pendingRuntimeLogReqs.get(requestId) + if (!pending) return + clearTimeout(pending.timer) + pendingRuntimeLogReqs.delete(requestId) + pending.resolve(entries) + } + + const requestRuntimeLogs: RawAppRuntimeLogRequester = (limit) => { + const win = previewIframe?.contentWindow + if (!win || !previewIframeLoaded) return Promise.resolve(undefined) + const requestId = randomUUID() + return new Promise((resolve) => { + const timer = setTimeout(() => { + resolvePendingRuntimeLogRequest(requestId, undefined) + }, RUNTIME_LOGS_TIMEOUT_MS) + pendingRuntimeLogReqs.set(requestId, { resolve, timer }) + win.postMessage({ type: 'getRuntimeLogs', requestId, limit }, '*') + }) + } + + const getRuns: RawAppRunsProvider = () => { + const out: RawAppRunSummary[] = [] + for (const id of jobs) { + const j = jobsById[id] + if (!j) continue + const run: RawAppRunSummary = { + job_id: j.job ?? id, + component: j.component, + status: j.result !== undefined || j.duration_ms !== undefined ? 'completed' : 'running' + } + if (j.created_at !== undefined) run.created_at = j.created_at + if (j.started_at !== undefined) run.started_at = j.started_at + if (j.duration_ms !== undefined) run.duration_ms = j.duration_ms + out.push(run) + } + return out.reverse() + } + + onMount(() => { + onRuntimeLogRequester?.(requestRuntimeLogs) + onRunsProvider?.(getRuns) + return () => { + onRuntimeLogRequester?.(undefined) + onRunsProvider?.(undefined) + for (const requestId of Array.from(pendingRuntimeLogReqs.keys())) + resolvePendingRuntimeLogRequest(requestId, undefined) + } + }) + let darkMode: boolean = $state(false) // Host's computed `text-xs` size in px. Windmill bumps :root to 18px at // ≥1760px viewports, so this re-evaluates on resize via the listener below. @@ -1314,7 +1407,51 @@ return () => window.removeEventListener('keydown', onEscapeCapture, true) }) + // Force an immediate flush. No toast — the AutosaveIndicator narrates the + // result, and `flush` never rejects (postSave routes errors to the failures map). + function flushDraft() { + if (!$workspaceStore || !liveEditorDraftStoragePath) return + void UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: liveEditorDraftStoragePath + }) + } + + // The VS Code workbench iframe's keydowns don't bubble out, so the window + // handler can't see Ctrl/Cmd+S while editing code. Attach a capture listener + // inside the iframe per load (it dies with the iframe, so no leak). No + // preventDefault: VS Code's own save still runs; we just flush alongside it. + function attachIframeSaveShortcut() { + const win = iframe?.contentWindow + if (!win) return + win.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) { + flushDraft() + } + }, + true + ) + } + + // Monaco swallows Ctrl/Cmd+S in inline editors; Editor/SimpleEditor + // re-broadcast it as `wm-monaco-save-shortcut` (untyped, hence manual listener). + $effect(() => { + window.addEventListener('wm-monaco-save-shortcut', flushDraft) + return () => window.removeEventListener('wm-monaco-save-shortcut', flushDraft) + }) + function handleKeydown(e: KeyboardEvent) { + // Ctrl/Cmd + S — catch this BEFORE the input/Monaco guard below so + // the shortcut fires regardless of focus. + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) { + e.preventDefault() + flushDraft() + return + } + // Skip when typing in an input, textarea, or Monaco editor. const classes = (e.target as HTMLElement | null)?.className if ( @@ -1357,6 +1494,7 @@ bind:jobsById bind:savedApp bind:summary + bind:pendingDraftPath on:restore on:savedNewAppPath {policy} @@ -1371,6 +1509,10 @@ {getBundle} {onNavigate} {onDeploy} + {onResetToDeployed} + {loadedFromDraft} + {othersDraftsCount} + {onOpenOthersDrafts} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1509,6 +1651,7 @@ title="UI builder" src="/ui_builder/index.html" class="w-full h-full block" + onload={attachIframeSaveShortcut} > {/if}
@@ -1637,8 +1780,9 @@ title="Build failed" class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']" > -
{buildError}
+
{buildError}
{/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index dedb7b5d11..e21d1cd914 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -5,8 +5,9 @@ import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' - import { AppService, DraftService, type Policy } from '$lib/gen' + import { AppService, type Policy } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' + import { discardDraftAfterDeploy } from '$lib/userDraftToast' import { rawAppToHubUrl } from '$lib/hub' import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' import YAML from 'yaml' @@ -26,32 +27,25 @@ WandSparkles } from 'lucide-svelte' import { createEventDispatcher, untrack } from 'svelte' - import { - cleanValueProperties, - orderedJsonStringify, - type Value, - replaceFalseWithUndefined - } from '../../utils' + import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils' import { random_adj } from '$lib/components/random_positive_adjetive' // import { allItems, toStatic } from '../apps/editor/settingsPanel/utils' import AppExportButton from '../apps/editor/AppExportButton.svelte' - import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { sendUserToast } from '$lib/toast' import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte' import Awareness from '$lib/components/Awareness.svelte' import type DiffDrawer from '$lib/components/DiffDrawer.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' + import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte' import { goto } from '$app/navigation' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte' - import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes' import DropdownV2 from '../DropdownV2.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' - import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte' import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte' import type { Runnable } from './RawAppInlineScriptRunnable.svelte' import { updateRawAppPolicy } from './rawAppPolicy' @@ -103,11 +97,9 @@ savedApp?: | { value: any - draft?: any path: string summary: string policy: any - draft_only?: boolean custom_path?: string } | undefined @@ -136,6 +128,20 @@ liveEditorDraftStoragePath?: string // Fired after a successful deploy; lets the session preview reload. onDeploy?: (e: { path: string }) => void + /** Surfaces the user-typed path (`newEditedPath`) up to the route + * when (and only when) it differs from the deployed/seeded + * `savedApp.path`. The route writes it into the autosaved raw-app + * draft as `draft_path` so the home-page row can render the + * friendly name instead of the URL's autogenerated draft slot. */ + pendingDraftPath?: string | undefined + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the raw-app editor's + // indicator. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void } let { @@ -162,9 +168,22 @@ onToggleSidebar = undefined, onNavigate = undefined, liveEditorDraftStoragePath = undefined, - onDeploy = undefined + onDeploy = undefined, + pendingDraftPath = $bindable(undefined), + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts }: Props = $props() + $effect(() => { + const typed = newEditedPath + const baseline = savedApp?.path ?? '' + untrack(() => { + pendingDraftPath = typed && typed !== baseline ? typed : undefined + }) + }) + let newEditedPath = $state( untrack(() => newApp @@ -198,14 +217,12 @@ const loading = $state({ publish: false, - save: false, - saveDraft: false + save: false }) let pathError: string = $state('') let appExport = $state() as AppExportButton | undefined - let draftDrawerOpen = $state(false) let saveDrawerOpen = $state(false) let historyBrowserDrawerOpen = $state(false) let publishToHubDrawerOpen = $state(false) @@ -251,10 +268,6 @@ saveDrawerOpen = false } - function closeDraftDrawer() { - draftDrawerOpen = false - } - async function computeTriggerables() { policy = await updateRawAppPolicy(runnables, policy) } @@ -297,7 +310,19 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - if (!inSessionPane) UserDraft.remove('raw_app', path) + // Canonical autosave key (the URL slot `appPath`), NOT the + // just-typed deploy `path` — for a draft-only app they differ + // (`u/{user}/draft_{uuid}` vs the chosen path), so removing at + // `path` orphaned the real draft row. Bracketed + flushed: + // RawAppEditor stays mounted through the post-deploy navigation + // and its mirror would otherwise displace the queued delete. + if (!inSessionPane && $workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: appPath + }) + } dispatch('savedNewAppPath', path) onDeploy?.({ path }) } catch (e) { @@ -326,7 +351,7 @@ replaceFalseWithUndefined({ summary: summary, value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, + path: newEditedPath || savedApp.path, policy, custom_path: customPath }) @@ -376,11 +401,10 @@ diffDrawer?.setDiff({ mode: 'normal', deployed: deployedValue ?? savedApp, - draft: savedApp.draft, current: { summary: summary, value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, + path: newEditedPath || savedApp.path, policy, custom_path: customPath } @@ -432,7 +456,14 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - if (!inSessionPane) UserDraft.remove('raw_app', appPath) + // Bracketed + flushed (see createApp). + if (!inSessionPane && $workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: appPath + }) + } if (appPath !== npath) { dispatch('savedNewAppPath', npath) } @@ -458,175 +489,6 @@ return } - async function saveInitialDraft() { - if (!app) { - sendUserToast(`App hasn't been loaded yet`, true) - return - } - await computeTriggerables() - try { - let { css, js } = await getBundle() - await AppService.createAppRaw({ - workspace: $workspaceStore!, - formData: { - app: { - value: app, - path: newEditedPath, - summary: summary, - policy, - draft_only: true, - custom_path: customPath - }, - js, - css - } - }) - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: newEditedPath, - typ: 'app', - value: { - value: app, - path: newEditedPath, - summary: summary, - policy, - custom_path: customPath - } - } - }) - savedApp = { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath, - policy, - draft_only: true, - draft: { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath, - policy, - custom_path: customPath - }, - custom_path: customPath - } - - draftDrawerOpen = false - // The initial draft was promoted to a real path on the backend — - // drop the autosave keyed on the prior (possibly empty) path so - // a future "+ App" click opens on a clean slate. - if (!inSessionPane) UserDraft.remove('raw_app', appPath) - dispatch('savedNewAppPath', newEditedPath) - } catch (e) { - sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true) - } - draftDrawerOpen = false - } - - async function saveDraft(forceSave = false) { - if (!app) { - sendUserToast(`App hasn't been loaded yet`, true) - return - } - if (newApp) { - // initial draft - draftDrawerOpen = true - return - } - if (!savedApp) { - return - } - const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp) - const current = cleanValueProperties({ - summary: summary, - value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, - policy - }) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraft(true) - } - } - ]) - return - } - loading.saveDraft = true - try { - await computeTriggerables() - let path = appPath - if (savedApp.draft_only) { - await AppService.deleteApp({ - workspace: $workspaceStore!, - path: path - }) - let { css, js } = await getBundle() - - await AppService.createAppRaw({ - workspace: $workspaceStore!, - formData: { - app: { - value: app!, - summary: summary, - policy, - path: newEditedPath || path, - draft_only: true, - custom_path: customPath - }, - js, - css - } - }) - } - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: savedApp.draft_only ? newEditedPath || path : path, - typ: 'app', - value: { - value: app!, - summary: summary, - policy, - path: newEditedPath || path - } - } - }) - - savedApp = { - ...(savedApp?.draft_only - ? { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: savedApp.draft_only ? newEditedPath || path : path, - policy, - draft_only: true, - custom_path: customPath - } - : savedApp), - draft: { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath || path, - policy, - custom_path: customPath - } - } - - sendUserToast('Draft saved') - if (!inSessionPane) UserDraft.remove('raw_app', path) - loading.saveDraft = false - if (newApp || savedApp.draft_only) { - dispatch('savedNewAppPath', newEditedPath || path) - } - } catch (e) { - loading.saveDraft = false - throw e - } - } - let onLatest = $state(true) async function compareVersions() { if (version === undefined) { @@ -649,13 +511,6 @@ let moreItems = $derived([ ...(compactTopbar ? [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: !newApp && !savedApp - }, { displayName: `Jobs (${jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})`, icon: Bug, @@ -715,18 +570,6 @@ let jobsDrawerOpen = $state(false) - function getInitialAndModifiedValues(): SavedAndModifiedValue { - return { - savedValue: savedApp, - modifiedValue: { - summary: summary, - value: app, - path: newEditedPath || savedApp?.draft?.path || savedApp?.path, - policy, - custom_path: customPath - } - } - } let app = $derived(files ? { runnables: runnables, files, data } : undefined) $effect(() => { @@ -734,14 +577,6 @@ }) - -{#if !inSessionPane} - -{/if} - -{#if appPath == ''} - - closeDraftDrawer()}> - {#snippet actions()} -
- -
- {/snippet} - -
-
-{/if} closeSaveDrawer()}> {#snippet actions()}
{#if $enterpriseLicense && appPath != ''} @@ -965,9 +786,9 @@ variant="default" unifiedSize="md" on:click={() => openDiffDrawer()} - disabled={!savedApp} + disabled={!savedApp || newApp} iconOnly={compactTopbar} - title="Diff" + title={newApp ? 'Deploy this app once to compare against the deployed version' : 'Diff'} startIcon={{ icon: DiffIcon }} > Diff @@ -1006,19 +827,6 @@ AI {/if} - {#if !compactTopbar} - - {/if} + {/each} +
+
+ +
+

Data configuration

+ + {#if hasNoDatatables} + + You can still create an app, but for data storage you won't be able to use data tables + which are highly recommended. +
+ {#if $userStore?.is_admin} + Configure datatables in + workspace settings + to enable this feature. + {:else} + Ask your workspace admin to configure datatables in workspace settings to enable this + feature. + {/if} +
+ {:else} +
+
+ Default settings for new tables +
+
+
+ + +
+ {/if} +
+ {#if newSchemaAlreadyExists} + Schema "{newSchemaName}" already exists + {/if} +
+
+
+
+ +
+ +
+ +
+ dataTableDrawer?.openDrawer()} + onRemove={(index) => { + preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) + }} + /> +
+
+ {/if} +
+ +
+

+ + Start with AI + (optional) +

+ + {#if !isAiEnabled} + + You can still create an app manually but using AI is highly recommended. +
+ {#if $userStore?.is_admin} + Configure AI in + workspace settings + to enable this feature. + {:else} + Ask your workspace admin to configure AI in workspace settings to enable this feature. + {/if} +
+ {:else} +
+ +

+ Leave empty to start with a blank template, or describe your app to get AI assistance + right away. +

+
+ {/if} +
+ +
+ + {#if isAiEnabled} + + {/if} +
+
+ +{/if} + + { + preWhitelistedTables = [...preWhitelistedTables, ref] + }} +/> diff --git a/frontend/src/lib/components/raw_apps/utils.test.ts b/frontend/src/lib/components/raw_apps/utils.test.ts index e6b1fe5e36..b8ca6d207d 100644 --- a/frontend/src/lib/components/raw_apps/utils.test.ts +++ b/frontend/src/lib/components/raw_apps/utils.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { genWmillTs, type Runnable } from './utils' +import { + formatRuntimeLogsForChat, + genWmillTs, + normalizeRawAppRuntimeLogs, + type Runnable +} from './utils' const flowSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -38,8 +43,21 @@ describe('genWmillTs', () => { const dts = genWmillTs(runnables) - expect(dts).toContain( - 'myflow: (args: { string_input: string }) => Promise;' - ) + expect(dts).toContain('myflow: (args: { string_input: string }) => Promise;') + }) +}) + +describe('normalizeRawAppRuntimeLogs', () => { + it('keeps only well-formed runtime log entries', () => { + const entries = normalizeRawAppRuntimeLogs([ + { level: 'log', message: 'ready', ts: 1718000000000 }, + { level: 'trace', message: 'unsupported level', ts: 1718000000000 }, + { level: 'error', message: 'bad date', ts: Number.MAX_VALUE }, + { level: 'warn', message: 123, ts: 1718000000000 }, + 'not an entry' + ]) + + expect(entries).toEqual([{ level: 'log', message: 'ready', ts: 1718000000000 }]) + expect(formatRuntimeLogsForChat(entries)).toBe('[06:13:20.000] LOG: ready') }) }) diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index 928a7b8d81..53d84fd97c 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -15,6 +15,70 @@ export type RawApp = { files: string[] } +export type RawAppRuntimeLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug' +export type RawAppRuntimeLogEntry = { + level: RawAppRuntimeLogLevel + message: string + ts: number +} +export type RawAppRuntimeLogRequester = ( + limit: number +) => Promise + +const RAW_APP_RUNTIME_LOG_LEVELS = new Set([ + 'log', + 'info', + 'warn', + 'error', + 'debug' +]) + +function isRawAppRuntimeLogLevel(level: unknown): level is RawAppRuntimeLogLevel { + return typeof level === 'string' && RAW_APP_RUNTIME_LOG_LEVELS.has(level as RawAppRuntimeLogLevel) +} + +function isValidRuntimeLogTimestamp(ts: unknown): ts is number { + return typeof ts === 'number' && Number.isFinite(ts) && !Number.isNaN(new Date(ts).getTime()) +} + +export function normalizeRawAppRuntimeLogs(logs: unknown): RawAppRuntimeLogEntry[] { + if (!Array.isArray(logs)) return [] + return logs.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return [] + const { level, message, ts } = entry as Record + if ( + !isRawAppRuntimeLogLevel(level) || + typeof message !== 'string' || + !isValidRuntimeLogTimestamp(ts) + ) + return [] + return [{ level, message, ts }] + }) +} + +export function formatRuntimeLogsForChat(entries: RawAppRuntimeLogEntry[]): string { + const lines = entries.map((e) => { + const date = new Date(e.ts) + const time = Number.isNaN(date.getTime()) ? '--:--:--' : date.toISOString().slice(11, 23) + return `[${time}] ${e.level.toUpperCase()}: ${e.message}` + }) + return lines.join('\n') +} + +export type RawAppRunSummary = { + job_id: string + component: string + status: 'running' | 'completed' + created_at?: number + started_at?: number + duration_ms?: number +} +export type RawAppRunsProvider = () => RawAppRunSummary[] | undefined + +export function formatAppRunsForChat(runs: RawAppRunSummary[]): string { + return JSON.stringify(runs, null, 2) +} + export function htmlContent( workspace: string, secret: string | undefined, diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index cac47d4eef..2bd16dd5c8 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -1,10 +1,10 @@ -import type { NewScript } from '$lib/gen' +import type { NewScript, Script } from '$lib/gen' import type { AssetWithAltAccessType } from './assets/lib' import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui' import type { DiffDrawerI } from './diff_drawer' import type { ScriptBuilderFunctionExports } from './scriptBuilder' import type { ScheduleTrigger } from './triggers' -import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils' +import type { Trigger } from './triggers/utils' import type { WorkspaceItem } from './workspacePicker' export interface ScriptBuilderProps { @@ -15,6 +15,18 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string + /** + * Path the route's `UserDraft.use('script', ...)` + * handle is keyed by. Distinct from `initialPath` for new drafts — + * `initialPath` is the displayed/editor path (empty for new), while + * this is the URL path the draft is persisted under (`u/{user}/ + * draft_{uuid}`). Used to bracket the bootstrap `initContent` write + * with `UserDraft.stopSync` / `restartSync` so the template seed + * doesn't POST before the user's first real edit. Default to `''` + * for backwards compat with callers that don't manage drafts; the + * stop/restart pair is a no-op on a non-live entry. + */ + userDraftPath?: string template?: | 'docker' | 'bunnative' @@ -29,7 +41,7 @@ export interface ScriptBuilderProps { showMeta?: boolean neverShowMeta?: boolean diffDrawer?: DiffDrawerI | undefined - savedScript?: NewScriptWithDraftAndDraftTriggers | undefined + savedScript?: Script | NewScript | undefined searchParams?: URLSearchParams disableHistoryChange?: boolean customUi?: ScriptBuilderWhitelabelCustomUi @@ -41,12 +53,8 @@ export interface ScriptBuilderProps { // the deployed item) — consumers should skip post-deploy navigation when set. onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void onDeployError?: (e: { path: string; error: any }) => void - onSaveInitial?: (e: { path: string; hash: string }) => void onHistoryRestore?: () => void - onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void - onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void onSeeDetails?: (e: { path: string }) => void - onSaveDraftError?: (e: { path: string; error: any }) => void onNavigate?: (item: WorkspaceItem) => void // Fired whenever a test run is started from the script editor, with the // preview job id. Used by whitelabel embedders to track test jobs. @@ -59,4 +67,19 @@ export interface ScriptBuilderProps { // overwrite it. Used by the session preview, which opens AI-created scripts // as new but with a path the AI already assigned. initialPathChosen?: boolean + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + // Routes pass their own re-load-without-draft callback here; omit on + // callers (session preview, embedded SDK) that shouldn't surface the + // action at all. + onResetToDeployed?: () => void | Promise + // Triggers the AutosaveIndicator's on-mount "Loaded from draft" hint + // (with a one-shot green flash) the first time it flips to true. + loadedFromDraft?: boolean + // Non-zero when other workspace users have a draft at this path. + // Drives both the indicator's hint label ("Others are working on + // this script") and the popover's "See others' drafts" button. + othersDraftsCount?: number + // Wired by the route to flip the OtherUsersDraftsModal open. + onOpenOthersDrafts?: () => void } diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 5abd8235f2..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -14,7 +14,7 @@ unifiedSize="lg" variant="accent" startIcon={{ icon: Plus }} - href="{base}/scripts/add?nodraft=true" + href="{base}/scripts/add" endIcon={{ icon: Code2 }} > Script diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index 21b79ea6b9..19e71530e6 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -472,7 +472,6 @@ type?: U time?: number starred?: boolean - has_draft?: boolean } // interface SelectableSearchMenuItem { diff --git a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte new file mode 100644 index 0000000000..9fa83be7fc --- /dev/null +++ b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte @@ -0,0 +1,82 @@ + + + buildEditUrl(d as unknown as WorkspaceItemDiff, workspaceId)} +> + {#snippet titleExtra()} +
+ + {ws?.name ?? workspaceId} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index dfa928f08c..fa48fcf26e 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -2,9 +2,13 @@ import FlowBuilder from '$lib/components/FlowBuilder.svelte' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' + import type { Flow } from '$lib/gen' import type { SessionRuntime } from './sessionRuntime.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' + import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -25,22 +29,40 @@ let selectedId = $state('settings-metadata') let diffDrawer: DiffDrawer | undefined = $state() - // In a session pane, "restore" just reloads from the current state — the - // session target stays put. The Diff drawer's primary use here is viewing - // the diff; restore is best-effort. - async function restoreFromCurrentTarget() { + // Restore actions for the diff drawer. A `loadFlow`-based handler is a no-op: + // loadFlow early-returns on the already-loaded path (and would re-read the + // local draft anyway). Instead reset the live UserDraft cell to the target + // baseline — useUserDraftSync's inbound effect then syncs the editor preview. + // Mirrors ScriptEditorView. + async function restoreDeployed() { + const saved = runtime.savedFlow.val + if (!saved) { + sendUserToast('Could not restore to deployed', true) + return + } diffDrawer?.closeDrawer() - await runtime.loadFlow(workspaceId, path) + // Drop the user's per-user draft too, so "deployed" sticks across a + // reload. The syncer's `value: null` POST is the canonical per-user + // delete; fire-and-forget since the in-memory reset below is what the + // preview reflects immediately. + if (saved.is_draft) { + saved.is_draft = false + UserDraftDbSyncer.save({ + workspace: workspaceId, + itemKind: 'flow', + path: saved.path, + value: null + }).catch((e) => console.error('restoreDeployed: draft delete failed', e)) + invalidateWorkspaceDrafts(workspaceId) + } + const deployed = structuredClone($state.snapshot(saved)) as Flow & { draft?: unknown } + delete deployed.draft + UserDraft.discard('flow', path, deployed, { workspace: workspaceId }) } {#if runtime.savedFlow.val} - + {/if} runtime.scheduleForkComparisonRefresh()} onDeploy={() => { // FlowBuilder has no deploy toast and the session stays put, so toast // here, then sync the preview to deployed (pulls the new locks + version_id). sendUserToast('Deployed') runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) + // Deploying clears the item's pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) }} /> {/snippet} diff --git a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte index acfac1f03f..e03fc5a49c 100644 --- a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte @@ -1,84 +1,71 @@ - searchableText(d)} -/> - -{#snippet renderTreeNode(node: TreeNode, depth: number)} - {#if node.type === 'folder'} - {@const isUserScope = node.isScope && node.name.startsWith('u/')} - {@const fkey = folderKey(node)} - {@const open = isFolderOpen(fkey)} - {@const isHl = fkey === highlightedKey} -
(folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)} - class="select-none" - > - setHoverHighlight(fkey)} - class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl - ? 'bg-surface-hover' - : ''}" - style="padding-left: {depth * 12 + 8}px" - > - - - {#if isUserScope} - - {:else} - - {/if} - {node.name} - -
- {#each node.children as child} - {@render renderTreeNode(child, depth + 1)} - {/each} -
-
- {:else} - {@const status = statusOf(node.diff)} - {@const key = itemKey(node.diff)} - { - highlightedKey = key - scrollToDiff(node.diff) - }} - onmouseenter={() => setHoverHighlight(key)} - > - {#snippet extras()} - - {/snippet} - - {/if} -{/snippet} - - - drawer?.closeDrawer()} - documentationLink={undefined} - noPadding - overflow_y={false} - > - {#snippet titleExtra()} -
- - {forkWs?.name ?? forkWorkspaceId} - - {parentWs?.name ?? parentWorkspaceId} - {#if comparison} - - {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} + buildEditUrl(d as unknown as WorkspaceItemDiff, forkWorkspaceId)} +> + {#snippet titleExtra()} +
+ + {forkWs?.name ?? forkWorkspaceId} + + {parentWs?.name ?? parentWorkspaceId} + {#if comparison} + + {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} + + {#if comparison.summary.conflicts > 0} + + + {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} - {#if comparison.summary.conflicts > 0} - - - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} - - {/if} {/if} -
- {/snippet} - {#snippet actions()} - - {#snippet children({ item })} - - - {/snippet} - - - {/snippet} -
- {#if comparison && comparison.diffs.length > 0} - {/if} -
-
- {#if loading && !comparison} -
- - Loading comparison... -
- {:else if error} -
{error}
- {:else if comparison?.skipped_comparison} -
- This fork was created before change tracking was added — diffs are not available. -
- {:else if comparison && comparison.diffs.length === 0} -
No changes between this fork and its parent.
- {:else if comparison && filteredDiffs.length === 0} -
No files match "{searchQuery}".
- {:else if comparison} -
- {#each filteredDiffs as d (itemKey(d))} - {@const key = itemKey(d)} - {@const status = statusOf(d)} - {@const StatusIcon = statusIcons[status]} - {@const loaded = loadedDiffs[key]} - {@const editUrl = editUrlFor(d)} -
onDetailsToggle(d, e)} - > - - - - -
- {#if d.ahead > 0} - {d.ahead} ahead - {/if} - {#if d.behind > 0} - {d.behind} behind - {/if} - - - {status} - -
-
-
- {#if !loaded || loaded.state === 'loading'} -
- - Loading diff… -
- {:else if loaded.state === 'error'} -
{loaded.error}
- {:else if loaded.state === 'ready'} - - {/if} -
-
- {/each} -
- {/if} -
- - - - +
+ {/snippet} + diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 9ed75f9702..8504f71e0a 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -4,6 +4,12 @@ import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionRuntime } from './sessionRuntime.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' + import { runResetToDeployed } from '$lib/userDraftToast' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' + import type { + RawAppRuntimeLogRequester, + RawAppRunsProvider + } from '$lib/components/raw_apps/utils' let { runtime, @@ -23,18 +29,32 @@ let diffDrawer: DiffDrawer | undefined = $state() - async function restoreFromCurrentTarget() { + async function reloadDeployed() { + await runtime.loadRawApp(workspaceId, path, true, true) + } + + async function restoreDeployed() { diffDrawer?.closeDrawer() - await runtime.loadRawApp(workspaceId, path) + await runResetToDeployed({ + workspace: workspaceId, + itemKind: 'raw_app', + path, + onResetToDeployed: reloadDeployed + }) + invalidateWorkspaceDrafts(workspaceId) + } + + function registerRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined) { + runtime.setRuntimeLogRequester(requester) + } + + function registerRunsProvider(provider: RawAppRunsProvider | undefined) { + runtime.setAppRunsProvider(provider) } {#if runtime.savedRawApp.val} - + {/if} { // Sync the preview to deployed (raw apps deploy only from this editor). runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) + // Deploying clears the item's pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) }} defaultSidebarCollapsed sidebarStorageKey="raw-app-sidebar-collapsed-preview" defaultSplitWithPreview={false} + onRuntimeLogRequester={registerRuntimeLogRequester} + onRunsProvider={registerRunsProvider} /> {/if} {/snippet} diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index bebd6d3cce..775c66daec 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -3,10 +3,12 @@ import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionRuntime } from './sessionRuntime.svelte' - import { DraftService, ScriptService, type NewScript } from '$lib/gen' + import type { NewScript } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -40,36 +42,37 @@ return } diffDrawer?.closeDrawer() - // Drop the backend (DB) draft too, so "deployed" sticks across a reload. - if (saved.draft) { - try { - await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path }) - saved.draft = undefined - } catch (e: any) { - sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true) - return - } + // Drop the user's per-user draft too, so "deployed" sticks across + // a reload. The overlay sets `is_draft: true` when a draft exists + // for the authed user; the syncer's `value: null` POST is the + // canonical per-user delete. + // + // Fire-and-forget: every read here (`saved`, the snapshot we build + // below, the UserDraft.discard write) is purely in-memory, so we + // don't need the DELETE to have landed to finish the restore. We + // flip `is_draft` optimistically so the UI matches the new intent + // immediately. A failed DELETE only matters across a hard reload + // before it lands — log and move on. + if (saved.is_draft) { + saved.is_draft = false + UserDraftDbSyncer.save({ + workspace: workspaceId, + itemKind: 'script', + path: saved.path, + value: null + }).catch((e) => console.error('restoreDeployed: draft delete failed', e)) + // Per-user draft gone — refresh the session draft-bar count immediately + // instead of waiting for an AI turn-end / tab-refocus signal. + invalidateWorkspaceDrafts(workspaceId) } const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown } delete deployed.draft UserDraft.discard('script', path, deployed, { workspace: workspaceId }) } - - async function restoreDraft() { - const backendDraft = runtime.savedScript.val?.draft as NewScript | undefined - if (!backendDraft) { - sendUserToast('Could not restore to draft', true) - return - } - diffDrawer?.closeDrawer() - UserDraft.discard('script', path, structuredClone($state.snapshot(backendDraft)), { - workspace: workspaceId - }) - } {#if runtime.savedScript.val} - + {/if} { - runtime.scheduleForkComparisonRefresh() - // Re-pin parent_hash to the latest version so the next Deploy's conflict - // check (which runs before deploy, while the session stays mounted) - // doesn't misfire. - try { - const latest = await ScriptService.getScriptLatestVersion({ - workspace: workspaceId, - path: e.path - }) - const cur = runtime.scriptStore.val - if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash - } catch (err) { - console.error('Failed to sync parent_hash after save draft', err) - } - }} onDeploy={(e) => { // Fires on every deploy (primary, "Deploy & Stay here", and lib — we // ignore e.stay since the session always stays). Toast, then sync the // preview to the deployed version. sendUserToast('Deployed') runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) + // Deploying clears the item's pending draft — refresh the workspace + // Draft Count so the session bar / compare page drop it immediately. + invalidateWorkspaceDrafts(workspaceId) }} /> {/if} diff --git a/frontend/src/lib/components/sessions/SessionDiffButton.svelte b/frontend/src/lib/components/sessions/SessionDiffButton.svelte new file mode 100644 index 0000000000..77dd16d156 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionDiffButton.svelte @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/lib/components/sessions/SessionDraftBar.svelte b/frontend/src/lib/components/sessions/SessionDraftBar.svelte new file mode 100644 index 0000000000..dcc5640f2c --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionDraftBar.svelte @@ -0,0 +1,70 @@ + + +{#if committedId && count > 0} +
+
+ + {count} draft{count === 1 ? '' : 's'} +
+
+ drawer?.open()} /> + +
+
+ + +{/if} diff --git a/frontend/src/lib/components/sessions/SessionForkBar.svelte b/frontend/src/lib/components/sessions/SessionForkBar.svelte index 22b41a7a00..b1e74b43eb 100644 --- a/frontend/src/lib/components/sessions/SessionForkBar.svelte +++ b/frontend/src/lib/components/sessions/SessionForkBar.svelte @@ -2,10 +2,8 @@ import { Archive, ArrowRight, - Diff, GitCompareArrows, GitFork, - GitMerge, GitPullRequestArrow, GitPullRequestClosed, MoveRight, @@ -20,6 +18,7 @@ import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte' import { getRuntime } from './sessionRuntime.svelte' import ForkDiffDrawer from './ForkDiffDrawer.svelte' + import SessionDiffButton from './SessionDiffButton.svelte' let { session, @@ -189,24 +188,12 @@
- - + /> +
diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index 31c463e79f..b8c92a85f3 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -31,7 +31,7 @@ syncWorkspaceTo, type Session } from './sessionState.svelte' - import { forgetSessionSeen, unreadCountFor } from './sessionUnread.svelte' + import { unreadCountFor } from './sessionUnread.svelte' import Popover from '$lib/components/meltComponents/Popover.svelte' import Toggle from '$lib/components/Toggle.svelte' import { @@ -46,6 +46,7 @@ import DropdownV2 from '$lib/components/DropdownV2.svelte' import { visibleWorkspaceIds } from './sessionScope.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { copilotInfo } from '$lib/aiStore' import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' @@ -264,7 +265,6 @@ if (!session) return const wasActive = sessionState.currentSessionId === session.id removeSession(session.id) - forgetSessionSeen(session.id) if (forkToDelete) { try { await WorkspaceService.deleteWorkspace({ workspace: forkToDelete }) @@ -318,8 +318,9 @@ ) -{#if !globalEnabled} - +{#if !globalEnabled || !$copilotInfo.enabled} + {:else if isCollapsed}
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 81b6f06aa0..830cf1d2bd 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -29,6 +29,7 @@ import RawAppEditorView from './RawAppEditorView.svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import SessionForkBar from './SessionForkBar.svelte' + import SessionDraftBar from './SessionDraftBar.svelte' import { createSession, getEffectiveWorkspaceId, @@ -274,13 +275,20 @@ {#if !hasFirstUserMessage} {/if} - moveAndActivate(workspaceId)} - onCreateForkAndMove={(fork) => createForkAndMove(fork)} - onArchive={() => archiveAndReset()} - onDelete={() => (deleteConfirmOpen = true)} - /> + +
+ moveAndActivate(workspaceId)} + onCreateForkAndMove={(fork) => createForkAndMove(fork)} + onArchive={() => archiveAndReset()} + onDelete={() => (deleteConfirmOpen = true)} + /> + +
{/snippet} + +
+
+ {#if tree.children.length > 0} + {#each tree.children as child} + {@render renderTreeNode(child, 0)} + {/each} + {:else} +
No matches
+ {/if} +
+ + {/if} +
+
+ {#if loading && diffs.length === 0} +
+ + Loading comparison... +
+ {:else if error} +
{error}
+ {:else if notice} +
{notice}
+ {:else if diffs.length === 0} +
{emptyMessage}
+ {:else if filteredDiffs.length === 0} +
No files match "{searchQuery}".
+ {:else} +
+ {#each filteredDiffs as d (itemKey(d))} + {@const key = itemKey(d)} + {@const status = d.status} + {@const StatusIcon = statusIcons[status]} + {@const loaded = loadedDiffs[key]} + {@const editUrl = editUrlFor?.(d)} +
onDetailsToggle(d, e)} + > + + + +
+ {#if editUrl} + + {d.path} + + {:else} +
+ {d.path} +
+ {/if} +
+
+ {#if d.ahead && d.ahead > 0} + {d.ahead} ahead + {/if} + {#if d.behind && d.behind > 0} + {d.behind} behind + {/if} + + + {status} + +
+
+
+ {#if !loaded || loaded.state === 'loading'} +
+ + Loading diff… +
+ {:else if loaded.state === 'error'} +
{loaded.error}
+ {:else if loaded.state === 'ready'} + + {/if} +
+
+ {/each} +
+ {/if} +
+
+ + + + diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 8bfe6dda30..a6455c6ba3 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -9,9 +9,20 @@ import { WorkspaceService, type Flow, type NewScript, - type NewScriptWithDraft, + type Script, + type UserDraftOverlay, type WorkspaceComparison } from '$lib/gen' + +// `get_draft=true` does NOT merge: the top-level fields stay the deployed +// payload and the user's draft rides in a sibling `.draft` pocket (with +// `is_draft` / `draft_saved_at` alongside). Hence the `saved.draft ?? saved` +// fall-throughs below prefer the draft, else the deployed payload. +// The generated `UserDraftOverlay` types `draft` permissively; locally it's a +// `NewScript` / `Flow`, so `Omit` and re-add the precise type (assignments from +// the response type still need an explicit cast). +type SavedScript = Omit - +
-
- {#if scope === 'workspace'} - Customize the system prompts for each AI mode. These prompts apply to all workspace - members. - {:else if scope === 'instance'} - Customize the system prompts for each AI mode. These prompts apply to workspaces using - instance AI defaults. - {:else} - Customize the system prompts for each AI mode. These prompts are stored locally in your - browser and apply in addition to workspace-level prompts. - {/if} -
+ {#if readOnly} +
+ + {#if readOnlyReason} + {readOnlyReason} + {:else if scope === 'workspace'} + Only workspace admins can edit the workspace AI prompt. It applies to all workspace + members. + {:else} + This prompt is read-only. + {/if} + +
+ {:else} +
+ {#if scope === 'workspace'} + {#if singleMode} + Customize the workspace system prompt. It applies to all workspace members. + {:else} + Customize the system prompts for each AI mode. These prompts apply to all workspace + members. + {/if} + {:else if scope === 'instance'} + {#if singleMode} + Customize the system prompt. It applies to workspaces using instance AI defaults. + {:else} + Customize the system prompts for each AI mode. These prompts apply to workspaces using + instance AI defaults. + {/if} + {:else if singleMode} + Customize your system prompt. It is stored locally in your browser and applies in + addition to the workspace-level prompt. + {:else} + Customize the system prompts for each AI mode. These prompts are stored locally in your + browser and apply in addition to workspace-level prompts. + {/if} +
+ {/if} - {#each visibleModes as mode (mode)} + {#if readOnly && displayModes.length === 0} +
No workspace prompt configured.
+ {/if} + {#each displayModes as mode (mode)}
-
{/each}
-
- - {#if onSave} - - {/if} -
+ {#if !readOnly} +
+
+ {#if settingsHref} + + {/if} +
+
+ + {#if onSave} + + {/if} +
+
+ {/if}
diff --git a/frontend/src/lib/components/settings/AIUserSettings.svelte b/frontend/src/lib/components/settings/AIUserSettings.svelte index c512ba6f78..de83b15158 100644 --- a/frontend/src/lib/components/settings/AIUserSettings.svelte +++ b/frontend/src/lib/components/settings/AIUserSettings.svelte @@ -1,5 +1,7 @@
@@ -33,35 +42,52 @@
{ - updateSetting(codeCompletionSessionEnabled, e.detail, 'codeCompletionSessionEnabled') + updateAiEnabled(e.detail) }} - checked={$codeCompletionSessionEnabled} + checked={!$aiUserDisabled} options={{ - right: 'Code completion', - rightTooltip: 'AI completion in the code editors' + right: 'Windmill AI', + rightTooltip: + 'Enable Windmill AI for your account on this device. Turning this off hides the AI chat, code completion, metadata completion and flow step input completion.' }} /> - { - updateSetting(metadataCompletionEnabled, e.detail, 'metadataCompletionEnabled') - }} - checked={$metadataCompletionEnabled} - options={{ - right: 'Metadata completion', - rightTooltip: 'AI completion for summaries and descriptions' - }} - /> - { - updateSetting(stepInputCompletionEnabled, e.detail, 'stepInputCompletionEnabled') - }} - checked={$stepInputCompletionEnabled} - options={{ - right: 'Flow step input completion', - rightTooltip: 'AI completion for flow step inputs' - }} - /> +
+ { + updateSetting(codeCompletionSessionEnabled, e.detail, 'codeCompletionSessionEnabled') + }} + checked={$codeCompletionSessionEnabled} + options={{ + right: 'Code completion', + rightTooltip: 'AI completion in the code editors' + }} + /> + + { + updateSetting(metadataCompletionEnabled, e.detail, 'metadataCompletionEnabled') + }} + checked={$metadataCompletionEnabled} + options={{ + right: 'Metadata completion', + rightTooltip: 'AI completion for summaries and descriptions' + }} + /> + { + updateSetting(stepInputCompletionEnabled, e.detail, 'stepInputCompletionEnabled') + }} + checked={$stepInputCompletionEnabled} + options={{ + right: 'Flow step input completion', + rightTooltip: 'AI completion for flow step inputs' + }} + /> +
diff --git a/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte b/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte index 40405c9fe5..29fe62d2ff 100644 --- a/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte +++ b/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte @@ -46,11 +46,18 @@ function setPeers() { if (!awareness) return - $awarenessStore = Object.fromEntries( - Array.from(awareness.getStates().values()) - .filter((x) => x.name) - .map((x) => [x.name, x.url]) - ) + const states = Array.from(awareness.getStates().values()).filter((x) => x.name) + const peerMap: Record = {} + for (const state of states) { + if (state.name === $userStore?.username) { + // For current user, always use this tab's URL to avoid multi-tab flickering + peerMap[state.name] = $page.url.pathname + } else if (!Object.prototype.hasOwnProperty.call(peerMap, state.name)) { + // For other users, keep first seen URL per username (stable dedup) + peerMap[state.name] = state.url + } + } + $awarenessStore = peerMap } setPeers() diff --git a/frontend/src/lib/components/triggers/TriggerFilters.svelte b/frontend/src/lib/components/triggers/TriggerFilters.svelte index cb7f198346..e4a51818f8 100644 --- a/frontend/src/lib/components/triggers/TriggerFilters.svelte +++ b/frontend/src/lib/components/triggers/TriggerFilters.svelte @@ -10,12 +10,16 @@ filters: { key: string; value: any }[] filterLogic: 'and' | 'or' disabled?: boolean + // Set when the runnable receives the payload base64-encoded (e.g. Kafka). + // Filters always run on the message parsed as JSON, so we clarify the distinction. + payloadBase64Encoded?: boolean } let { filters = $bindable([]), filterLogic = $bindable(), - disabled = false + disabled = false, + payloadBase64Encoded = false }: Props = $props() const filterLogicItems = [ @@ -28,12 +32,20 @@ ? 'Filters will limit the execution of the trigger to only messages that match any criterion.' : 'Filters will limit the execution of the trigger to only messages that match all criteria.' ) + + let filterHelp = $derived( + 'The JSON filter checks if the value at the key is equal or a superset of the filter value. ' + + 'Keys match top-level fields of the message (parsed as JSON); to match a nested field, set an object value (e.g. key data, value {"status": "active"}).' + + (payloadBase64Encoded + ? ' The runnable still receives the payload base64-encoded; filters run on the message before that encoding.' + : '') + )

{description}
- The JSON filter checks if the value at the key is equal or a superset of the filter value. + {filterHelp}

{#if filters.length > 0}
diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 3293a171da..e628c7cc99 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -135,11 +135,16 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger(defaultValues) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultValues) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + originalConfig = structuredClone($state.snapshot(getAzureConfig())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultValues) { initialConfig = structuredClone($state.snapshot(getAzureConfig())) } - originalConfig = structuredClone($state.snapshot(getAzureConfig())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load Azure trigger: ${err.body}`, true) @@ -184,19 +189,31 @@ } } - async function loadTrigger(defaultConfig?: Record): Promise { + /** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return + return { overlay: undefined, noDeployed: false } } try { const s = await AzureTriggerService.getAzureTrigger({ workspace: $workspaceStore!, - path: initialPath + path: initialPath, + getDraft: true }) - loadTriggerConfig(s) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined + } } catch (error) { sendUserToast(`Could not load Azure trigger: ${error.body}`, true) + return { overlay: undefined, noDeployed: false } } } diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 7b1cac1264..2f146831b3 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -134,12 +134,20 @@ dirtyPath = false dirtyLocalPart = false fixedScriptPath = fixedScriptPath_ ?? '' - await loadTrigger(defaultConfig) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + // Form holds DEPLOYED here. Capture `originalConfig` as the + // deployed baseline so `hasChanged` (= current != originalConfig) + // fires whenever a draft exists, not only after the user edits. + originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger + if (draftOverlay) loadTriggerConfig(draftOverlay as Partial) if (!defaultConfig) { // If the email trigger is loaded from the backend, we to set the initial config - initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) + initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger } - originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load email trigger: ${err}`, true) @@ -209,17 +217,31 @@ preservePermissionedAs = !!cfg?.permissioned_as } - async function loadTrigger(defaultConfig?: Partial): Promise { + /** + * Apply the deployed config to the form, then return the saved-draft + * overlay (if any) so the caller can capture the deployed-only form + * state as `originalConfig` BEFORE applying the draft. See + * `NatsTriggerEditorInner` for the rationale. + */ + async function loadTrigger( + defaultConfig?: Partial + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - const s = await EmailTriggerService.getEmailTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - - loadTriggerConfig(s) + return { overlay: undefined, noDeployed: false } + } + const s = await EmailTriggerService.getEmailTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined } } diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 6e464d3f3a..356983b1fc 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -140,11 +140,16 @@ edit = true dirtyPath = false fixedScriptPath = fixedScriptPath_ ?? '' - await loadTrigger(defaultValues) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultValues) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + originalConfig = structuredClone($state.snapshot(getGcpConfig())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultValues) { initialConfig = structuredClone($state.snapshot(getGcpConfig())) } - originalConfig = structuredClone($state.snapshot(getGcpConfig())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true) @@ -191,20 +196,31 @@ } } - async function loadTrigger(defaultConfig?: Record): Promise { + /** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - try { - const s = await GcpTriggerService.getGcpTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - loadTriggerConfig(s) - } catch (error) { - sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true) - } + return { overlay: undefined, noDeployed: false } + } + try { + const s = await GcpTriggerService.getGcpTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined + } + } catch (error) { + sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true) + return { overlay: undefined, noDeployed: false } } } diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 3f281be23b..d29a3d0cab 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -230,12 +230,17 @@ edit = true dirtyPath = false dirtyRoutePath = false - await loadTrigger(defaultConfig) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + originalConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger + if (draftOverlay) loadTriggerConfig(draftOverlay as Partial) if (!defaultConfig) { // If the route is loaded from the backend, we to set the initial config - initialConfig = structuredClone($state.snapshot(getRouteConfig())) + initialConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger } - originalConfig = structuredClone($state.snapshot(getRouteConfig())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load route: ${err}`, true) @@ -339,17 +344,26 @@ preservePermissionedAs = !!cfg?.permissioned_as } - async function loadTrigger(defaultConfig?: Partial): Promise { + /** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */ + async function loadTrigger( + defaultConfig?: Partial + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - const s = await HttpTriggerService.getHttpTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - - loadTriggerConfig(s) + return { overlay: undefined, noDeployed: false } + } + const s = await HttpTriggerService.getHttpTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined } } diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 9f4d0c1c7c..b20e457cd9 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -162,11 +162,16 @@ edit = true dirtyPath = false fixedScriptPath = fixedScriptPath_ ?? '' - await loadTrigger(defaultConfig) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load Kafka trigger: ${err}`, true) @@ -248,16 +253,26 @@ preservePermissionedAs = !!cfg?.permissioned_as } - async function loadTrigger(defaultConfig?: Record): Promise { + /** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - const s = await KafkaTriggerService.getKafkaTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - loadTriggerConfig(s) + return { overlay: undefined, noDeployed: false } + } + const s = await KafkaTriggerService.getKafkaTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined } } @@ -623,7 +638,12 @@ {/if} - +
diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 3d99a2b56d..469bc0419d 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -158,11 +158,16 @@ edit = true dirtyPath = false fixedScriptPath = fixedScriptPath_ ?? '' - await loadTrigger(defaultConfig) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load mqtt trigger: ${err.body}`, true) @@ -247,20 +252,31 @@ } } - async function loadTrigger(defaultConfig?: Record): Promise { + /** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { try { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - const s = await MqttTriggerService.getMqttTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - loadTriggerConfig(s) + return { overlay: undefined, noDeployed: false } } + const s = await MqttTriggerService.getMqttTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined + } } catch (error) { sendUserToast(`Could not load mqtt trigger: ${error.body}`, true) + return { overlay: undefined, noDeployed: false } } } diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 404bf21477..d184fb65cf 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -146,11 +146,22 @@ edit = true dirtyPath = false fixedScriptPath = fixedScriptPath_ ?? '' - await loadTrigger(defaultConfig) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + // At this point the form holds the DEPLOYED config (or + // `defaultConfig` for new triggers). Capture `originalConfig` + // here so `hasChanged` (= `current != originalConfig`) compares + // against the deployed baseline; if a draft exists, applying + // the overlay below makes `current != originalConfig` fire + // the "unsaved changes" banner immediately. + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load nats trigger: ${err}`, true) @@ -230,16 +241,34 @@ preservePermissionedAs = !!cfg?.permissioned_as } - async function loadTrigger(defaultConfig?: Record): Promise { + /** + * Apply the deployed config to the form, then return the saved-draft + * overlay (if any) so the caller can capture the deployed-only form + * state as `originalConfig` BEFORE applying the draft. The + * "unsaved changes" banner compares `current` vs `originalConfig`, + * so capturing originalConfig from the deployed-only form makes the + * banner fire whenever a draft is present (instead of only after + * the user starts editing on top of the draft). + */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - const s = await NatsTriggerService.getNatsTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - loadTriggerConfig(s) + return { overlay: undefined, noDeployed: false } + } + const s = await NatsTriggerService.getNatsTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined } } diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 20659cf9c2..fad6fe18f2 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -253,11 +253,14 @@ transaction_to_track = [] tab = 'basic' fixedScriptPath = fixedScriptPath_ ?? '' - await loadTrigger(defaultConfig) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers have no deployed row, so saving must CREATE (update 404s). + edit = !noDeployed + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load postgres trigger: ${err.body}`, true) @@ -370,28 +373,55 @@ preservePermissionedAs = !!cfg?.permissioned_as } - async function loadTrigger(defaultConfig?: Record): Promise { + /** + * Apply the deployed config to the form, then return the saved-draft overlay + * so the caller captures `originalConfig` BEFORE applying the draft (see + * `NatsTriggerEditorInner.loadTrigger`). Postgres wrinkle: the publication is + * keyed by resource/name the draft may have changed, so it's fetched using + * the effective values to reflect the draft's view. + */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) if (defaultConfig?.publication) { transaction_to_track = [...defaultConfig.publication.transaction_to_track] relations = defaultConfig.publication.table_to_track ?? [] } - return - } else { - const s = await PostgresTriggerService.getPostgresTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - - const publication_data = await PostgresTriggerService.getPostgresPublication({ - path: s.postgres_resource_path, - workspace: $workspaceStore!, - publication: s.publication_name - }) - - loadTriggerConfig({ ...s, publication: publication_data }) + return { overlay: undefined, noDeployed: false } } + const s = await PostgresTriggerService.getPostgresTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + // Draft-only: synthesized stand-in, no row, so saving must CREATE. + const noDeployed = !!(s as any)?.no_deployed + + // Deployed config + publication become the `originalConfig` baseline. + const deployedPublication = await PostgresTriggerService.getPostgresPublication({ + path: deployedTrigger.postgres_resource_path, + workspace: $workspaceStore!, + publication: deployedTrigger.publication_name + }) + loadTriggerConfig({ ...deployedTrigger, publication: deployedPublication }) + + if (!draftFromBackend) return { overlay: undefined, noDeployed } + + // Refetch the publication for the draft's keys if they differ. + const effective = { ...deployedTrigger, ...draftFromBackend } + const effectivePublication = + effective.postgres_resource_path === deployedTrigger.postgres_resource_path && + effective.publication_name === deployedTrigger.publication_name + ? deployedPublication + : await PostgresTriggerService.getPostgresPublication({ + path: effective.postgres_resource_path, + workspace: $workspaceStore!, + publication: effective.publication_name + }) + return { overlay: { ...effective, publication: effectivePublication }, noDeployed } } function getCaptureConfig() { diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 0c4c23551d..0d7198bf86 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -163,11 +163,15 @@ itemKind = isFlow ? 'flow' : 'script' path = defaultCfg?.path ?? ePath fixedScriptPath = fixedScriptPath_ ?? '' - await loadSchedule(defaultCfg) - edit = true + const { overlay: draftOverlay, noDeployed } = await loadSchedule(defaultCfg) + // Draft-only schedules have no deployed row, so saving must CREATE (update 404s). + edit = !noDeployed if (!defaultCfg) { + // Form holds DEPLOYED here; capture it as `initialConfig` so the + // dirty check / banner fires whenever a saved draft exists. initialConfig = structuredClone($state.snapshot(getScheduleCfg())) } + if (draftOverlay) await loadScheduleCfg(draftOverlay) await draftSync.maybeRestore() } finally { clearTimeout(loadingTimeout) @@ -289,8 +293,10 @@ initial_script_path?: string, defaultValues?: Schedule, schedule_path?: string, - fixedScriptPath_?: string + fixedScriptPath_?: string, + opts: { getDraft?: boolean } = {} ) { + const getDraft = opts.getDraft ?? true let loadingTimeout = setTimeout(() => { showLoading = true }, 100) // Do not show loading spinner for the first 100ms @@ -298,10 +304,17 @@ try { let s: Schedule | undefined if (schedule_path) { - s = await ScheduleService.getSchedule({ + const resp = await ScheduleService.getSchedule({ workspace: $workspaceStore!, - path: schedule_path + path: schedule_path, + getDraft }) + // `.draft` holds the saved Schedule; layer it over the deployed + // fields so the form assignments below see the last-saved state. + const { draft: draftFromBackend, ...deployedSchedule } = resp as any + s = draftFromBackend + ? ({ ...deployedSchedule, ...draftFromBackend } as Schedule) + : (deployedSchedule as Schedule) initNewPath = true } else if (defaultValues) { s = defaultValues @@ -465,19 +478,36 @@ } } - async function loadSchedule(defaultCfg?: Record): Promise { - if (!defaultCfg) { - try { - const s = await ScheduleService.getSchedule({ - workspace: $workspaceStore!, - path: initialPath - }) - await loadScheduleCfg(s) - } catch (err) { - sendUserToast(`Could not load schedule: ${err}`, true) - } - } else { + /** + * Apply the deployed config to the form, then return the saved-draft overlay + * so the caller captures `initialConfig` from the deployed-only form BEFORE + * applying the draft, making the banner fire whenever a draft is present. + */ + async function loadSchedule( + defaultCfg?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { + if (defaultCfg) { await loadScheduleCfg(defaultCfg) + return { overlay: undefined, noDeployed: false } + } + try { + const s = await ScheduleService.getSchedule({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedSchedule } = s as any + await loadScheduleCfg(deployedSchedule) + return { + overlay: draftFromBackend + ? ({ ...deployedSchedule, ...draftFromBackend } as Record) + : undefined, + // Draft-only: synthesized stand-in, no row, so saving must CREATE. + noDeployed: !!(s as any).no_deployed + } + } catch (err) { + sendUserToast(`Could not load schedule: ${err}`, true) + return { overlay: undefined, noDeployed: false } } } diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 90eb5d44c7..a8f25c7167 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -141,14 +141,20 @@ edit = true dirtyPath = false fixedScriptPath = fixedScriptPath_ ?? '' - await loadTrigger(defaultConfig) - // Snapshot the *backend* config as the baseline before overlaying - // any local autosave, so hasChanged / onConfigChange correctly - // flag the local edits as unsaved changes. + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + // Snapshot the *deployed* config as the baseline before + // overlaying the saved draft, so hasChanged compares + // draft-vs-deployed and the banner fires whenever a draft + // exists. + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load sqs trigger: ${err.body}`, true) @@ -224,20 +230,31 @@ } } - async function loadTrigger(defaultConfig?: Record): Promise { + /** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { try { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - const s = await SqsTriggerService.getSqsTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - loadTriggerConfig(s) + return { overlay: undefined, noDeployed: false } } + const s = await SqsTriggerService.getSqsTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined + } } catch (error) { sendUserToast(`Could not load SQS trigger: ${error.body}`, true) + return { overlay: undefined, noDeployed: false } } } diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts index 88c1d62232..aba27de99f 100644 --- a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -1,8 +1,24 @@ import { untrack } from 'svelte' -import { UserDraft, localDraftDiffers, type UserDraftItemKind } from '$lib/userDraft.svelte' +import { deepEqual } from 'fast-equals' +import { UserDraft, normalizeDraftForCompare, type UserDraftItemKind } from '$lib/userDraft.svelte' +import { setLocalDraftHint } from '$lib/localDraftHints.svelte' type Cfg = Record +/** + * Whether `a` differs from `b` after `normalizeDraftForCompare` (JSON + * round-trip to drop `undefined`-valued keys, plus ignored deploy-directive + * fields). Nullish `a` returns `false` ("no draft" = "no divergence"). A + * `true` result narrows `a` to non-nullish `V`. + */ +function cfgDiffers(a: V | undefined | null, b: V | undefined): a is V { + if (a === undefined || a === null) return false + return !deepEqual( + normalizeDraftForCompare(a), + b === undefined ? undefined : normalizeDraftForCompare(b) + ) +} + export interface TriggerDraftSyncOptions { /** UserDraft item kind for this trigger, e.g. `'trigger_postgres'`. */ itemKind: UserDraftItemKind @@ -66,7 +82,7 @@ export interface TriggerDraftSync { * - **persist-effect**: writes form edits back through the handle, dropping * the draft when the form is back at the deployed baseline. * - * Both effect bodies are `untrack`ed and gated by `localDraftDiffers` + * Both effect bodies are `untrack`ed and gated by `cfgDiffers` * idempotence so they can't feed back into each other. Must be called once * during component init (it registers `useMany` + two `$effect`s). */ @@ -85,12 +101,18 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const hasDraft = $derived( !opts.drawerLoading() && opts.deployed() != null && - localDraftDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg) + cfgDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg) ) - function discard(path: string, fallback: Cfg | undefined): void { + /** `auto: true` marks a discard from the reactive persist-effect (not an + * explicit user action), so it respects the "Enable auto-save" toggle — else + * with autosave off the editor would delete server drafts while writing none. */ + function discard(path: string, fallback: Cfg | undefined, auto = false): void { + // `UserDraft.discard` POSTs `value: null`, which clears the list-page + // `*` hint via the syncer. No explicit clear needed. UserDraft.discard(opts.itemKind, path, fallback, { - workspace: opts.workspace() ?? undefined + workspace: opts.workspace() ?? undefined, + auto }) } @@ -99,12 +121,33 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const d = handle?.draft if (opts.drawerLoading() || d == null) return untrack(() => { - if (localDraftDiffers(d, opts.getCfg() as Cfg)) { + if (cfgDiffers(d, opts.getCfg() as Cfg)) { void opts.applyCfg(d) } }) }) + /** Deferred + revalidated auto-discard. "At baseline but a draft exists" + * also occurs transiently during programmatic churn (list pages fire + * `openEdit` twice per row click), where an immediate discard would delete a + * draft the user never touched. Re-checking after a settle window keeps the + * legit revert case while the churn case reconciles before the timer fires. */ + let discardTimer: ReturnType | undefined + function scheduleAutoDiscard() { + if (discardTimer) return + discardTimer = setTimeout(() => { + discardTimer = undefined + if (opts.drawerLoading()) return + const cfg = opts.getCfg() + const deployed = opts.deployed() + const h = handle + if (!h || cfg == null) return + if (!cfgDiffers(cfg, deployed) && cfgDiffers(h.draft, deployed)) { + discard(opts.path(), deployed, true) + } + }, 600) + } + // persist-effect: form edits → handle; drop the draft when back at the // deployed baseline. $effect(() => { @@ -115,14 +158,30 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const h = handle if (!h) return const deployed = opts.deployed() - if (localDraftDiffers(cfg, deployed)) { - if (localDraftDiffers(cfg, h.draft)) h.draft = cfg - } else { - discard(opts.path(), deployed) + if (cfgDiffers(cfg, deployed)) { + if (cfgDiffers(cfg, h.draft)) h.draft = cfg + } else if (cfgDiffers(h.draft, deployed)) { + // Only when a draft actually exists to drop: `h.draft` equals + // `deployed` right after a discard or the post-load seed. + scheduleAutoDiscard() } }) }) + // The list-page `*` hint is owned by UserDraftDbSyncer. This effect only + // CLEARS it (never sets): while settled and at baseline, drop any stale + // hint, so a draft discarded in another tab disappears on reopen. + $effect(() => { + const ws = opts.workspace() + const p = opts.path() + const loading = opts.drawerLoading() + const dirty = hasDraft + untrack(() => { + if (!ws || !p || loading) return + if (!dirty) setLocalDraftHint(ws, opts.itemKind, p, false) + }) + }) + return { get draft() { return handle?.draft @@ -138,9 +197,22 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft }, async maybeRestore() { const d = handle?.draft - if (!localDraftDiffers(d, opts.getCfg() as Cfg)) return - // Overlay the local autosave on the just-loaded backend config. - await opts.applyCfg(d) + if (cfgDiffers(d, opts.getCfg() as Cfg)) { + // Overlay the local autosave on the just-loaded backend config. + await opts.applyCfg(d) + } + // Adopt the post-load form state as the cell's baseline without + // POSTing, consuming the entry's one-shot first-write seed guard. + // Trigger drawers never write the cell programmatically on open, so + // without this the guard would swallow the user's first edit. + const ws = opts.workspace() + const p = opts.path() + const cfg = opts.getCfg() + if (ws && p && cfg != null) { + UserDraft.seed(opts.itemKind, p, structuredClone($state.snapshot(cfg)) as Cfg, { + workspace: ws + }) + } }, async resetToDeployed(path: string) { const deployedCfg = structuredClone($state.snapshot(opts.deployed())) as Cfg diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index de9c0f21c0..231de5cea4 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -651,71 +651,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] { }) } -export type FlowWithDraftAndDraftTriggers = Flow & { - draft?: Flow & { - draft_triggers?: Trigger[] - } -} - -export type NewScriptWithDraftAndDraftTriggers = NewScript & { - draft?: NewScript & { draft_triggers?: Trigger[] } - hash: string -} - -// Get rid of deployed triggers from the saved flow in the case there is a match with a deployed trigger -export function filterDraftTriggers( - savedValue: FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers, - triggersState: Triggers -): FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers { - const deployedTriggers = triggersState.triggers.filter((t) => !t.draftConfig && !t.isDraft) - - // Early return if no deployed triggers or no draft triggers to filter - if (deployedTriggers.length === 0 || !savedValue?.draft?.draft_triggers?.length) { - return savedValue - } - - const deployedTriggerKeys = new Set(deployedTriggers.map((t) => `${t.path}:${t.type}`)) - - const originalSavedDraftTriggers = savedValue.draft.draft_triggers - const keptTriggers: Trigger[] = [] - const removedTriggers: Trigger[] = [] - - // Single pass to separate kept vs removed triggers - for (const savedTrigger of originalSavedDraftTriggers) { - const triggerKey = `${savedTrigger.draftConfig?.path}:${savedTrigger.type}` - if (deployedTriggerKeys.has(triggerKey)) { - removedTriggers.push(savedTrigger) - } else { - keptTriggers.push(savedTrigger) - } - } - - // Early return if nothing was filtered - if (removedTriggers.length === 0) { - return savedValue - } - - // Update saved value - const newSavedValue = { - ...savedValue, - draft: { - ...savedValue.draft, - draft_triggers: keptTriggers.length > 0 ? keptTriggers : undefined - } - } as typeof savedValue - - const removedTriggerKeys = new Set(removedTriggers.map((t) => `${t.draftConfig?.path}:${t.type}`)) - - // Remove filtered triggers from triggersState - triggersState.setTriggers( - triggersState.triggers.filter((trigger) => { - const triggerKey = `${trigger.draftConfig?.path}:${trigger.type}` - return !removedTriggerKeys.has(triggerKey) - }) - ) - - return newSavedValue -} +export type FlowWithDraftAndDraftTriggers = Flow +export type NewScriptWithDraftAndDraftTriggers = NewScript & { hash?: string } export function getHandlerType(scriptPath: string): ErrorHandler { const handlerMap = { diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index 0567071bae..77b5149933 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -187,11 +187,16 @@ edit = true dirtyPath = false dirtyUrl = false - await loadTrigger(defaultConfig) + const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig) + // Draft-only triggers open as "new trigger prefilled from the + // draft" — no deployed row exists, so saving must CREATE (the + // update endpoint 404s). + edit = !noDeployed + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + if (draftOverlay) loadTriggerConfig(draftOverlay) if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) await draftSync.maybeRestore() } catch (err) { sendUserToast(`Could not load websocket trigger: ${err}`, true) @@ -302,16 +307,26 @@ } } - async function loadTrigger(defaultConfig?: Record): Promise { + /** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */ + async function loadTrigger( + defaultConfig?: Record + ): Promise<{ overlay: Record | undefined; noDeployed: boolean }> { if (defaultConfig) { loadTriggerConfig(defaultConfig) - return - } else { - const s = await WebsocketTriggerService.getWebsocketTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - loadTriggerConfig(s) + return { overlay: undefined, noDeployed: false } + } + const s = await WebsocketTriggerService.getWebsocketTrigger({ + workspace: $workspaceStore!, + path: initialPath, + getDraft: true + }) + const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any + loadTriggerConfig(deployedTrigger) + return { + noDeployed: !!(s as any)?.no_deployed, + overlay: draftFromBackend + ? ({ ...deployedTrigger, ...draftFromBackend } as Record) + : undefined } } diff --git a/frontend/src/lib/components/usePageDraftSync.svelte.ts b/frontend/src/lib/components/usePageDraftSync.svelte.ts new file mode 100644 index 0000000000..6edbe98070 --- /dev/null +++ b/frontend/src/lib/components/usePageDraftSync.svelte.ts @@ -0,0 +1,105 @@ +import { untrack } from 'svelte' +import { UserDraft, type UserDraftHandle, type UserDraftItemKind } from '$lib/userDraft.svelte' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + +/** + * Page-level draft orchestration for the full-page editors (scripts / flows / + * apps / apps_raw) — the page analogue of `useTriggerDraftSync`. Owns the + * per-path autosave handle (re-keyed on navigation), the live-editor-draft + * registry entry (home-page "edit draft" links), `recordRemoteSync` (a method + * so pages can't forget it and silently downgrade conflict detection to + * overwrite-as-fresh), the deployed-baseline `seed`, and the draft `remove`. + * The entity-specific backend load and new-draft template stay in the page. + */ +export interface PageDraftSyncOptions { + itemKind: UserDraftItemKind + /** Reactive draft storage path. `''` (e.g. viewing a historical hash) + * releases the handle and skips registry/sync work. */ + path: () => string + /** Reactive workspace (`$workspaceStore`). */ + workspace: () => string | undefined + /** Reactive effective path for the live-editor-draft registry (the + * draft's own `path`, used by home-page deep links). Omit to skip + * registry registration entirely (e.g. read-only hash views). */ + effectivePath?: () => string | undefined +} + +export interface PageDraftSync { + /** The live autosave handle. `draft` is the editor's bind target. */ + readonly handle: UserDraftHandle + get draft(): V | undefined + set draft(value: V | undefined) + /** Load `value` as the deployed/template baseline WITHOUT POSTing it + * (see `UserDraft.seed`). */ + seedBaseline(value: V): void + /** After a backend load, record the server's `draft_saved_at` so the + * next autosave attaches a matching `last_sync` and the server can + * reject stale writes. `undefined` clears it (no draft existed). */ + recordRemoteSync(draftSavedAt: string | undefined): void + /** Drop the draft (server row + local cell) — restore-to-deployed and + * post-deploy cleanup. */ + remove(): void +} + +export function usePageDraftSync(opts: PageDraftSyncOptions): PageDraftSync { + // One handle, re-keyed on (workspace, path); `''` path releases it. + // `canBeDisabled` because these editors carry the "Enable auto-save" toggle. + const handle = UserDraft.useReactive(() => ({ + itemKind: opts.itemKind, + path: opts.path(), + workspace: opts.workspace(), + canBeDisabled: true + })) + + // Live-editor-draft registry: lets the home-page "edit draft" link resolve + // this open editor. Re-registers on path change; cleared on teardown. + $effect(() => { + if (!opts.effectivePath) return + const ws = opts.workspace() + const p = opts.path() + const eff = opts.effectivePath() + if (!ws || !p) return + UserDraft.setLiveEditorDraft({ + workspace: ws, + itemKind: opts.itemKind, + storagePath: p, + effectivePath: eff || p + }) + return () => UserDraft.clearLiveEditorDraft(opts.itemKind, { workspace: ws, storagePath: p }) + }) + + return { + get handle() { + return handle + }, + get draft() { + return handle.draft + }, + set draft(value: V | undefined) { + handle.draft = value + }, + seedBaseline(value: V) { + const ws = opts.workspace() + const p = opts.path() + if (!ws || !p) return + UserDraft.seed(opts.itemKind, p, value, { workspace: ws }) + }, + recordRemoteSync(draftSavedAt: string | undefined) { + const ws = opts.workspace() + const p = opts.path() + if (!ws || !p) return + untrack(() => + UserDraftDbSyncer.recordRemoteSync( + { workspace: ws, itemKind: opts.itemKind, path: p }, + draftSavedAt + ) + ) + }, + remove() { + const ws = opts.workspace() + const p = opts.path() + if (!ws || !p) return + UserDraft.remove(opts.itemKind, p, { workspace: ws }) + } + } +} diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index ff1eeb9e9a..2080ecd332 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -9,7 +9,7 @@ } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { AI_PROVIDERS, fetchAvailableModels } from '../copilot/lib' + import { AI_PROVIDERS, fetchAvailableModels, providerSupportsWebSearch } from '../copilot/lib' import { supportsAutocomplete } from '../copilot/utils' import TestAiKey from '../copilot/TestAIKey.svelte' import Label from '../Label.svelte' @@ -88,8 +88,21 @@ return JSON.parse(JSON.stringify(v)) } + function normalizeProviderSettings( + providers: Exclude + ): Exclude { + return Object.fromEntries( + Object.entries(providers).map(([provider, config]) => [ + provider, + providerSupportsWebSearch(provider as AIProvider) + ? { ...config, web_search_enabled: config.web_search_enabled ?? true } + : config + ]) + ) + } + function applyConfig(config: AIConfig | undefined) { - aiProviders = clone(config?.providers ?? {}) + aiProviders = normalizeProviderSettings(clone(config?.providers ?? {})) defaultModel = config?.default_model?.model metadataModel = config?.metadata_model?.model codeCompletionModel = config?.code_completion_model?.model @@ -202,12 +215,20 @@ ) ) ) + // Keep model selections valid: when a selected model is no longer in the + // configured providers (provider disabled or model removed from the list), + // clear it. The default chat model then falls back to the first configured + // model rather than pointing at a model that no longer exists. $effect(() => { - if (Object.keys(aiProviders).length < 1) { - codeCompletionModel = undefined + if (defaultModel && !selectedAiModels.includes(defaultModel)) { defaultModel = undefined + } + if (metadataModel && !selectedAiModels.includes(metadataModel)) { metadataModel = undefined } + if (codeCompletionModel && !selectedAiModels.includes(codeCompletionModel)) { + codeCompletionModel = undefined + } }) $effect(() => { @@ -271,8 +292,7 @@ return ( !Object.values(aiProviders).every((p) => p.resource_path) || (metadataModel != undefined && metadataModel.length === 0) || - (codeCompletionModel != undefined && codeCompletionModel.length === 0) || - (Object.keys(aiProviders).length > 0 && !defaultModel) + (codeCompletionModel != undefined && codeCompletionModel.length === 0) ) } @@ -383,41 +403,17 @@ models: availableAiModels[provider].length > 0 ? [availableAiModels[provider][0]] - : [] + : [], + ...(providerSupportsWebSearch(provider as AIProvider) + ? { web_search_enabled: true } + : {}) } } - - if (availableAiModels[provider].length > 0 && !defaultModel) { - defaultModel = availableAiModels[provider][0] - } } else { aiProviders = Object.fromEntries( Object.entries(aiProviders).filter(([key]) => key !== provider) ) - if (defaultModel) { - const currentDefaultModel = Object.values(aiProviders).find( - (p) => defaultModel && p.models.includes(defaultModel) - ) - if (!currentDefaultModel) { - defaultModel = undefined - } - } - if (codeCompletionModel) { - const currentCodeCompletionModel = Object.values(aiProviders).find( - (p) => codeCompletionModel && p.models.includes(codeCompletionModel) - ) - if (!currentCodeCompletionModel) { - codeCompletionModel = undefined - } - } - if (metadataModel) { - const currentMetadataModel = Object.values(aiProviders).find( - (p) => metadataModel && p.models.includes(metadataModel) - ) - if (!currentMetadataModel) { - metadataModel = undefined - } - } + // Stale model selections are cleared reactively (see the validity $effect above). } }} /> @@ -479,6 +475,22 @@ If you don't see the model you want, you can type it manually in the selector.

+ + {#if providerSupportsWebSearch(provider as AIProvider)} + + {/if}
{/if}
@@ -495,6 +507,7 @@ placeholder="Select a default model" size="sm" class="max-w-lg" + clearable /> {/key} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index e1f5d125bc..024d94e8e6 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -26,8 +26,9 @@ import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { AI_PROVIDERS } from '$lib/components/copilot/lib' - import { LoaderCircle } from 'lucide-svelte' + import { LoaderCircle, Trash2 } from 'lucide-svelte' import PrefixedInput from '../PrefixedInput.svelte' + import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import TextInput from '../text_input/TextInput.svelte' import { jobManager } from '$lib/services/JobManager' import Alert from '../common/alert/Alert.svelte' @@ -68,9 +69,17 @@ async function validateName(id: string): Promise { checking = true - let exists = await WorkspaceService.existsWorkspace({ requestBody: { id } }) + // For forks the actual workspace id is prefixed: checking the bare id + // would report the name as free even when `wm-fork-` is taken + // (e.g. by an archived fork, which keeps its id reserved). + const effectiveId = isFork ? `${WM_FORK_PREFIX}${id}` : id + let exists = + id != '' && (await WorkspaceService.existsWorkspace({ requestBody: { id: effectiveId } })) + forkIdTaken = isFork && exists if (exists) { - errorId = 'ID already exists' + errorId = isFork + ? `A workspace with id '${effectiveId}' already exists. It may be an archived fork: archiving keeps the id reserved.` + : 'ID already exists' } else if (id != '' && !/^\w+(-\w+)*$/.test(id)) { errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash' } else { @@ -81,6 +90,26 @@ const WM_FORK_PREFIX = 'wm-fork-' + let forkIdTaken = $state(false) + let deleteExistingForkOpen = $state(false) + let deletingExistingFork = $state(false) + + async function deleteExistingFork(): Promise { + if (deletingExistingFork) return + const prefixedId = `${WM_FORK_PREFIX}${id}` + deletingExistingFork = true + try { + await WorkspaceService.deleteWorkspace({ workspace: prefixedId }) + sendUserToast(`Permanently deleted workspace ${prefixedId}`) + deleteExistingForkOpen = false + await validateName(id) + } catch (e: any) { + sendUserToast(`Failed to delete workspace ${prefixedId}: ${e?.body?.toString() ?? e}`, true) + } finally { + deletingExistingFork = false + } + } + let forkCreationLoading = $state(false) let forkCreationError = $state('') let errorMsgs: string[] = $state([]) @@ -465,6 +494,22 @@ {/if} {#if errorId} {errorId} + {#if forkIdTaken} +
+ + + Frees up the id so you can reuse it (fork owner or superadmin only) + +
+ {/if} {/if}
+ + { + deleteExistingForkOpen = false + }} + on:confirmed={() => { + deleteExistingFork() + }} +> +
+ + This will permanently delete the workspace '{WM_FORK_PREFIX}{id}' and all of its content + (scripts, flows, apps, variables, resources, runs). This cannot be undone. Unlike archiving, + this frees up the workspace id for a new fork. + +
+
diff --git a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte index ab1e080264..6a71f63c44 100644 --- a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte +++ b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte @@ -37,6 +37,7 @@ let disableDirectDeployment = $state(hasRule('DisableDirectDeployment')) let disableFork = $state(hasRule('DisableWorkspaceForking')) let restrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers')) + let restrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let selectedGroups = $state( untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? [] ) @@ -49,6 +50,7 @@ let initialDisableDirectDeployment = $state(hasRule('DisableDirectDeployment')) let initialDisableFork = $state(hasRule('DisableWorkspaceForking')) let initialRestrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers')) + let initialRestrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let initialSelectedGroups = $state( untrack(() => rule)?.bypass_groups ? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', '')) @@ -115,12 +117,14 @@ disableDirectDeployment || disableFork || restrictDeployToDeployers || + restrictAnonymousAppDeployment || selectedGroups.length > 0 || selectedUsers.length > 0 : name !== initialName || disableDirectDeployment !== initialDisableDirectDeployment || disableFork !== initialDisableFork || restrictDeployToDeployers !== initialRestrictDeployToDeployers || + restrictAnonymousAppDeployment !== initialRestrictAnonymousAppDeployment || JSON.stringify([...selectedGroups].sort()) !== JSON.stringify([...initialSelectedGroups].sort()) || JSON.stringify([...selectedUsers].sort()) !== @@ -160,6 +164,9 @@ ...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []), ...(restrictDeployToDeployers ? ['RestrictDeployToDeployers' as ProtectionRuleKind] + : []), + ...(restrictAnonymousAppDeployment + ? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind] : []) ], bypass_groups: selectedGroups, @@ -188,6 +195,9 @@ ...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []), ...(restrictDeployToDeployers ? ['RestrictDeployToDeployers' as ProtectionRuleKind] + : []), + ...(restrictAnonymousAppDeployment + ? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind] : []) ], bypass_groups: selectedGroups, @@ -202,6 +212,7 @@ initialDisableDirectDeployment = disableDirectDeployment initialDisableFork = disableFork initialRestrictDeployToDeployers = restrictDeployToDeployers + initialRestrictAnonymousAppDeployment = restrictAnonymousAppDeployment initialSelectedGroups = clone(selectedGroups) initialSelectedUsers = clone(selectedUsers) @@ -335,6 +346,20 @@ Non-deployers can still fork, browse, and request a review. + + +
+ +
+ Only workspace admins and bypass users can make an app publicly accessible without login + (anonymous execution mode). Apps that are already public can still be redeployed. +
+
diff --git a/frontend/src/lib/debouncerByKey.svelte.ts b/frontend/src/lib/debouncerByKey.svelte.ts new file mode 100644 index 0000000000..b584deca12 --- /dev/null +++ b/frontend/src/lib/debouncerByKey.svelte.ts @@ -0,0 +1,88 @@ +/** + * Per-key debouncer with a max-wait ceiling (keyed lodash + * `debounce(fn, { wait, maxWait })`). Within a chain the latest `fn` wins and + * the timer is pushed `debounceMs` out, capped at `chainStart + maxDebounceMs` + * so a steady trickle can't defer a save forever. The fire ends the chain; the + * next `schedule` starts a fresh one. Keys independent; task errors are logged + * and swallowed. + */ +import { SvelteSet } from 'svelte/reactivity' + +export type DebouncedTask = () => unknown | Promise + +export type DebouncerByKey = { + /** Replace `key`'s pending task with `fn` and set/extend the timer to + * `min(now + debounceMs, chainStart + maxDebounceMs)`. */ + schedule(key: string, fn: DebouncedTask): void + /** Drop `key`'s pending task and timer (returns whether there was one). + * Use to hand a key to an imperative path (e.g. an immediate save). */ + cancel(key: string): boolean + /** Reactively whether `key` has a queued task (SvelteSet-backed). */ + isPending(key: string): boolean +} + +type Entry = { + timer: ReturnType + task: DebouncedTask + /** Wall-clock ms of the chain's first schedule; the max-wait ceiling + * is measured from here, not "now". */ + chainStart: number +} + +export function createDebouncerByKey(opts: { + debounceMs: number + maxDebounceMs: number +}): DebouncerByKey { + const { debounceMs, maxDebounceMs } = opts + const entries = new Map() + // Reactive mirror of `entries`' keys, kept in lock-step (SvelteSet for + // per-key `isPending` subscriptions). + const pendingKeys = new SvelteSet() + + function fire(key: string): void { + const entry = entries.get(key) + if (!entry) return + entries.delete(key) + // Drop from `pendingKeys` before running: the task synchronously flips + // the key to "running" in the coalescing runner, so there's no gap. + pendingKeys.delete(key) + try { + const result = entry.task() + if (result && typeof (result as Promise).then === 'function') { + ;(result as Promise).catch((e) => + console.error('debouncerByKey: task rejected', e) + ) + } + } catch (e) { + console.error('debouncerByKey: task threw', e) + } + } + + function schedule(key: string, fn: DebouncedTask): void { + const now = Date.now() + const existing = entries.get(key) + const chainStart = existing?.chainStart ?? now + const fireAt = Math.min(now + debounceMs, chainStart + maxDebounceMs) + const delay = Math.max(0, fireAt - now) + + if (existing) clearTimeout(existing.timer) + const timer = setTimeout(() => fire(key), delay) + entries.set(key, { timer, task: fn, chainStart }) + pendingKeys.add(key) + } + + function cancel(key: string): boolean { + const existing = entries.get(key) + if (!existing) return false + clearTimeout(existing.timer) + entries.delete(key) + pendingKeys.delete(key) + return true + } + + function isPending(key: string): boolean { + return pendingKeys.has(key) + } + + return { schedule, cancel, isPending } +} diff --git a/frontend/src/lib/debouncerByKey.test.ts b/frontend/src/lib/debouncerByKey.test.ts new file mode 100644 index 0000000000..f6a51c95c5 --- /dev/null +++ b/frontend/src/lib/debouncerByKey.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createDebouncerByKey } from './debouncerByKey.svelte' + +describe('createDebouncerByKey', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('fires the task after debounceMs', () => { + const d = createDebouncerByKey({ debounceMs: 1000, maxDebounceMs: 5000 }) + const fn = vi.fn() + d.schedule('k', fn) + expect(d.isPending('k')).toBe(true) + vi.advanceTimersByTime(999) + expect(fn).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(fn).toHaveBeenCalledTimes(1) + expect(d.isPending('k')).toBe(false) + }) + + it('keeps only the latest task and pushes the timer on each schedule', () => { + const d = createDebouncerByKey({ debounceMs: 1000, maxDebounceMs: 10000 }) + const first = vi.fn() + const second = vi.fn() + d.schedule('k', first) + vi.advanceTimersByTime(800) + d.schedule('k', second) // resets the 1000ms window, replaces task + vi.advanceTimersByTime(999) + expect(second).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(first).not.toHaveBeenCalled() // replaced — never ran + expect(second).toHaveBeenCalledTimes(1) + }) + + it('caps the chain at maxDebounceMs from its start under a constant trickle', () => { + const d = createDebouncerByKey({ debounceMs: 1000, maxDebounceMs: 2500 }) + const fn = vi.fn() + // Re-schedule every 500ms — without the ceiling the 1000ms window + // would never elapse, but maxDebounceMs forces a fire by t=2500. + d.schedule('k', fn) // chainStart = 0 + for (let t = 500; t <= 2500; t += 500) { + vi.advanceTimersByTime(500) + if (t < 2500) d.schedule('k', fn) + } + expect(fn).toHaveBeenCalledTimes(1) // fired at the 2500ms ceiling + }) + + it('starts a fresh chain (and ceiling) after a fire', () => { + const d = createDebouncerByKey({ debounceMs: 1000, maxDebounceMs: 2000 }) + const fn = vi.fn() + d.schedule('k', fn) + vi.advanceTimersByTime(1000) + expect(fn).toHaveBeenCalledTimes(1) + // Next schedule is a new chain — its own full debounce applies. + d.schedule('k', fn) + vi.advanceTimersByTime(999) + expect(fn).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(1) + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('cancel stops a pending task and reports whether there was one', () => { + const d = createDebouncerByKey({ debounceMs: 1000, maxDebounceMs: 5000 }) + const fn = vi.fn() + d.schedule('k', fn) + expect(d.cancel('k')).toBe(true) + expect(d.isPending('k')).toBe(false) + vi.advanceTimersByTime(5000) + expect(fn).not.toHaveBeenCalled() + expect(d.cancel('k')).toBe(false) // nothing left to cancel + }) + + it('keeps keys independent', () => { + const d = createDebouncerByKey({ debounceMs: 1000, maxDebounceMs: 5000 }) + const a = vi.fn() + const b = vi.fn() + d.schedule('a', a) + d.schedule('b', b) + d.cancel('a') + vi.advanceTimersByTime(1000) + expect(a).not.toHaveBeenCalled() + expect(b).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/lib/draftAddRedirect.ts b/frontend/src/lib/draftAddRedirect.ts new file mode 100644 index 0000000000..2f9e381ed1 --- /dev/null +++ b/frontend/src/lib/draftAddRedirect.ts @@ -0,0 +1,32 @@ +import { redirect } from '@sveltejs/kit' +import { base } from '$app/paths' +import { getUsernameForNamespace } from '$lib/userNamespace' +import { randomUUID } from '$lib/utils/uuid' + +/** + * Shared `load` for every `/{scripts,flows,apps,apps_raw}/add` route. Doing the + * redirect in `load` (not `onMount`) avoids painting a blank frame first. + * + * Mints a fresh `u//draft_` path and 307s to + * `{base}/{editPrefix}/?new_draft=true&`. `new_draft=true` + * tells the edit route to seed an empty editor instead of 404-ing. The hash is + * carried over too — fork / handler-template buttons encode a payload into it + * that the edit route's `new_draft` branch consumes. `randomUUID` not + * `crypto.randomUUID` (WebCrypto is absent on non-secure origins). + */ +export function makeDraftAddLoad(editPrefix: string) { + return ({ url }: { url: URL }) => { + const username = getUsernameForNamespace() + // Underscores not dashes — path segments are `[a-zA-Z0-9_]` words and + // downstream consumers treat `-` as foreign. + const uuid = randomUUID().replaceAll('-', '_') + const params = new URLSearchParams(url.searchParams) + params.set('new_draft', 'true') + // `url.hash` is unavailable in `load`; read `window.location` instead + // (safe — the app is SPA-only, `ssr = false`). On client-side nav it + // points at the PREVIOUS page, but every hash-payload producer arrives + // as a full page load, so the hash is correct. + const hash = typeof window !== 'undefined' ? window.location.hash : '' + redirect(307, `${base}/${editPrefix}/u/${username}/draft_${uuid}?${params.toString()}${hash}`) + } +} diff --git a/frontend/src/lib/encryptedDraft.ts b/frontend/src/lib/encryptedDraft.ts new file mode 100644 index 0000000000..c0062058a7 --- /dev/null +++ b/frontend/src/lib/encryptedDraft.ts @@ -0,0 +1,10 @@ +/** Marker prefix for draft secret values the backend encrypted at rest + * with the workspace key (mirrors `ENCRYPTED_DRAFT_PREFIX` in + * `backend/windmill-common/src/user_drafts.rs`). The plaintext cannot be + * recovered client-side — deploying sends the marker as-is and the + * deploy endpoints decrypt it server-side. */ +export const ENCRYPTED_DRAFT_PREFIX = '$encrypted:' + +export function isEncryptedDraftValue(v: unknown): boolean { + return typeof v === 'string' && v.startsWith(ENCRYPTED_DRAFT_PREFIX) +} diff --git a/frontend/src/lib/localDraftHints.svelte.ts b/frontend/src/lib/localDraftHints.svelte.ts new file mode 100644 index 0000000000..c0c12de22a --- /dev/null +++ b/frontend/src/lib/localDraftHints.svelte.ts @@ -0,0 +1,41 @@ +/** + * Optimistic "this item has a draft" overrides for the list-page `*` suffix. + * + * The list pages render `*` from the server's `is_draft`, which only updates on + * refetch. An open editor knows the live truth and publishes it here; pages read + * `getLocalDraftHint(...) ?? is_draft`, so the editor overrides the stale flag in + * both directions immediately. SvelteMap-backed, so readers re-render on a flip. + * + * Hints PERSIST past editor teardown (the divergence/discard is synced + * server-side) and are corrected, not expired, on the next editor settle. + */ +import { SvelteMap } from 'svelte/reactivity' +import type { UserDraftItemKind } from '$lib/gen' + +const hints = new SvelteMap() + +function key(workspace: string, kind: UserDraftItemKind, path: string): string { + return `${workspace}/${kind}/${path}` +} + +export function setLocalDraftHint( + workspace: string, + kind: UserDraftItemKind, + path: string, + on: boolean +): void { + if (!workspace || !path) return + hints.set(key(workspace, kind, path), on) +} + +/** The editor-observed draft state, or `undefined` when no editor has + * published an opinion — callers fall back to the server flag: + * `getLocalDraftHint(...) ?? is_draft`. */ +export function getLocalDraftHint( + workspace: string | undefined, + kind: UserDraftItemKind, + path: string +): boolean | undefined { + if (!workspace) return undefined + return hints.get(key(workspace, kind, path)) +} diff --git a/frontend/src/lib/logoutRedirect.test.ts b/frontend/src/lib/logoutRedirect.test.ts index ca961f147d..a42a86e6ef 100644 --- a/frontend/src/lib/logoutRedirect.test.ts +++ b/frontend/src/lib/logoutRedirect.test.ts @@ -1,5 +1,33 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { toSameOriginRelativePath } from './logoutRedirect' +import { isValidLogoutRedirect, toSameOriginRelativePath } from './logoutRedirect' + +describe('isValidLogoutRedirect', () => { + beforeEach(() => { + vi.stubGlobal('window', { location: { origin: 'http://localhost:3000' } }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('accepts same-origin absolute URLs', () => { + expect(isValidLogoutRedirect('http://localhost:3000/')).toBe(true) + expect(isValidLogoutRedirect('http://localhost:3000/runs?workspace=foo')).toBe(true) + }) + + it('accepts root-relative paths', () => { + expect(isValidLogoutRedirect('/foo')).toBe(true) + }) + + it('accepts windmill.dev hosts', () => { + expect(isValidLogoutRedirect('https://app.windmill.dev/foo')).toBe(true) + }) + + it('rejects cross-origin and protocol-relative', () => { + expect(isValidLogoutRedirect('https://evil.com/')).toBe(false) + expect(isValidLogoutRedirect('//evil.com')).toBe(false) + }) +}) describe('toSameOriginRelativePath', () => { beforeEach(() => { diff --git a/frontend/src/lib/logoutRedirect.ts b/frontend/src/lib/logoutRedirect.ts index 6416eef1e5..8160dfd66d 100644 --- a/frontend/src/lib/logoutRedirect.ts +++ b/frontend/src/lib/logoutRedirect.ts @@ -7,6 +7,13 @@ export function isValidLogoutRedirect(url: string): boolean { } try { const parsed = new URL(url) + // Same-origin absolute URLs are always safe: the user can only be sent + // back to the site they're already on, which is never an open redirect. + // Mirrors toSameOriginRelativePath()'s same-origin handling, and lets a + // self-hosted instance redirect back to its own app after OAuth login. + if (typeof window !== 'undefined' && parsed.origin === window.location.origin) { + return true + } const host = parsed.hostname if (host === 'windmill.dev' || host.endsWith('.windmill.dev')) { return true diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index b86119477e..c5bc08d606 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -602,7 +602,7 @@ export const mcpEndpointTools: EndpointTool[] = [ { name: "createScript", description: "create script: Creates a new script when the path does not already exist.\nCreates a new version of an existing script when called with the same path and the current `parent_hash`", - instructions: "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed.", + instructions: "To create a NEW script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language, and leave parent_hash unset. For TypeScript, use 'bun' unless deno-specific APIs are needed. To UPDATE an existing script, do NOT delete and recreate it: call this tool with the same path and set parent_hash to the script's current hash, which you can read from the `hash` field returned by getScriptByPath. This creates a new version while preserving the script's history.", path: "/w/{workspace}/scripts/create", method: "POST", pathParamsSchema: undefined, @@ -613,6 +613,9 @@ export const mcpEndpointTools: EndpointTool[] = [ "path": { "type": "string" }, + "parent_hash": { + "type": "string" + }, "summary": { "type": "string" }, diff --git a/frontend/src/lib/rawAppDeploy.ts b/frontend/src/lib/rawAppDeploy.ts new file mode 100644 index 0000000000..972b00ebc0 --- /dev/null +++ b/frontend/src/lib/rawAppDeploy.ts @@ -0,0 +1,124 @@ +/** + * Deploy a raw app (code-based app) from its server-side draft. Raw apps can't + * be deployed through the normal AppService.updateApp/createApp path: their + * source `files` must be bundled to js/css and saved via the raw-app endpoints. + * + * This mirrors how the global AI chat deploys raw apps + * (`copilot/chat/global/core.ts` → deployDraft, case 'app'): read the item with + * its draft, normalise to an AppDraftValue, recompute the policy, bundle the + * files, then createAppRaw/updateAppRaw. The two pure transforms + * (appSourceToDraftValue / normalizeRawAppData) are re-implemented here to avoid + * importing the heavy chat module. + */ +import { get } from 'svelte/store' +import { AppService } from '$lib/gen' +import type { Policy } from '$lib/gen' +import { userStore } from '$lib/stores' +import { bundleRawAppDraft } from '$lib/components/copilot/chat/global/rawAppBundlerBridge' +import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems' +import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' + +function normalizeRawAppData(value: Record): AppDraftValue['data'] { + if (value.data?.creation) { + return { + tables: value.data.tables ?? [], + datatable: value.data.creation.datatable, + schema: value.data.creation.schema + } + } + if (value.data) return value.data + if (value.datatables) return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables } + if (value.dataTableRefs) return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs } + return { ...DEFAULT_RAW_APP_DATA } +} + +function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { + const value = (app.value ?? {}) as Record + return { + summary: app.summary ?? '', + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: normalizeRawAppData(value), + policy: app.policy ?? fallback?.policy, + custom_path: app.custom_path ?? fallback?.custom_path + } +} + +/** + * Promote a raw app's draft to deployed. Throws on failure (caller wraps into a + * DeployResult). The matching draft row is deleted server-side by the raw-app + * create/update handler, like the other deploy paths. + */ +export async function deployRawAppDraft( + workspace: string, + path: string, + deploymentMessage?: string +): Promise { + // `rawApp: true` so a never-deployed raw app (no `app` row) resolves to + // the raw_app draft kind server-side instead of 404ing. + const app = await AppService.getAppByPath({ workspace, path, getDraft: true, rawApp: true }) + const draft = (app as any).draft + // Honor a renamed draft path; the URL `path` below stays the existing item key. + const targetPath = draft?.path ?? path + const value = appSourceToDraftValue(draft ?? app, app) + + const policy = (await updateRawAppPolicy( + value.runnables as any, + value.policy as any + )) as NonNullable & Policy + if (!policy.execution_mode) { + policy.execution_mode = 'publisher' + } + + const bundle = await bundleRawAppDraft({ workspace, files: value.files }) + + const rawAppValue = { + files: value.files, + runnables: value.runnables, + data: value.data ?? { ...DEFAULT_RAW_APP_DATA } + } + const summary = value.summary ?? '' + + if (await AppService.existsApp({ workspace, path })) { + // custom_path changes require admin. Mirror RawAppEditorHeader's update path: + // admins send the draft's value (`''` to clear), non-admins send undefined so + // the backend ignores it and preserves the existing route — otherwise a + // non-admin deploying a draft for an app that has a custom route would hit + // RequireAdmin (the deployed custom_path is sent via the appSourceToDraftValue + // fallback even when unchanged). + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + await AppService.updateAppRaw({ + workspace, + path, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: isAdmin ? (value.custom_path ?? '') : undefined + }, + js: bundle.js, + css: bundle.css + } + }) + } else { + await AppService.createAppRaw({ + workspace, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: value.custom_path + }, + js: bundle.js, + css: bundle.css + } + }) + } +} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index e19a1cc34d..92c8f18946 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -118,6 +118,7 @@ export const RELATIVE_LINE_NUMBERS_SETTING_NAME = 'relativeLineNumbers' export const CODE_COMPLETION_SETTING_NAME = 'codeCompletionSessionEnabled' export const COPILOT_SESSION_MODEL_SETTING_NAME = 'copilotSessionModel' export const COPILOT_SESSION_PROVIDER_SETTING_NAME = 'copilotSessionProvider' +export const COPILOT_SESSION_REASONING_SETTING_NAME = 'copilotSessionReasoning' export const formatOnSave = writable( getLocalSetting(FORMAT_ON_SAVE_SETTING_NAME) != 'false' ) @@ -129,6 +130,13 @@ export const codeCompletionSessionEnabled = writable( getLocalSetting(CODE_COMPLETION_SETTING_NAME) != 'false' ) +export const AI_USER_DISABLED_SETTING_NAME = 'aiUserDisabled' +// Master per-user (per-device) opt-out for all Windmill AI features. Initialized at +// module load so it applies on startup, not only once the settings panel mounts. +export const aiUserDisabled = writable( + getLocalSetting(AI_USER_DISABLED_SETTING_NAME) === 'true' +) + export const usedTriggerKinds = writable([]) export let globalDbManagerDrawer: StateStore = { val: undefined } diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index aad16431cb..d688ec1502 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -600,11 +600,21 @@ export function useLocalStorageValue( * across long editing sessions. */ transformBeforePersist?: (val: T) => T + /** + * Register a `$effect` that walks the stored value on every access and + * persists when any nested field mutated. Required for callers that + * mutate the value in place (`s.foo = bar`) rather than reassigning + * via the setter. Setter-only callers (e.g. a flat string slot) should + * leave this `false` — the `$effect` requires a Svelte effect scope, + * so enabling it forces the hook to be called from inside a component. + */ + reactToNestedUpdates?: boolean } ): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } { const saveInitialValue = options?.saveInitialValue ?? true const debounceMs = options?.debounce ?? 0 const transformBeforePersist = options?.transformBeforePersist + const reactToNestedUpdates = options?.reactToNestedUpdates ?? false const serialize = (val: T) => typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val) const deserialize = (val: string): T => { @@ -672,17 +682,19 @@ export function useLocalStorageValue( pendingValue = undefined } - $effect(() => { - readFieldsRecursively(s) - const next = s === undefined ? undefined : serialize(s) - if (next === lastSerialized) return - lastSerialized = next - if (skipNextWrite) { - skipNextWrite = false - return - } - schedulePersist(s) - }) + if (reactToNestedUpdates) { + $effect(() => { + readFieldsRecursively(s) + const next = s === undefined ? undefined : serialize(s) + if (next === lastSerialized) return + lastSerialized = next + if (skipNextWrite) { + skipNextWrite = false + return + } + schedulePersist(s) + }) + } return { get val() { diff --git a/frontend/src/lib/test-setup.ts b/frontend/src/lib/test-setup.ts index 6b82fcefb6..01cacdc912 100644 --- a/frontend/src/lib/test-setup.ts +++ b/frontend/src/lib/test-setup.ts @@ -2,6 +2,10 @@ * Vitest setup file to mock browser globals for testing */ +// Provides a real in-memory IndexedDB (indexedDB / IDBKeyRange / structuredClone) +// under the node test environment so the IndexedDB-backed session list and +// chat-history stores can be exercised in unit tests. +import 'fake-indexeddb/auto' import { vi } from 'vitest' // Mock localStorage @@ -67,7 +71,8 @@ Object.defineProperty(globalThis, 'sessionStorage', { if (typeof (globalThis as any).window === 'undefined') { Object.defineProperty(globalThis, 'window', { value: globalThis, - writable: true + writable: true, + configurable: true }) } diff --git a/frontend/src/lib/tutorials/config.ts b/frontend/src/lib/tutorials/config.ts index 455c4c50e3..082dc0473d 100644 --- a/frontend/src/lib/tutorials/config.ts +++ b/frontend/src/lib/tutorials/config.ts @@ -67,7 +67,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Build a flow', description: 'Learn how to build workflows in Windmill with our interactive tutorial.', onClick: () => { - window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true` + window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial` }, index: 2, active: true, @@ -81,7 +81,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Fix a broken flow', description: 'Learn how to monitor and debug your script and flow executions.', onClick: () => { - window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true` + window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow` }, index: 3, active: true, @@ -131,7 +131,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Background runnables', description: 'Learn how to create and use background runnables in your apps.', onClick: () => { - window.location.href = `${base}/apps/add?tutorial=backgroundrunnables&nodraft=true` + window.location.href = `${base}/apps/add?tutorial=backgroundrunnables` }, index: 4, active: true, @@ -145,7 +145,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Connection', description: 'Learn how to connect component inputs to outputs in your apps.', onClick: () => { - window.location.href = `${base}/apps/add?tutorial=connection&nodraft=true` + window.location.href = `${base}/apps/add?tutorial=connection` }, index: 5, active: true, diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 69a9bba5d8..5b3bbb37a8 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -2,8 +2,14 @@ import { get } from 'svelte/store' import { onDestroy, untrack } from 'svelte' import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' -import { useLocalStorageValue } from './svelte5Utils.svelte' +import { readFieldsRecursively } from './utils' +import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte' +import type { UserDraftItemKind } from './gen' +export type { UserDraftItemKind } + +// Runtime mirror of the generated `UserDraftItemKind` union. `satisfies` +// + the exhaustiveness check below make a drift between the two a type error. export const USER_DRAFT_ITEM_KINDS = [ 'script', 'flow', @@ -29,78 +35,24 @@ export const USER_DRAFT_ITEM_KINDS = [ 'trigger_nextcloud', 'trigger_google', 'trigger_github' -] as const +] as const satisfies readonly UserDraftItemKind[] -export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number] +// Reverse direction: every union member must appear in the array above. +type _AssertKindsExhaustive = + Exclude extends never ? true : never +const _: _AssertKindsExhaustive = true +void _ export type UserDraftOptions = { workspace?: string } -export type UserDraftUseOptions = UserDraftOptions & { - /** - * Initial value used when localStorage holds no draft for this - * (workspace, itemKind, path). It is *not* eagerly persisted — the first - * actual mutation is what writes to localStorage. - */ - defaultValue?: V -} - export type UserDraftListOptions = UserDraftOptions & { itemKinds?: readonly UserDraftItemKind[] } -/** - * A single (kind, path, workspace) tuple that `useMany` should hold a handle - * for. The shape mirrors `use()`'s arguments, just bundled into one object - * so a getter can return a list of them. - */ -export type UserDraftSpec = { - itemKind: UserDraftItemKind - path: string - workspace?: string - defaultValue?: V -} - -/** - * Snapshot of the remote item's freshness at the moment the local draft was - * written. Used by editor routes to detect that the remote has moved on - * (someone else deployed, or saved a DB draft) so we can warn the user - * before they push stale changes. - * - * - `remoteRev`: the deployed version's id/hash/timestamp at draft creation. - * - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set - * for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`). - */ -export type UserDraftMeta = { - remoteRev?: string | number - remoteDraftRev?: string | number -} - -/** - * The shape of what we actually persist. Wrapping the value lets us add - * metadata (timestamps, originating user, schema version, ...) later - * without breaking existing entries. - * - * `lastWrittenAt` is the unix-ms timestamp of the most recent write - * (setter call or deep mutation flush). It's the GC signal — - * `gcUserDrafts` sweeps entries that haven't been touched in N days. - * Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`, - * `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing - * (undefined) on entries written before this field was introduced; - * `gcUserDrafts` backfills them on first sighting. - */ -type StoredDraft = { value: V; lastWrittenAt?: number } & UserDraftMeta - -function stamp(stored: StoredDraft | undefined): StoredDraft | undefined { - if (stored === undefined) return undefined - return { ...stored, lastWrittenAt: Date.now() } -} - type DraftState = { - val: StoredDraft | undefined - skipNextWriteOnce(): void - setWithoutPersist(newVal: StoredDraft | undefined): void + val: V | undefined } type DraftEntry = { @@ -110,12 +62,27 @@ type DraftEntry = { path: string state: DraftState /** - * Tears down the `$effect.root` scope that owns the entry's - * `useLocalStorageValue` reactivity — its `$state` cell and the persist - * `$effect` deep-mutation loop. Called when the refcount hits 0. - * - * `undefined` only when the test runtime's broken `$effect.root` forced - * us through the fallback path (see `acquireEntry`). + * Single-shot: skip the next reactive POST. Set by callers that already + * pushed the right thing (e.g. `discard`'s explicit `value: null` POST) + * before the reactive write, so the effect doesn't fire a duplicate. + */ + skipNextSync: boolean + /** + * Sticky `skipNextSync`: while true the sync effect updates local state + * but never POSTs. For programmatic bootstrap mutations (e.g. seeding + * `initialCode`). Toggled via `stopSync` / `restartSync`. + */ + syncSuspended: boolean + /** + * Single-shot: the next cell write is a programmatic SEED (baseline load + * / new-draft template), not a user edit. The effect adopts it as the + * baseline and skips the POST. Like `syncSuspended` but scoped to one + * write, so there's no suspension to forget to resume. Set via `seed`. + */ + seedNextWrite: boolean + /** + * Tears down the entry's `$effect.root` scope; called at refcount 0. + * `undefined` only on the test-runtime fallback path (see `acquireEntry`). */ destroyRoot?: () => void } @@ -125,9 +92,6 @@ export type UserDraftEntry = { itemKind: UserDraftItemKind path: string value: V | undefined - meta: UserDraftMeta - persisted: boolean - live: boolean } export type LiveEditorDraft = { @@ -150,6 +114,11 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & { const entries = new Map() const liveEditorDrafts = new Map() +/** + * Map keys whose entry should start `syncSuspended` on acquire. Lets + * callers `stopSync` BEFORE the editor has mounted (and called `use`). + * Consumed by `acquireEntry`; cleared by the matching `restartSync`. */ +const pendingSuspensions = new Set() function resolveWorkspace(opts?: UserDraftOptions): string { const ws = opts?.workspace ?? get(workspaceStore) @@ -161,115 +130,14 @@ function resolveWorkspace(opts?: UserDraftOptions): string { return ws } -function wrap(value: V | undefined, meta?: UserDraftMeta): StoredDraft | undefined { - if (value === undefined) return undefined - const out: StoredDraft = { value } - if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev - if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev - return out -} - -function unwrap(stored: StoredDraft | undefined): V | undefined { - return stored?.value -} - -function extractMeta(stored: StoredDraft | undefined): UserDraftMeta { - if (!stored) return {} - const meta: UserDraftMeta = {} - if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev - if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev - return meta -} - -/** - * Compares the rev metadata recorded against the local draft to the current - * backend revs. Returns the staleness cause, or `null` when the local draft - * is still based on the latest backend state we know about. - * - * - Entries with no recorded meta (legacy entries written before this field - * existed) report `null` — we can't tell if they're stale, and we'd rather - * trust the local autosave than spam the user with false positives. - * - DB-draft staleness wins over deployed-version staleness: a remote DB - * draft is the more recent state to reconcile against. - * - If a DB draft existed when the local autosave was created but now no - * longer exists on the remote (someone discarded it), we report `version` - * because the deployed version is now the canonical "latest saved". - */ -export type UserDraftStalenessCause = 'draft' | 'version' - -export function checkStaleness( - meta: UserDraftMeta, - currentRev: string | number | undefined, - currentDraftRev?: string | number | undefined -): UserDraftStalenessCause | null { - if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null - if (meta.remoteDraftRev !== currentDraftRev) { - return currentDraftRev !== undefined ? 'draft' : 'version' - } - if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version' - return null -} - -/** - * Synchronous localStorage write, bypassing the entry's debounced setter - * and its first-write skip. See `setMeta({ force: true })`. - */ -function persistDirect(key: string, value: V | undefined, meta: UserDraftMeta): void { - try { - const next = stamp(wrap(value, meta)) - if (next === undefined) { - localStorage.removeItem(key) - } else { - localStorage.setItem(key, JSON.stringify(next)) - } - } catch (e) { - console.error('UserDraft: localStorage write failed', e) - } -} - -function readPersisted(key: string): StoredDraft | undefined { - try { - const raw = localStorage.getItem(key) - if (raw == null || raw === 'undefined') return undefined - const parsed = JSON.parse(raw) - // Defensive: ignore pre-wrapping payloads (no `.value`). - if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined - return parsed as StoredDraft - } catch (e) { - console.error('UserDraft: localStorage read failed', e) - return undefined - } -} - function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string { return `${workspace}/${itemKind}/${path}` } -function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string { - return `userdraft/w/${workspace}/${itemKind}/${path}` -} - function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string { return `${workspace}/${itemKind}` } -function parseLocalStorageKey( - key: string, - workspace: string, - itemKinds: readonly UserDraftItemKind[] -): { itemKind: UserDraftItemKind; path: string } | undefined { - const prefix = `userdraft/w/${workspace}/` - if (!key.startsWith(prefix)) return undefined - const rest = key.slice(prefix.length) - for (const itemKind of itemKinds) { - const kindPrefix = `${itemKind}/` - if (rest.startsWith(kindPrefix)) { - return { itemKind, path: rest.slice(kindPrefix.length) } - } - } - return undefined -} - function snapshotDraftValue(value: V | undefined): V | undefined { if (value === undefined) return undefined try { @@ -283,69 +151,50 @@ function snapshotDraftValue(value: V | undefined): V | undefined { } } -export type UserDraftHandle = { - get draft(): V | undefined - set draft(value: V | undefined) - /** - * Read the rev metadata stored alongside the current draft. Empty object - * if the entry has no draft or no rev was ever recorded. - */ - get meta(): UserDraftMeta - /** - * Set value AND rev metadata in one write (no extra persist). Later - * `draft = X` writes preserve the rev metadata. - */ - setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void - /** - * Update rev metadata without touching the value. `{ force: true }` also - * persists synchronously — use when this may be the entry's first write, - * else the ack is lost on remount. - */ - setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void -} +/** + * Top-level fields IGNORED when diffing a draft against its deployed + * baseline. `permissioned_as` / `preserve_permissioned_as` are deploy + * run-as directives, not draft content, and the editor round-trips them + * asymmetrically (`preserve_…` rebuilt as `!!cfg.permissioned_as` on load + * but `|| undefined` on build) — keeping them produces a phantom banner. + */ +const DRAFT_COMPARE_IGNORED_FIELDS = [ + 'permissioned_as', + 'preserve_permissioned_as', + 'extra_perms' +] as const /** - * JSON round-trip normalization. localStorage persistence stringifies the - * draft, which silently drops keys whose value is `undefined`, turns `Date` - * into a string, etc. A freshly-built config object (e.g. a trigger editor's - * `getXConfig()`) keeps those `undefined`-valued keys, so a raw - * `deepEqual(persistedDraft, freshConfig)` reports spurious differences - * (`{ a: undefined }` ≠ `{}`). Normalize BOTH sides through the same - * round-trip before comparing. Returns the input unchanged if it can't be - * serialized (e.g. a cyclic structure) — better a false "differs" than a - * throw inside a load/effect path. + * Normalize one side of a draft-vs-baseline comparison: JSON round-trip + * (drafts are stored as json server-side, which strips `undefined` keys, + * so `{ labels: undefined }` and `{}` must compare equal) and drop the + * ignored fields above. Returns the input unchanged if unserializable. */ -export function normalizeForCompare(value: V | undefined): V | undefined { - if (value === undefined) return undefined +export function normalizeDraftForCompare(value: V): V { try { - return JSON.parse(JSON.stringify(value)) as V + const v = JSON.parse(JSON.stringify(value)) + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + for (const f of DRAFT_COMPARE_IGNORED_FIELDS) delete v[f] + } + return v as V } catch { return value } } /** - * Whether the persisted local autosave (`localDraft`, as returned by - * `UserDraft.get`) meaningfully differs from the freshly-built - * `currentConfig`. Editor restore guards use this to decide whether to - * overlay the local autosave and toast. - * - * Returns `false` when there is no local draft. Normalizes both sides (see - * `normalizeForCompare`) so a draft that round-trips equal to the deployed - * config — e.g. one written by merely opening then closing the editor with - * no edits — is correctly treated as "no meaningful draft" instead of - * spuriously triggering a restore on every reopen. - * - * Typed as a guard: a `true` result narrows `localDraft` to non-nullish - * `V`, mirroring the `localCfg && …` narrowing it replaces so call sites - * can pass the draft straight into `loadXConfig(...)` without re-checking. + * Normalized deep equality for draft values — THE "diverges from deployed + * baseline" check. Editors must use it for BOTH their "unsaved changes" + * banner and the `discardIf` predicate so the two can't disagree. */ -export function localDraftDiffers( - localDraft: V | undefined | null, - currentConfig: V -): localDraft is V { - if (localDraft === undefined || localDraft === null) return false - return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig)) +export function draftValuesEqual(a: unknown, b: unknown): boolean { + if (a === undefined || b === undefined) return a === b + return deepEqual(normalizeDraftForCompare(a), normalizeDraftForCompare(b)) +} + +export type UserDraftHandle = { + get draft(): V | undefined + set draft(value: V | undefined) } export const UserDraft = { @@ -354,70 +203,20 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (entry) { - // Static writes are external mutations. Update live observers and - // force the storage slot to match, even if the live entry still has - // its initial-write skip armed. - const current = untrack(() => entry.state.val as StoredDraft | undefined) - const meta = extractMeta(current) - entry.state.setWithoutPersist(wrap(value, meta)) - persistDirect(localStorageKey(ws, itemKind, path), value, meta) - return + // The reactive effect in `acquireEntry` observes this write + // and POSTs it. + entry.state.val = value + } else { + // No live handle: push straight to the syncer. The next editor + // mount re-fetches the draft from the backend. + void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value }) } - // No live handle: preserve any persisted meta so the staleness - // signal survives a write while the editor is closed. - const existing = readPersisted(localStorageKey(ws, itemKind, path)) - try { - localStorage.setItem( - localStorageKey(ws, itemKind, path), - JSON.stringify(stamp(wrap(value, extractMeta(existing)))) - ) - } catch (e) { - console.error('UserDraft.save: localStorage write failed', e) - } - }, - - setDraftAndMeta( - itemKind: UserDraftItemKind, - path: string, - value: V | undefined, - meta: UserDraftMeta, - opts?: UserDraftOptions - ): void { - const ws = resolveWorkspace(opts) - const mk = mapKey(ws, itemKind, path) - const entry = entries.get(mk) - if (entry) { - // Static writes represent explicit external draft mutations. A - // freshly acquired live entry may still have the initial-write skip - // armed, so force the storage slot to match the live value. - entry.state.setWithoutPersist(wrap(value, meta)) - persistDirect(localStorageKey(ws, itemKind, path), value, meta) - return - } - persistDirect(localStorageKey(ws, itemKind, path), value, meta) }, /** - * Autosave gate: persist `value` only when it differs (after - * `normalizeForCompare`) from the `deployed` baseline; otherwise remove - * any draft. Without this, opening and closing an editor with no edits - * would leave a no-op draft that `has()` / restore guards treat as - * unsaved work. + * Current draft value from the in-memory cell. `undefined` when no + * editor has mounted a handle for this key in this tab. */ - saveIfChanged( - itemKind: UserDraftItemKind, - path: string, - value: V, - deployed: V | undefined, - opts?: UserDraftOptions - ): void { - if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) { - UserDraft.remove(itemKind, path, opts) - } else { - UserDraft.save(itemKind, path, value, opts) - } - }, - get( itemKind: UserDraftItemKind, path: string, @@ -426,127 +225,112 @@ export const UserDraft = { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) - if (entry) { - return snapshotDraftValue(unwrap(entry.state.val as StoredDraft | undefined)) - } - return snapshotDraftValue(unwrap(readPersisted(localStorageKey(ws, itemKind, path)))) + if (!entry) return undefined + return snapshotDraftValue(entry.state.val as V | undefined) }, /** - * Update the rev metadata for an entry without touching the value, and - * persist immediately. Used by editor routes that don't hold a live - * handle (apps, raw apps) — they read the local draft via `UserDraft.get` - * and the handle is created later inside the child editor. - * - * No-op when the entry has no draft to attach meta to. - */ - saveMeta( - itemKind: UserDraftItemKind, - path: string, - meta: UserDraftMeta, - opts?: UserDraftOptions - ): void { - const ws = resolveWorkspace(opts) - const mk = mapKey(ws, itemKind, path) - const entry = entries.get(mk) - if (entry) { - const current = untrack(() => entry.state.val as StoredDraft | undefined) - if (current === undefined) return - entry.state.val = wrap(current.value, meta) - } - const existing = readPersisted(localStorageKey(ws, itemKind, path)) - if (existing === undefined) return - persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta) - }, - - /** - * Read the rev metadata for the entry. Returns an empty object if there - * is no entry. Useful for staleness checks before reading the draft. - */ - getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta { - const ws = resolveWorkspace(opts) - const mk = mapKey(ws, itemKind, path) - const entry = entries.get(mk) - if (entry) return extractMeta(entry.state.val as StoredDraft | undefined) - return extractMeta(readPersisted(localStorageKey(ws, itemKind, path))) - }, - - /** - * Whether a draft currently exists for (workspace, itemKind, path). - * Falls back to the persisted localStorage entry when no live handle is - * registered. Useful for distinguishing "first visit" from "returning - * visit with unsaved local changes". + * Whether a draft exists for this key in the in-memory cache. False when + * no editor has mounted a handle for it yet. */ has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) - if (entry) return entry.state.val !== undefined - return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined + if (!entry) return false + return entry.state.val !== undefined }, remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) - try { - localStorage.removeItem(localStorageKey(ws, itemKind, path)) - } catch (e) { - console.error('UserDraft.remove: localStorage remove failed', e) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + // Clear the cell so live observers see the delete; arm + // `skipNextSync` since we POST the `null` explicitly below. + entry.skipNextSync = true + entry.state.val = undefined } + void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null }) }, clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { UserDraft.discard(itemKind, path, undefined, opts) }, + /** + * Suspend reactive sync for this key: writes still update the cell and + * subscribers but don't POST. Use to bracket programmatic bootstrap + * mutations (seeding `initialCode`, app init) so they don't appear as + * the user's "first edit". + * + * Safe BEFORE the entry is live (queued, applied on acquire). MUST pair + * every `stopSync` with a `restartSync` — forgetting silently disables + * autosave for the rest of the session. + */ + stopSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) entry.syncSuspended = true + else pendingSuspensions.add(mk) + }, + + /** + * Resume reactive sync after `stopSync`. Subsequent differing writes + * POST normally; writes made during the suspension are dropped from the + * server's view (the cell still reflects them). Also clears a queued + * (pre-acquire) suspension. + */ + restartSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + pendingSuspensions.delete(mk) + const entry = entries.get(mk) + if (entry) entry.syncSuspended = false + }, + + /** + * Load `value` into the cell as a SEED (baseline reload / new-draft + * template) that must NOT POST as the user's edit. Reactive readers + * update immediately; the sync effect adopts it as the new baseline. + * + * Prefer over the `stopSync`/`restartSync` bracket for a single write — + * scoped to exactly this write, nothing to forget to resume. The bracket + * is still needed when a write fans out across components (e.g. an + * editor's `initContent` cascading into the bound value). + * + * No-op if the entry isn't live yet (acquire via `use`/`useMany` first). + */ + seed(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (!entry) return + entry.seedNextWrite = true + entry.state.val = snapshotDraftValue(value) + }, + + /** + * Currently-mounted live entries for `workspace` (in-tab only — for a + * workspace-wide view call `DraftService` directly). + */ list(opts?: UserDraftListOptions): UserDraftEntry[] { const ws = resolveWorkspace(opts) const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS - const out = new Map>() - - if (typeof localStorage !== 'undefined') { - const keys: string[] = [] - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i) - if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key) - } - for (const key of keys) { - const parsed = parseLocalStorageKey(key, ws, itemKinds) - if (!parsed) continue - const stored = readPersisted(key) - if (stored === undefined) continue - out.set(mapKey(ws, parsed.itemKind, parsed.path), { - workspace: ws, - itemKind: parsed.itemKind, - path: parsed.path, - value: snapshotDraftValue(unwrap(stored)), - meta: extractMeta(stored), - persisted: true, - live: false - }) - } - } - + const out: UserDraftEntry[] = [] for (const entry of entries.values()) { if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue - const stored = untrack(() => entry.state.val as StoredDraft | undefined) - const mk = mapKey(entry.workspace, entry.itemKind, entry.path) - if (stored === undefined) { - out.delete(mk) - continue - } - const existing = out.get(mk) - out.set(mk, { + const val = untrack(() => entry.state.val as V | undefined) + if (val === undefined) continue + out.push({ workspace: entry.workspace, itemKind: entry.itemKind, path: entry.path, - value: snapshotDraftValue(unwrap(stored)), - meta: extractMeta(stored), - persisted: existing?.persisted ?? false, - live: true + value: snapshotDraftValue(val) }) } - - return Array.from(out.values()) + return out }, setLiveEditorDraft(spec: LiveEditorDraftSpec): void { @@ -578,71 +362,118 @@ export const UserDraft = { }, /** - * Like `remove`, but also resets any live handle's `draft` to - * `fallback` in-memory (so reactive readers see it immediately) and - * skips re-persisting it, leaving the LS slot empty until the next real - * edit. Pass the deployed baseline as `fallback`. + * Like `remove`, but also resets any live handle's `draft` to `fallback` + * in-memory (reactive readers see it at once). The explicit `value: null` + * POST is the canonical delete; the cell update is UI convenience. * - * `fallback` is deep-cloned before being installed — otherwise a caller - * who passes their own live `$state` baseline (e.g. resource/variable - * editors' `initialStates[ws]`) would end up with `handle.draft` and the - * baseline pointing at the *same* proxy; subsequent edits would mutate - * both sides in lock-step and the dirty check would never fire. + * `fallback` MUST be deep-cloned: otherwise a caller passing their own + * live `$state` baseline (e.g. `initialStates[ws]`) would share a proxy + * with `handle.draft`, so edits mutate both sides and the dirty check + * never fires. */ discard( itemKind: UserDraftItemKind, path: string, fallback: V | undefined, - opts?: UserDraftOptions + opts?: UserDraftOptions & { + /** Mark the `value: null` POST as a reactive autosave (subject to + * the auto-save toggle) instead of an explicit action. Set by the + * trigger persist-effect's at-baseline discard; explicit discards + * leave it unset. */ + auto?: boolean + } ): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) const safeFallback = snapshotDraftValue(fallback) if (entry) { - // Drop any queued debounced write owned by this live entry before - // resetting the in-memory value. Otherwise a timer from the old - // entry can outlive unmount and later delete a freshly written - // draft for the same key. - entry.state.setWithoutPersist(wrap(safeFallback) as StoredDraft | undefined) - } - try { - localStorage.removeItem(localStorageKey(ws, itemKind, path)) - } catch (e) { - console.error('UserDraft.discard: localStorage remove failed', e) + entry.skipNextSync = true + entry.state.val = safeFallback } + void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null, auto: opts?.auto }) }, use( itemKind: UserDraftItemKind, path: string, - opts?: UserDraftUseOptions + opts?: UserDraftOptions & { + /** See the `useMany` spec field. Default `false`. */ + canBeDisabled?: boolean + } ): UserDraftHandle { - // `use()` is a single-spec wrapper around `useMany`. We untrack the - // getter so that reactive opts (e.g. `$workspaceStore`) are captured - // once at call time — the current `use()` contract is "the handle - // stays bound to this workspace until the component unmounts." Use - // `useMany` directly if you want spec changes to release/acquire - // entries as you go. + // Single-spec wrapper around `useMany`. `untrack` captures reactive + // opts (e.g. `$workspaceStore`) once: the handle stays bound to this + // workspace until unmount. For reactive `(kind, path)` use `useReactive`. const handles = UserDraft.useMany(() => untrack(() => [ - { - itemKind, - path, - workspace: opts?.workspace, - defaultValue: opts?.defaultValue - } + { itemKind, path, workspace: opts?.workspace, canBeDisabled: opts?.canBeDisabled } ]) ) return handles[0] }, - useMany(getSpecs: () => UserDraftSpec[]): UserDraftHandle[] { - // Reactive handles array, reconciled against the latest `getSpecs()` - // output. Indices line up with the spec array. Handles for the same - // (workspace, kind, path) tuple are reused across reconciles so - // callers can capture a reference and keep it alive — only the - // underlying entry's refcount moves. + /** + * Reactive single-spec variant of `use()`. `getSpec` is read inside the + * `useMany` reconcile, so a changed `(workspace, kind, path)` releases the + * old entry and acquires a new one. The returned object is a stable proxy + * forwarding `draft` to the current handle, so `bind:` lvalues survive + * re-keying. Use for reactive paths (`/scripts/edit/[...path]`). + */ + useReactive( + getSpec: () => { + itemKind: UserDraftItemKind + path: string + workspace?: string + canBeDisabled?: boolean + } + ): UserDraftHandle { + const handles = UserDraft.useMany(() => [getSpec()]) + return { + get draft(): V | undefined { + return handles[0]?.draft + }, + set draft(value: V | undefined) { + const h = handles[0] + if (h) h.draft = value + } + } + }, + + useMany( + getSpecs: () => { + itemKind: UserDraftItemKind + path: string + workspace?: string + /** + * Value the entry's cell is seeded with on first acquire. Swallowed + * by the syncer's seed guard so it never POSTs. Ignored if the + * entry already exists (refcount > 0, e.g. another live handle + * keeps its value). + */ + defaultValue?: V + /** + * Predicate: is the value about to autosave back at the deployed + * baseline? When true, the mirror POSTs a delete instead of a + * baseline-equal copy — keeping it would leave `is_draft` (and the + * list `*`) stuck on. MUST use the same comparison as the "unsaved + * changes" banner (`draftValuesEqual`) so the two can't disagree. + * Return false for draft-only items (no deployed copy — deleting on + * equality would destroy the item). Captured on first acquire. + */ + discardIf?: (val: V) => boolean + /** + * Subject autosaves to the auto-save toggle. Only the full-page + * editors (script / flow / app / raw app) opt in; while off their + * keystroke saves park for Ctrl/Cmd+S. Default `false`: drawer + * editors always sync. Captured on first acquire. + */ + canBeDisabled?: boolean + }[] + ): UserDraftHandle[] { + // Handles array reconciled against `getSpecs()`, indices aligned. + // Same-key handles are reused across reconciles so callers can hold + // a stable reference — only the entry's refcount moves. const handles = $state[]>([]) const acquired = new Set() const handleCache = new Map>() @@ -655,10 +486,32 @@ export const UserDraft = { for (const spec of specs) { const ws = spec.workspace ?? resolveWorkspace() const mk = mapKey(ws, spec.itemKind, spec.path) + + // Empty path = no draftable item (e.g. read-only + // historical-hash view that still binds an editor value). + // Acquiring would mirror edits into an unroutable + // `POST /drafts/update/kind/` (permanent "Save failed"). + // Hand out a detached, local-only handle instead. + if (!spec.path) { + let handle = handleCache.get(mk) + if (!handle) { + handle = makeDetachedHandle() + handleCache.set(mk, handle) + } + next.push(handle) + continue + } seen.add(mk) if (!acquired.has(mk)) { - acquireEntry(ws, spec.itemKind, spec.path, spec.defaultValue) + acquireEntry( + ws, + spec.itemKind, + spec.path, + spec.defaultValue, + spec.discardIf as ((val: unknown) => boolean) | undefined, + spec.canBeDisabled ?? false + ) acquired.add(mk) } let handle = handleCache.get(mk) @@ -677,24 +530,35 @@ export const UserDraft = { } } - // Skip no-op mutations (handles are cached by mapKey, so an - // unchanged spec set yields reference-equal arrays). `untrack` so - // this effect doesn't subscribe to its own `handles` write — - // otherwise it self-loops (`effect_update_depth_exceeded`). - // Downstream notification still propagates. + // Skip no-op mutations (cached handles → reference-equal arrays). + // `untrack` so this effect doesn't subscribe to its own `handles` + // write — otherwise it self-loops (`effect_update_depth_exceeded`). untrack(() => { const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i]) if (!unchanged) handles.splice(0, handles.length, ...next) }) } - // Synchronous initial reconcile so single-spec callers (`use()`) get a - // populated `handles[0]` before the function returns. Reactive reads - // inside `getSpecs()` here are intentionally not tracked — the - // `$effect` below picks up any subsequent dependency changes. + // Synchronous initial reconcile so single-spec callers get a populated + // `handles[0]` before returning. `untrack` here — the `$effect` below + // picks up subsequent changes. untrack(reconcile) $effect(reconcile) onDestroy(() => { + // Flush each entry's pending autosave BEFORE releasing it. SPA + // nav doesn't fire `pagehide`, so a debounced edit would silently + // vanish when the editor unmounts mid-typing. Fire-and-forget — + // the POST rides the runner's own lifetime, which outlives this + // component, so destroying the cell here doesn't cancel it. + for (const mk of acquired) { + const entry = entries.get(mk) + if (!entry) continue + void UserDraftDbSyncer.flush({ + workspace: entry.workspace, + itemKind: entry.itemKind, + path: entry.path + }) + } for (const mk of acquired) releaseEntry(mk) acquired.clear() handleCache.clear() @@ -708,7 +572,9 @@ function acquireEntry( workspace: string, itemKind: UserDraftItemKind, path: string, - defaultValue: unknown + defaultValue?: unknown, + discardIf?: (val: unknown) => boolean, + canBeDisabled = false ): void { const mk = mapKey(workspace, itemKind, path) const existing = entries.get(mk) @@ -716,40 +582,112 @@ function acquireEntry( existing.count++ return } - // `useLocalStorageValue`'s internal persist `$effect` would otherwise - // parent to `useMany`'s reconcile effect and be torn down on the next - // reconcile. `$effect.root` gives the entry its own scope, disposed only - // by `releaseEntry`. - const useLocalStorageOptions = { - // First value is the baseline (don't persist it); coalesce edits. - saveInitialValue: false, - debounce: 500, - // Stamp `lastWrittenAt` at persist time so deep mutations also bump - // the GC clock (the setter doesn't re-run for those). - transformBeforePersist: stamp - } as const + // Seed the cell with `defaultValue` (deep-cloned). Swallowed by + // `skipNextWrite` below — it never POSTs. + const seed = defaultValue !== undefined ? snapshotDraftValue(defaultValue) : undefined + // `$effect.root` gives the entry its own scope, disposed only by + // `releaseEntry`. Without it the sync `$effect` would parent to + // `useMany`'s reconcile effect and die on the next reconcile. let stateRef: DraftState | undefined const destroyRoot = $effect.root(() => { - stateRef = useLocalStorageValue | undefined>( - localStorageKey(workspace, itemKind, path), - wrap(defaultValue), - undefined, - useLocalStorageOptions - ) + const cell = $state<{ val: unknown }>({ val: seed }) + stateRef = cell as DraftState + // Mirror every observable change of `cell.val` to the syncer. + // `readFieldsRecursively` walks the value so deep mutations + // (`handle.draft.content = '...'`) re-fire the effect — reading + // `cell.val` alone only subscribes to the proxy root. + // + // `lastSerialized` + `skipNextWrite` dedup no-op updates and treat + // the FIRST change after mount as the seed/restore (no POST), so + // landing on `?new_draft` doesn't sync until the user edits. + // + // `cell.val === undefined` is the delete signal (`value: null`). + // `skipNextSync` lets callers that already POSTed (`discard`, + // `remove`) suppress the duplicate fire from their own write. + let lastSerialized: string | undefined = undefined + let skipNextWrite = true + $effect(() => { + const val = cell.val + if (val !== undefined) readFieldsRecursively(val) + const next = val === undefined ? undefined : JSON.stringify(val) + if (next === lastSerialized) { + // No-op write. If a `seed` re-seeded the value already in the + // cell, its one-shot flag consumed nothing — defuse it here or + // it lingers and swallows the user's NEXT edit. (`skipNextWrite` + // stays armed: an undefined-seeded cell's initial run lands + // here, and page editors rely on it to swallow their load write.) + const e = entries.get(mk) + if (e?.seedNextWrite) e.seedNextWrite = false + return + } + lastSerialized = next + if (skipNextWrite) { + skipNextWrite = false + // One write consumes BOTH one-shot guards: a `seed` on a fresh + // entry (still armed with the first-write skip) must not leave + // `seedNextWrite` behind to swallow the user's first edit. + const fresh = entries.get(mk) + if (fresh?.seedNextWrite) fresh.seedNextWrite = false + return + } + const entry = entries.get(mk) + // `seed` write: baseline already advanced above; don't POST. + if (entry?.seedNextWrite) { + entry.seedNextWrite = false + return + } + if (entry?.skipNextSync) { + entry.skipNextSync = false + return + } + // `syncSuspended` swallows the POST but `lastSerialized` advanced + // above, so only writes made during suspension are dropped — the + // first change after resume is still detected. + if (entry?.syncSuspended) return + // At the deployed baseline → sync a delete, not a baseline-equal + // copy. `untrack` so reactive reads in the predicate (the editor's + // post-deploy baseline) don't re-fire the mirror. + const atBaseline = untrack(() => val !== undefined && (discardIf?.(val) ?? false)) + void UserDraftDbSyncer.save({ + workspace, + itemKind, + path, + value: val === undefined || atBaseline ? null : val, + // Reactive keystroke mirror — `auto`, so suppressed while the + // auto-save toggle is off (for `canBeDisabled` handles). + auto: true, + canBeDisabled + }) + }) }) if (stateRef) { - entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot }) + entries.set(mk, { + count: 1, + workspace, + itemKind, + path, + state: stateRef, + skipNextSync: false, + syncSuspended: pendingSuspensions.delete(mk), + seedNextWrite: false, + destroyRoot + }) return } // Fallback for the vitest runtime where `$effect.root`'s callback isn't - // invoked. Unreachable in production (Svelte runs it synchronously). - const state = useLocalStorageValue | undefined>( - localStorageKey(workspace, itemKind, path), - wrap(defaultValue), - undefined, - useLocalStorageOptions - ) - entries.set(mk, { count: 1, workspace, itemKind, path, state }) + // invoked (unreachable in production). No sync effect — writes in tests + // stay in-memory. + const fallback = $state<{ val: unknown }>({ val: seed }) + entries.set(mk, { + count: 1, + workspace, + itemKind, + path, + state: fallback as DraftState, + skipNextSync: false, + syncSuspended: pendingSuspensions.delete(mk), + seedNextWrite: false + }) } function releaseEntry(mk: string): void { @@ -762,100 +700,40 @@ function releaseEntry(mk: string): void { } } +/** + * Local-only handle for empty-path specs: a reactive cell that supports + * `bind:` but is wired to nothing (no entry, no sync, no POSTs). For views + * that bind an editor value with no draftable item behind it. + */ +function makeDetachedHandle(): UserDraftHandle { + let val = $state(undefined) + return { + get draft(): V | undefined { + return val + }, + set draft(value: V | undefined) { + val = value + } + } +} + function makeHandle( workspace: string, itemKind: UserDraftItemKind, path: string ): UserDraftHandle { - // The handle reads `entries.get(mk)` on every access. The entry it points - // at is stable as long as the refcount stays > 0 (which `useMany` keeps - // the case for as long as a spec references it). If the refcount drops to - // 0 and the entry is destroyed, reads return `undefined` rather than - // throwing — the consumer should already have been torn down by that point. + // Reads `entries.get(mk)` on every access; the entry is stable while + // refcount > 0. After destruction, reads return `undefined` rather than + // throwing — the consumer should already be torn down by then. const mk = mapKey(workspace, itemKind, path) const stateOf = (): DraftState | undefined => entries.get(mk)?.state return { get draft(): V | undefined { - return unwrap(stateOf()?.val as StoredDraft | undefined) + return stateOf()?.val as V | undefined }, set draft(value: V | undefined) { - // Preserve existing rev metadata on a value edit. `untrack` the - // read: callers often set this from inside a `$effect` mirroring - // `$state` into the handle; a tracked read would subscribe that - // effect to the cell it's about to write (self-loop → - // effect_update_depth_exceeded). const state = stateOf() - if (!state) return - const current = untrack(() => state.val as StoredDraft | undefined) - state.val = wrap(value, extractMeta(current)) - }, - get meta(): UserDraftMeta { - return extractMeta(stateOf()?.val as StoredDraft | undefined) - }, - setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void { - const state = stateOf() - if (!state) return - state.val = wrap(value, meta) - }, - setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void { - // Read under `untrack` for the same reason as `set draft` above — - // avoid making any surrounding effect re-fire on the write below. - const state = stateOf() - if (!state) return - const current = untrack(() => state.val as StoredDraft | undefined) - if (current === undefined) return - state.val = wrap(current.value, meta) - if (opts?.force) { - persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta) - } - } - } -} - -/** - * Default GC retention window: 30 days. Entries that haven't been touched - * (no setter call, no deep-mutation persist) for this long are swept on - * the next `gcUserDrafts` invocation. - */ -export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 - -/** - * Sweep stale UserDraft entries from localStorage. Walks every - * `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes - * any entry older than `maxAgeMs`. - * - * Entries written before `lastWrittenAt` was introduced lack the field; - * we backfill them to `now()` on first sighting so they participate in - * the next sweep cycle rather than getting wiped immediately. - * - * Safe to call on every load and on a timer (e.g. every 30 min) — live - * entries get their stamp refreshed on every persist, so the sweep only - * touches truly stale records. - */ -export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void { - if (typeof localStorage === 'undefined') return - const now = Date.now() - const cutoff = now - maxAgeMs - const keys: string[] = [] - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i) - if (k != null && k.startsWith('userdraft/w/')) keys.push(k) - } - for (const key of keys) { - try { - const raw = localStorage.getItem(key) - if (raw == null) continue - const parsed = JSON.parse(raw) - if (parsed == null || typeof parsed !== 'object') continue - if (typeof parsed.lastWrittenAt !== 'number') { - // Pre-GC-feature entry. Backfill so the next sweep can decide. - parsed.lastWrittenAt = now - localStorage.setItem(key, JSON.stringify(parsed)) - continue - } - if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key) - } catch (e) { - console.error('UserDraft GC: failed to inspect', key, e) + if (state) state.val = value } } } diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts deleted file mode 100644 index 05679b823b..0000000000 --- a/frontend/src/lib/userDraft.test.ts +++ /dev/null @@ -1,1145 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' - -// Capture onDestroy callbacks so we can simulate component teardown without -// a real component context. -const onDestroyCallbacks: Array<() => void> = [] - -vi.mock('svelte', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - onDestroy: (fn: () => void) => { - onDestroyCallbacks.push(fn) - } - } -}) - -// Imported AFTER vi.mock so the module sees the mocked onDestroy. -const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } = - await import('./userDraft.svelte') -const { workspaceStore } = await import('./stores') -const { deleteGlobalDraft } = await import('./components/copilot/chat/global/userDraftAdapter') - -function flushDestroyCallbacks(): void { - const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length) - for (const cb of callbacks) cb() -} - -// UserDraft.use debounces localStorage writes by 500 ms via -// useLocalStorageValue. Tests assert localStorage state synchronously after -// writes, so we use fake timers and call this helper to fast-forward past -// the debounce window before each assertion. -function flushPersist(): void { - vi.runAllTimers() -} - -// Helper: localStorage payloads are always wrapped as { value: } so -// future metadata fields can be added without breaking existing entries. -function wrapped(value: V): string { - return JSON.stringify({ value }) -} - -// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so -// assertions can stay focused on value + rev metadata. Real entries always -// carry `lastWrittenAt` once written; the GC tests below assert on it -// directly via `localStorage.getItem`. -function storedShape(key: string): string | null { - const raw = localStorage.getItem(key) - if (raw == null) return null - const parsed = JSON.parse(raw) - delete parsed.lastWrittenAt - return JSON.stringify(parsed) -} - -beforeEach(() => { - __resetUserDraftForTesting() - onDestroyCallbacks.length = 0 - localStorage.clear() - workspaceStore.set('test_ws') - vi.useFakeTimers() -}) - -describe('UserDraft.save / get / remove (no observers)', () => { - it('save writes a wrapped { value } payload under the workspace-scoped key', () => { - UserDraft.save('flow', 'u/me/myflow', { hello: 'world' }) - - expect(storedShape('userdraft/w/test_ws/flow/u/me/myflow')).toBe(wrapped({ hello: 'world' })) - }) - - it('get reads from a wrapped localStorage payload when no observer is registered', () => { - localStorage.setItem('userdraft/w/test_ws/script/u/me/script1', wrapped('code')) - - expect(UserDraft.get('script', 'u/me/script1')).toBe('code') - }) - - it('get returns undefined when nothing is stored', () => { - expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined() - }) - - it('get returns undefined when the stored payload is malformed', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json') - expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined() - }) - - it('get returns undefined when the stored payload is unwrapped (pre-migration entry)', () => { - // Drafts written before the wrapping was introduced look like the raw - // value rather than { value: ... }. They must be ignored rather than - // surface as undefined-shaped drafts. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/raw', JSON.stringify({ hello: 'world' })) - expect(UserDraft.get('flow', 'u/me/raw')).toBeUndefined() - expect(UserDraft.has('flow', 'u/me/raw')).toBe(false) - }) - - it('remove clears the localStorage entry', () => { - UserDraft.save('app', 'u/me/app1', { grid: [] }) - expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull() - - UserDraft.remove('app', 'u/me/app1') - expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull() - }) - - it('uses the workspace from opts when provided', () => { - UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' }) - - expect(storedShape('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1)) - // Default workspace key must remain empty. - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull() - }) - - it('supports trigger kinds as item kinds', () => { - UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] }) - - expect(storedShape('userdraft/w/test_ws/trigger_kafka/u/me/topic1')).toBe( - wrapped({ brokers: ['localhost:9092'] }) - ) - }) - - it('throws when neither opts.workspace nor $workspaceStore is set', () => { - workspaceStore.set(undefined) - expect(() => UserDraft.save('flow', 'u/me/x', 1)).toThrow(/no workspace/) - }) -}) - -describe('UserDraft live editor draft registry', () => { - it('stores the live editor storage path and effective path per workspace and kind', () => { - UserDraft.setLiveEditorDraft({ - itemKind: 'script', - storagePath: '', - effectivePath: 'u/me/generated_script' - }) - - expect(UserDraft.getLiveEditorDraft('script')).toEqual({ - workspace: 'test_ws', - itemKind: 'script', - storagePath: '', - effectivePath: 'u/me/generated_script' - }) - }) - - it('keeps live editor registrations isolated by workspace', () => { - UserDraft.setLiveEditorDraft({ - workspace: 'ws_a', - itemKind: 'flow', - storagePath: '', - effectivePath: 'u/me/a' - }) - UserDraft.setLiveEditorDraft({ - workspace: 'ws_b', - itemKind: 'flow', - storagePath: '', - effectivePath: 'u/me/b' - }) - - expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_a' })?.effectivePath).toBe( - 'u/me/a' - ) - expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_b' })?.effectivePath).toBe( - 'u/me/b' - ) - }) - - it('clears only the matching live editor storage path when provided', () => { - UserDraft.setLiveEditorDraft({ - itemKind: 'raw_app', - storagePath: '', - effectivePath: 'u/me/live_app' - }) - - UserDraft.clearLiveEditorDraft('raw_app', { storagePath: 'u/me/other' }) - expect(UserDraft.getLiveEditorDraft('raw_app')).toBeDefined() - - UserDraft.clearLiveEditorDraft('raw_app', { storagePath: '' }) - expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined() - }) - - // Pins the slot-collision contract behind the SessionWrapper - // `isActiveSession` gate. Without the gate, two warm-mounted session - // editors on the same (workspace, kind) — e.g. `/sessions` keeping 3 - // warm sessions and two of them have script editors open in the same - // workspace — both call setLiveEditorDraft and the hidden one can - // clobber the visible one's claim. The fix in - // {Script,Flow,RawApp}EditorView returns early when `isActiveSession` - // is false, so only the visible session writes to the slot. - it('collides per (workspace, kind) when two callers both set — the hidden session can hijack', () => { - UserDraft.setLiveEditorDraft({ - workspace: 'ws_collide', - itemKind: 'script', - storagePath: 'session_a_path', - effectivePath: 'u/me/a' - }) - UserDraft.setLiveEditorDraft({ - workspace: 'ws_collide', - itemKind: 'script', - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - // Last write wins — exactly the bug Codex flagged: a hidden warm - // session B mounted after the active A overwrites A's claim. - expect(UserDraft.getLiveEditorDraft('script', { workspace: 'ws_collide' })).toMatchObject({ - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - }) - - it('with the active-session gate, only the visible session claims the slot', () => { - // Simulate the effect bodies in {Script,Flow,RawApp}EditorView: each - // returns early when isActiveSession is false. Session A is active, - // Session B is warm-mounted but hidden. - function registerIfActive(opts: { - isActive: boolean - workspace: string - storagePath: string - effectivePath: string - }) { - if (!opts.isActive) return - UserDraft.setLiveEditorDraft({ - workspace: opts.workspace, - itemKind: 'flow', - storagePath: opts.storagePath, - effectivePath: opts.effectivePath - }) - } - registerIfActive({ - isActive: true, - workspace: 'ws_gate', - storagePath: 'session_a_path', - effectivePath: 'u/me/a' - }) - registerIfActive({ - isActive: false, - workspace: 'ws_gate', - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_gate' })).toMatchObject({ - storagePath: 'session_a_path', - effectivePath: 'u/me/a' - }) - }) - - it('cleanup on the deactivating session is a no-op once the new active session has claimed the slot', () => { - // Active-session swap: A becomes hidden (cleanup runs), B becomes - // active and registers. Even if Svelte flushes B's effect before - // A's cleanup, A's cleanup is keyed on its own storagePath and is - // guarded so it doesn't clobber B's claim. Verified here by running - // the cleanups in reverse order. - UserDraft.setLiveEditorDraft({ - workspace: 'ws_swap', - itemKind: 'raw_app', - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - // A's cleanup runs after — should be a no-op. - UserDraft.clearLiveEditorDraft('raw_app', { - workspace: 'ws_swap', - storagePath: 'session_a_path' - }) - expect(UserDraft.getLiveEditorDraft('raw_app', { workspace: 'ws_swap' })).toMatchObject({ - storagePath: 'session_b_path' - }) - }) - - it('can remove persisted global draft storage without blanking the live editor', () => { - const draft = { path: 'u/me/live_script', content: 'export async function main() {}' } - localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft)) - const handle = UserDraft.use('script', '') - UserDraft.setLiveEditorDraft({ - itemKind: 'script', - storagePath: '', - effectivePath: 'u/me/live_script' - }) - - deleteGlobalDraft('test_ws', 'script', 'u/me/live_script', undefined, { - preserveLiveDraft: true - }) - flushPersist() - - expect(handle.draft).toEqual(draft) - expect(localStorage.getItem('userdraft/w/test_ws/script/')).toBeNull() - }) -}) - -describe('UserDraft.use() — observer sync', () => { - it('loads the existing localStorage value on first use', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded')) - - const handle = UserDraft.use('flow', 'u/me/loaded') - expect(handle.draft).toBe('preloaded') - }) - - it('two handles on the same key share the same underlying state', () => { - const a = UserDraft.use('flow', 'u/me/shared') - const b = UserDraft.use('flow', 'u/me/shared') - - a.draft = 42 - expect(b.draft).toBe(42) - - b.draft = 99 - expect(a.draft).toBe(99) - }) - - it('save() propagates to live use() handles and persists immediately', () => { - const handle = UserDraft.use('flow', 'u/me/observed') - expect(handle.draft).toBeUndefined() - - UserDraft.save('flow', 'u/me/observed', 7) - expect(handle.draft).toBe(7) - expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(7)) - - UserDraft.save('flow', 'u/me/observed', 9) - expect(handle.draft).toBe(9) - expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9)) - }) - - it('get() returns a cloneable snapshot of live handle values', () => { - const handle = UserDraft.use<{ path: string; nested: { value: number } }>('script', '') - handle.draft = { path: 'u/me/live', nested: { value: 1 } } - - const draft = UserDraft.get<{ path: string; nested: { value: number } }>('script', '') - expect(draft).toEqual({ path: 'u/me/live', nested: { value: 1 } }) - expect(draft).not.toBe(handle.draft) - expect(() => structuredClone(draft)).not.toThrow() - }) - - it('remove() clears localStorage without touching the in-memory handle', () => { - // Seed localStorage so the live handle initialises from it. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1)) - const handle = UserDraft.use('flow', 'u/me/removed') - expect(handle.draft).toBe(1) - - UserDraft.remove('flow', 'u/me/removed') - // Live handle keeps its current value — remove() only wipes the - // persisted side. This is what lets callers run UserDraft.remove - // during navigation without flickering the editor UI. - expect(handle.draft).toBe(1) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull() - }) - - it('discard() clears LS, resets the handle to the fallback, and does NOT re-persist', () => { - // Seed: handle holds a divergent local autosave. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/discard', wrapped('local-edit')) - const handle = UserDraft.use('flow', 'u/me/discard') - expect(handle.draft).toBe('local-edit') - - // Reset to a known backend baseline. - UserDraft.discard('flow', 'u/me/discard', 'backend-baseline') - flushPersist() - - // In-memory handle reflects the fallback immediately. - expect(handle.draft).toBe('backend-baseline') - // LS is cleared and stays cleared — the fallback must NOT round-trip - // back into storage (that would make the next reload "restore" the - // fallback as if it were a real autosave). - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/discard')).toBeNull() - }) - - it('discard() with undefined fallback clears both LS and in-memory state', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/wipe', wrapped('local-edit')) - const handle = UserDraft.use('flow', 'u/me/wipe') - expect(handle.draft).toBe('local-edit') - - UserDraft.discard('flow', 'u/me/wipe', undefined) - flushPersist() - - expect(handle.draft).toBeUndefined() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull() - }) - - it('the second write through the handle setter persists to localStorage', () => { - const handle = UserDraft.use('flow', 'u/me/setter') - - // First write is the baseline — not persisted. - handle.draft = 'initial' - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBeNull() - - // Second (and onwards) persists. - handle.draft = 'persisted' - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted')) - }) - - it('setting handle.draft = undefined after edits removes the localStorage entry', () => { - const handle = UserDraft.use('flow', 'u/me/clear') - handle.draft = 'initial' // baseline, not persisted - handle.draft = 'edited' // persisted - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull() - - handle.draft = undefined - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull() - expect(handle.draft).toBeUndefined() - }) - - it('two handles in different workspaces are isolated', () => { - const a = UserDraft.use('flow', 'u/me/iso', { workspace: 'ws_a' }) - const b = UserDraft.use('flow', 'u/me/iso', { workspace: 'ws_b' }) - - a.draft = 1 - b.draft = 2 - - expect(a.draft).toBe(1) - expect(b.draft).toBe(2) - }) - - it('save() falls back to localStorage when no handle is registered', () => { - UserDraft.save('flow', 'u/me/noobs', 'fallback') - // First use() afterwards loads the persisted value. - const handle = UserDraft.use('flow', 'u/me/noobs') - expect(handle.draft).toBe('fallback') - }) -}) - -describe('UserDraft.use() — defaultValue', () => { - it('returns defaultValue when localStorage has no entry', () => { - const handle = UserDraft.use('flow', 'u/me/withdefault', { defaultValue: 'fallback' }) - - expect(handle.draft).toBe('fallback') - }) - - it('does not persist the defaultValue on first read', () => { - UserDraft.use('flow', 'u/me/lazyDefault', { defaultValue: 'fallback' }) - - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull() - }) - - it('localStorage value wins over defaultValue', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/overridden', wrapped('persisted')) - - const handle = UserDraft.use('flow', 'u/me/overridden', { - defaultValue: 'fallback' - }) - - expect(handle.draft).toBe('persisted') - }) - - it('second write through the setter persists even though defaultValue was set', () => { - const handle = UserDraft.use('flow', 'u/me/writeDefault', { - defaultValue: 'fallback' - }) - - // First write is the initial-value baseline. - handle.draft = 'initial' - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBeNull() - - handle.draft = 'modified' - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified')) - }) -}) - -describe('UserDraft — empty path (new-item drafts persist across reloads)', () => { - it('use() with empty path persists subsequent edits to localStorage', () => { - const handle = UserDraft.use('flow', '', { defaultValue: 0 }) - - // First write under saveInitialValue=false counts as the baseline and - // is skipped — only the user's subsequent edits persist. - handle.draft = 99 - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull() - handle.draft = 100 - flushPersist() - // The "+ Flow / + Script / …" buttons are expected to call - // `UserDraft.remove(kind, '')` to wipe before navigating; an - // unguarded /add reload therefore restores the previous session. - expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100)) - }) - - it('two handles with empty path share state per workspace', () => { - const a = UserDraft.use('flow', '') - const b = UserDraft.use('flow', '') - - a.draft = 1 - expect(b.draft).toBe(1) - - b.draft = 2 - expect(a.draft).toBe(2) - }) - - it('save() with empty path writes to localStorage when no handle is live', () => { - UserDraft.save('flow', '', 5) - expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(5)) - }) - - it('get() with empty path falls back to localStorage when no handle is live', () => { - localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(11)) - expect(UserDraft.get('flow', '')).toBe(11) - }) - - it('remove() with empty path clears localStorage', () => { - localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(1)) - UserDraft.remove('flow', '') - expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull() - }) -}) - -describe('UserDraft — rev metadata for staleness checks', () => { - it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => { - const handle = UserDraft.use('flow', 'u/me/atomic') - - // Single atomic write — under saveInitialValue=false this counts as the - // initial baseline and shouldn't hit localStorage yet. - handle.setDraftAndMeta('backendValue', { - remoteRev: 42, - remoteDraftRev: '2026-01-01T00:00:00Z' - }) - expect(handle.draft).toBe('backendValue') - expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' }) - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull() - - // A subsequent user edit persists *with* the rev metadata. - handle.draft = 'userEdit' - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe( - JSON.stringify({ - value: 'userEdit', - remoteRev: 42, - remoteDraftRev: '2026-01-01T00:00:00Z' - }) - ) - }) - - it('setMeta updates only the rev fields, preserving the value', () => { - const handle = UserDraft.use('flow', 'u/me/setmeta') - handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted - handle.draft = 'edited' // persisted with remoteRev: 1 - - handle.setMeta({ remoteRev: 2 }) - expect(handle.draft).toBe('edited') - expect(handle.meta).toEqual({ remoteRev: 2 }) - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe( - JSON.stringify({ value: 'edited', remoteRev: 2 }) - ) - }) - - it('handle.draft setter preserves rev metadata across user edits', () => { - const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve') - handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' }) - handle.draft = { count: 1 } // first edit, persisted - handle.draft = { count: 2 } // another edit - - expect(handle.meta).toEqual({ remoteRev: 'v1' }) - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe( - JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' }) - ) - }) - - it('UserDraft.getMeta reads from localStorage when no live handle exists', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/getmeta', - JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' }) - ) - expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({ - remoteRev: 7, - remoteDraftRev: '2026-01-02' - }) - }) - - it('UserDraft.getMeta returns empty object when there is no entry', () => { - expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({}) - }) - - it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/savepreserve', - JSON.stringify({ value: 'old', remoteRev: 5 }) - ) - UserDraft.save('flow', 'u/me/savepreserve', 'new') - - expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe( - JSON.stringify({ value: 'new', remoteRev: 5 }) - ) - }) - - it('UserDraft.save persists immediately when a live handle exists', () => { - const handle = UserDraft.use('flow', 'u/me/live-save') - - UserDraft.save('flow', 'u/me/live-save', 'external') - - expect(handle.draft).toBe('external') - expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save')).toBe(wrapped('external')) - }) - - it('UserDraft.save preserves live rev metadata while forcing persistence', () => { - const handle = UserDraft.use('flow', 'u/me/live-save-meta') - handle.setDraftAndMeta('baseline', { remoteRev: 5 }) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBeNull() - - UserDraft.save('flow', 'u/me/live-save-meta', 'external') - - expect(handle.draft).toBe('external') - expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBe( - JSON.stringify({ value: 'external', remoteRev: 5 }) - ) - }) - - it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/legacy', - JSON.stringify({ value: 'no-rev' }) - ) - const handle = UserDraft.use('flow', 'u/me/legacy') - expect(handle.draft).toBe('no-rev') - expect(handle.meta).toEqual({}) - }) - - it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/forceack', - JSON.stringify({ value: 'edited', remoteRev: 'v1' }) - ) - const handle = UserDraft.use('flow', 'u/me/forceack') - - // Without force, this is the entry's first state mutation and gets - // swallowed by saveInitialValue=false — localStorage would still - // hold the old remoteRev. - handle.setMeta({ remoteRev: 'v2' }, { force: true }) - - expect(handle.meta).toEqual({ remoteRev: 'v2' }) - expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe( - JSON.stringify({ value: 'edited', remoteRev: 'v2' }) - ) - }) -}) - -describe('checkStaleness', () => { - let checkStaleness: ( - meta: { remoteRev?: string | number; remoteDraftRev?: string | number }, - currentRev: string | number | undefined, - currentDraftRev?: string | number | undefined - ) => 'draft' | 'version' | null - - beforeEach(async () => { - // Re-import to dodge ESM caching surprises across test files. - ;({ checkStaleness } = await import('./userDraft.svelte')) - }) - - it('returns null for legacy entries with no recorded rev', () => { - expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull() - }) - - it('returns null when meta matches current revs exactly', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull() - expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull() - }) - - it('returns "draft" when a newer DB draft was pushed on the remote', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft') - }) - - it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => { - expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft') - }) - - it('returns "version" when the deployed rev moved and draft revs match', () => { - expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version') - }) - - it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe( - 'version' - ) - }) - - it('prefers "draft" over "version" when both have changed', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft') - }) -}) - -describe('UserDraft.use() — reference counting & cleanup', () => { - it('destroys the entry when the last handle is released', () => { - // First handle acquires the entry. - const a = UserDraft.use('flow', 'u/me/ref') - a.draft = 1 // baseline write — not persisted - - // Second handle increments the count. - const b = UserDraft.use('flow', 'u/me/ref') - expect(b.draft).toBe(1) - - // onDestroy for both handles got registered. - expect(onDestroyCallbacks.length).toBe(2) - - // Releasing one handle keeps the entry alive — save() still updates handle a. - const firstCb = onDestroyCallbacks.shift()! - firstCb() - - UserDraft.save('flow', 'u/me/ref', 2) - expect(a.draft).toBe(2) - // External save() calls persist immediately, even with a live handle. - expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2)) - - // Releasing the second handle drops the entry; subsequent save() - // must go straight to localStorage rather than mutating in-memory - // state (which no longer exists). - const secondCb = onDestroyCallbacks.shift()! - secondCb() - - UserDraft.save('flow', 'u/me/ref', 3) - // UserDraft.save without a live entry writes synchronously. - expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3)) - }) - - it('a fresh use() after cleanup re-reads the latest persisted value', () => { - const a = UserDraft.use('flow', 'u/me/cycle') - a.draft = 'initial' // baseline — not persisted - a.draft = 'edited' // persisted (after debounce) - flushPersist() - flushDestroyCallbacks() - - // After all handles release, a brand-new use() must pick up the - // value persisted to localStorage from the previous round. - const b = UserDraft.use('flow', 'u/me/cycle') - expect(b.draft).toBe('edited') - }) - - it('coalesces a typing storm into a single localStorage write per 500 ms window', () => { - const handle = UserDraft.use('flow', 'u/me/debounce') - handle.draft = 'baseline' // first write — skipped under saveInitialValue=false - - // Three quick edits inside the 500 ms window: in-memory updates every - // time, but localStorage stays untouched until the timer fires. - handle.draft = 'one' - vi.advanceTimersByTime(100) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() - handle.draft = 'two' - vi.advanceTimersByTime(100) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() - handle.draft = 'three' - expect(handle.draft).toBe('three') - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() - - // After the window elapses, only the latest value lands. - vi.advanceTimersByTime(500) - expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three')) - }) -}) - -describe('UserDraft.useMany()', () => { - it('acquires one handle per spec in the synchronous initial reconcile', () => { - // `useMany`'s sync reconcile populates handles[0..] before returning, - // so callers (and `use()`'s 1-len wrapper) can use them immediately - // without waiting for an `$effect` tick. - const handles = UserDraft.useMany(() => [ - { itemKind: 'flow', path: 'u/me/many', workspace: 'a' }, - { itemKind: 'flow', path: 'u/me/many', workspace: 'b' } - ]) - expect(handles.length).toBe(2) - - // Each spec gets its own entry in the workspace-keyed store. - handles[0].draft = 0 // baseline - handles[0].draft = 1 // persisted - handles[1].draft = 0 - handles[1].draft = 9 - flushPersist() - expect(storedShape('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1)) - expect(storedShape('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9)) - - // One component-level onDestroy releases every acquired entry. - expect(onDestroyCallbacks.length).toBe(1) - }) -}) - -describe('gcUserDrafts', () => { - let gcUserDrafts: (maxAgeMs?: number) => void - let USER_DRAFT_GC_MAX_AGE_MS: number - const DAY = 24 * 60 * 60 * 1000 - - beforeEach(async () => { - ;({ gcUserDrafts, USER_DRAFT_GC_MAX_AGE_MS } = await import('./userDraft.svelte')) - }) - - it('sweeps entries whose lastWrittenAt is older than the cutoff', () => { - vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) - const old = Date.now() - 31 * DAY - const fresh = Date.now() - 1 * DAY - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/old', - JSON.stringify({ value: 1, lastWrittenAt: old }) - ) - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/fresh', - JSON.stringify({ value: 2, lastWrittenAt: fresh }) - ) - // Unrelated keys are left alone. - localStorage.setItem('some_other_key', 'unrelated') - - gcUserDrafts() - - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/old')).toBeNull() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/fresh')).not.toBeNull() - expect(localStorage.getItem('some_other_key')).toBe('unrelated') - }) - - it('backfills lastWrittenAt on entries lacking it, instead of sweeping them immediately', () => { - // Pre-GC-feature entry (legacy migration output, or just an old entry - // from earlier in this PR's lifecycle): no `lastWrittenAt`. First GC - // pass should stamp it as "now" rather than wipe it on sight. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/legacy', JSON.stringify({ value: 'data' })) - vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) - - gcUserDrafts() - - const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/legacy') - expect(raw).not.toBeNull() - const parsed = JSON.parse(raw!) - expect(parsed.lastWrittenAt).toBe(Date.now()) - expect(parsed.value).toBe('data') - }) - - it('exposes a 30-day default retention window', () => { - expect(USER_DRAFT_GC_MAX_AGE_MS).toBe(30 * 24 * 60 * 60 * 1000) - }) - - it('respects a custom maxAgeMs', () => { - vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/two_hours_ago', - JSON.stringify({ value: 1, lastWrittenAt: Date.now() - 2 * 60 * 60 * 1000 }) - ) - - gcUserDrafts(60 * 60 * 1000) // 1h cutoff - - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull() - }) -}) - -describe('normalizeForCompare', () => { - it('returns undefined for undefined input', () => { - expect(normalizeForCompare(undefined)).toBeUndefined() - }) - - it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => { - const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } }) - expect(out).toEqual({ a: 1, c: { e: 2 } }) - expect(Object.keys(out as object)).not.toContain('b') - expect(Object.keys((out as any).c)).not.toContain('d') - }) - - it('falls back to the original value when not serializable (cyclic)', () => { - const cyclic: any = { a: 1 } - cyclic.self = cyclic - expect(normalizeForCompare(cyclic)).toBe(cyclic) - }) -}) - -describe('localDraftDiffers', () => { - it('returns false when there is no local draft', () => { - expect(localDraftDiffers(undefined, { a: 1 })).toBe(false) - expect(localDraftDiffers(null, { a: 1 })).toBe(false) - }) - - it('treats a draft that round-trips equal to the config as NOT differing', () => { - // The Schedule bug: getXCfg() emits conditionally-undefined keys, but - // the persisted draft went through JSON.stringify which dropped them. - const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } - const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule } - expect(localDraftDiffers(persisted, freshCfg)).toBe(false) - }) - - it('returns true for a genuine difference', () => { - expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true) - expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true) - }) -}) - -describe('UserDraft.saveIfChanged', () => { - const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s' - - it('does not persist a draft equal to the deployed baseline', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } - // value is the post-load reactive cfg — same shape, undefined keys present - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) - expect(localStorage.getItem(KEY)).toBeNull() - }) - - it('treats a value that round-trips equal to deployed as unchanged', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } - const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule } - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) - expect(localStorage.getItem(KEY)).toBeNull() - }) - - it('persists when the value differs from the deployed baseline', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } - const value = { path: 'u/me/s', schedule: '5 0 * * *' } - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) - expect(storedShape(KEY)).toBe(wrapped(value)) - }) - - it('removes a pre-existing draft once the value reverts to deployed', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } - UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' }) - expect(localStorage.getItem(KEY)).not.toBeNull() - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) - expect(localStorage.getItem(KEY)).toBeNull() - }) - - it('persists when there is no deployed baseline (undefined)', () => { - const value = { path: 'u/me/s', schedule: '0 0 * * *' } - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined) - expect(storedShape(KEY)).toBe(wrapped(value)) - }) -}) - -describe('UserDraft.list / clear / setDraftAndMeta', () => { - it('enumerates persisted-only drafts for the requested workspace and kinds', () => { - UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' }) - UserDraft.setDraftAndMeta( - 'flow', - 'f/b', - { path: 'f/b', value: { modules: [] } }, - { remoteRev: 2 }, - { workspace: 'other_ws' } - ) - UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {}) - - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - { - workspace: 'test_ws', - itemKind: 'script', - path: 'f/a', - value: { path: 'f/a', content: 'a' }, - meta: { remoteRev: 'h1' }, - persisted: true, - live: false - } - ]) - expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([ - expect.objectContaining({ - workspace: 'other_ws', - itemKind: 'flow', - path: 'f/b', - persisted: true, - live: false - }) - ]) - }) - - it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => { - UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {}) - UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {}) - UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {}) - - const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] }) - - expect(entries).toHaveLength(3) - expect(entries).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - itemKind: 'script', - path: '', - value: { path: '', content: 'scratch' } - }), - expect.objectContaining({ - itemKind: 'script', - path: 'f/new-a', - value: { path: 'f/new-a', content: 'a' } - }), - expect.objectContaining({ - itemKind: 'script', - path: 'f/new-b', - value: { path: 'f/new-b', content: 'b' } - }) - ]) - ) - }) - - it('enumerates live-only drafts before the debounce persists them', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live') - handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' }) - - expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - { - workspace: 'test_ws', - itemKind: 'script', - path: 'f/live', - value: { path: 'f/live', content: 'live' }, - meta: { remoteRev: 'h1' }, - persisted: false, - live: true - } - ]) - }) - - it('dedupes entries that are both persisted and live', () => { - UserDraft.setDraftAndMeta( - 'script', - 'f/both', - { path: 'f/both', content: 'persisted' }, - { - remoteRev: 'h1' - } - ) - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both') - handle.draft = { path: 'f/both', content: 'live' } - - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - { - workspace: 'test_ws', - itemKind: 'script', - path: 'f/both', - value: { path: 'f/both', content: 'live' }, - meta: { remoteRev: 'h1' }, - persisted: true, - live: true - } - ]) - }) - - it('clear removes persisted storage and live state without re-persisting', () => { - UserDraft.setDraftAndMeta( - 'script', - 'f/clear', - { path: 'f/clear', content: 'x' }, - { - remoteRev: 'h1' - } - ) - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear') - expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' }) - - UserDraft.clear('script', 'f/clear') - flushPersist() - - expect(handle.draft).toBeUndefined() - expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) - }) - - it('clear cancels pending debounced live writes', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear') - handle.draft = { path: 'f/pending-clear', content: 'initial' } - handle.draft = { path: 'f/pending-clear', content: 'pending' } - - UserDraft.clear('script', 'f/pending-clear') - expect(handle.draft).toBeUndefined() - expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() - - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() - }) - - it('clear does not let an old debounced remove delete a later direct write', () => { - const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear' - const handle = UserDraft.use<{ path: string; content: string }>( - 'script', - 'f/rewrite-after-clear' - ) - handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' } - handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' } - - UserDraft.clear('script', 'f/rewrite-after-clear') - flushDestroyCallbacks() - UserDraft.setDraftAndMeta( - 'script', - 'f/rewrite-after-clear', - { path: 'f/rewrite-after-clear', content: 'new' }, - {} - ) - - flushPersist() - expect(storedShape(key)).toBe(wrapped({ path: 'f/rewrite-after-clear', content: 'new' })) - }) - - it('list hides persisted drafts when a live handle has cleared the value', () => { - UserDraft.setDraftAndMeta( - 'script', - 'f/live-clear', - { path: 'f/live-clear', content: 'persisted' }, - {} - ) - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear') - handle.draft = { path: 'f/live-clear', content: 'edited' } - handle.draft = undefined - - expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) - }) - - it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta') - - UserDraft.setDraftAndMeta( - 'script', - 'f/meta', - { path: 'f/meta', content: 'first' }, - { - remoteRev: 'h1', - remoteDraftRev: 'd1' - } - ) - handle.draft = { path: 'f/meta', content: 'second' } - - expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' }) - expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' }) - expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual( - expect.objectContaining({ - value: { path: 'f/meta', content: 'second' }, - meta: { remoteRev: 'h1', remoteDraftRev: 'd1' } - }) - ) - }) - - it('static setDraftAndMeta persists first writes even when a live handle exists', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live') - - UserDraft.setDraftAndMeta( - 'script', - 'f/static-live', - { path: 'f/static-live', content: 'first' }, - { remoteRev: 'h1' } - ) - - expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' }) - expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe( - JSON.stringify({ - value: { path: 'f/static-live', content: 'first' }, - remoteRev: 'h1' - }) - ) - }) - - it('lists live drafts with runtime-only values without throwing', () => { - const handle = UserDraft.use>('script', 'f/runtime') - handle.draft = { - path: 'f/runtime', - content: 'x', - callback: () => 'not serializable' - } - - expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - expect.objectContaining({ - itemKind: 'script', - path: 'f/runtime', - value: { path: 'f/runtime', content: 'x' } - }) - ]) - }) -}) diff --git a/frontend/src/lib/userDraftDbMigration.ts b/frontend/src/lib/userDraftDbMigration.ts new file mode 100644 index 0000000000..533961c859 --- /dev/null +++ b/frontend/src/lib/userDraftDbMigration.ts @@ -0,0 +1,220 @@ +/** + * One-off migration from the localStorage UserDraft autosave to the + * DB-backed `draft` table. Runs after `migrateLegacyUserDrafts` (which + * produces the `userdraft/w/{workspace}/{kind}/{path}` keys this reads), + * POSTing each to `/drafts/update` and clearing the source key only on + * success — so it's idempotent without a sentinel; failed entries retry next + * mount. Not workspace-gated: keys embed their own workspace and the token + * covers all of them, so gating would orphan other-workspace entries. + * Deliberately self-contained (no `userDraft.svelte.ts` import) so the + * runtime module stays free of legacy decoders. + */ + +import { DraftService } from './gen' +import type { UserDraftItemKind } from './gen' +import { sendUserToast } from './toast' +import { getUsernameForNamespace } from './userNamespace' +import { randomUUID } from './utils/uuid' + +// Mirror of `USER_DRAFT_ITEM_KINDS`, inlined to avoid importing the reactive +// runtime. The `_Exhaustive` assertion below fails compilation if this list +// drifts from the OpenAPI schema's kinds. +const ITEM_KINDS = [ + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +] as const satisfies readonly UserDraftItemKind[] + +type _Exhaustive = + Exclude extends never ? true : never +const _: _Exhaustive = true +void _ + +const KEY_PREFIX = 'userdraft/w/' + +type ParsedKey = { + key: string + workspace: string + itemKind: UserDraftItemKind + path: string +} + +/** + * Split a `userdraft/w/{workspace}/{kind}/{path}` key into its parts, or + * `undefined` for keys that don't match or have an unrecognized kind. + * `path` is `''` only for a legacy `/add` autosave (key ended `.../{kind}/`); + * the caller mints a fresh slot for those. An empty `path` here is always that + * case, never garbage, since a non-matching key returns `undefined`. + */ +function parseKey(key: string): ParsedKey | undefined { + if (!key.startsWith(KEY_PREFIX)) return undefined + const rest = key.slice(KEY_PREFIX.length) + const firstSlash = rest.indexOf('/') + if (firstSlash <= 0) return undefined + const workspace = rest.slice(0, firstSlash) + const afterWorkspace = rest.slice(firstSlash + 1) + for (const kind of ITEM_KINDS) { + const kindPrefix = `${kind}/` + if (afterWorkspace.startsWith(kindPrefix)) { + const path = afterWorkspace.slice(kindPrefix.length) + return { key, workspace, itemKind: kind, path } + } + } + return undefined +} + +/** + * Mint a fresh `u/{user}/draft_{uuid}` slot for a pathless legacy `/add` + * autosave, matching the editors' `/add` redirect convention (`makeDraftAddLoad`). + * Underscores not dashes (path segments are `[a-zA-Z0-9_]` words); `randomUUID` + * not `crypto.randomUUID` (WebCrypto is absent on non-secure origins). + */ +function mintDraftAddPath(): string { + const username = getUsernameForNamespace() + const uuid = randomUUID().replaceAll('-', '_') + return `u/${username}/draft_${uuid}` +} + +/** + * Extract `value` and `lastWrittenAt` from the LS payload (`{ value, lastWrittenAt?, ... }`). + * `lastWrittenAt` rides along as `last_sync` so a fresher server draft wins. + * `undefined` when the slot is empty / unparseable / wrong shape. + */ +function readPayload(key: string): { value: unknown; lastWrittenAt?: number } | undefined { + try { + const raw = localStorage.getItem(key) + if (raw == null || raw === 'undefined') return undefined + const parsed = JSON.parse(raw) + if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined + const lastWrittenAt = (parsed as { lastWrittenAt?: unknown }).lastWrittenAt + return { + value: (parsed as { value: unknown }).value, + lastWrittenAt: typeof lastWrittenAt === 'number' ? lastWrittenAt : undefined + } + } catch { + return undefined + } +} + +function collectKeys(): string[] { + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k != null && k.startsWith(KEY_PREFIX)) keys.push(k) + } + return keys +} + +/** + * Push every LS `userdraft/...` entry to `/drafts/update`, clearing each on + * success; failures stay in LS and retry next mount. + * + * `lastWrittenAt` rides as `last_sync` so the server rejects the upload when its + * own draft is fresher (the user may have edited the same path from another + * browser since this LS write). Missing `lastWrittenAt` uses epoch 0 (insert if + * absent, else yield). A `conflict` response means the server won; drop the LS + * copy. Never throws — the caller is fire-and-forget. + */ +export async function migrateUserDraftsToDb(): Promise { + if (typeof localStorage === 'undefined') return + const keys = collectKeys() + if (keys.length === 0) return + + // Parse up front so we only announce the migration when there's a real + // `userdraft/...` entry to upload (and can drop unparseable junk first). + const toMigrate: { + key: string + parsed: ParsedKey + path: string + value: unknown + lastWrittenAt?: number + }[] = [] + for (const key of keys) { + const parsed = parseKey(key) + if (!parsed) continue + const payload = readPayload(key) + if (payload === undefined) { + // Unparseable or empty — clear so we don't keep retrying it. + try { + localStorage.removeItem(key) + } catch { + // ignore + } + continue + } + // A legacy `/add` autosave has no path — mint a fresh slot so it lands + // as a regular draft-only item instead of being dropped. + const path = parsed.path === '' ? mintDraftAddPath() : parsed.path + toMigrate.push({ + key, + parsed, + path, + value: payload.value, + lastWrittenAt: payload.lastWrittenAt + }) + } + if (toMigrate.length === 0) return + + // Legacy drafts detected — tell the user the one-off upload is running. + sendUserToast('Migrating local storage drafts ...', 'info') + + for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) { + try { + const res = await DraftService.updateDraft({ + workspace: parsed.workspace, + kind: parsed.itemKind, + path, + requestBody: { value, last_sync: new Date(lastWrittenAt ?? 0).toISOString() } + }) + if (res.status === 'conflict') { + console.info( + `UserDraft LS→DB migration: server draft for ${path} is fresher, dropping LS copy` + ) + } + try { + localStorage.removeItem(key) + } catch { + // Best-effort. If LS removal fails the next mount retries + // the save (the conflict rule keeps it idempotent). + } + } catch (e) { + // Leave the LS entry in place — the next mount tries again — but + // surface it so the user isn't silently stuck, with an escape + // hatch to drop the un-migratable draft. + console.error('UserDraft LS→DB migration: failed for', key, e) + sendUserToast(`Could not migrate draft ${path} in workspace ${parsed.workspace}`, 'error', [ + { + label: 'Delete draft', + callback: () => { + try { + localStorage.removeItem(key) + } catch { + // ignore + } + } + } + ]) + } + } +} diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts new file mode 100644 index 0000000000..23257c6239 --- /dev/null +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -0,0 +1,475 @@ +import { SvelteMap } from 'svelte/reactivity' +import { DraftService, type UserDraftItemKind } from './gen' +import { OpenAPI } from './gen/core/OpenAPI' +import { createCoalescingKeyedRunner } from './coalescingRunner.svelte' +import { createDebouncerByKey } from './debouncerByKey.svelte' +import { setLocalDraftHint } from './localDraftHints.svelte' + +/** + * Per-draft baseline timestamp attached as `last_sync` on the next save so + * the backend can reject stale writes (`created_at <= last_sync`). + * + * MUST be tab-local, not localStorage: each tab tracks its own baseline. + * Sharing it would let tab-1's save advance tab-2's `last_sync` to a fresh + * timestamp, so tab-2's next save would pass the server's WHERE check and + * clobber tab-1 — the exact conflict this feature prevents. The cost: a + * fresh tab has no `last_sync` (first save lands unconditionally), but the + * editor's `recordRemoteSync` reseeds from the server timestamp on load + * before any user edit can save. + */ +type DraftLastSyncEntry = { lastSync: string } +const lastSyncMap = new Map() + +/** Must match `mapKey` in `userDraft.svelte.ts`. */ +function draftKey(workspace: string, itemKind: UserDraftItemKind, path: string): string { + return `${workspace}/${itemKind}/${path}` +} + +function getLastSyncEntry(key: string): DraftLastSyncEntry | undefined { + return lastSyncMap.get(key) +} + +function setLastSync( + workspace: string, + itemKind: UserDraftItemKind, + path: string, + lastSync: string +): void { + lastSyncMap.set(draftKey(workspace, itemKind, path), { lastSync }) +} + +function clearLastSync(workspace: string, itemKind: UserDraftItemKind, path: string): void { + lastSyncMap.delete(draftKey(workspace, itemKind, path)) +} + +export type UserDraftDbSyncerSaveOpts = { + workspace: string + itemKind: UserDraftItemKind + path: string + /** `null` signals a delete — same conflict rules as an upsert. */ + value: unknown | null + /** Bypass the debouncer: cancel any pending autosave for this key (it + * would otherwise overwrite what we send), route through the coalescing + * runner to preserve ordering against an in-flight POST, and resolve + * the returned promise only once the POST lands. Use for + * `await save(...); read-the-server` flows where a fire-and-forget save + * would race the next read. */ + immediate?: boolean + /** Skip the optimistic-concurrency check and overwrite the server row. + * Used by the conflict-resolution UI ("Overwrite the remote"). Default + * `false`: autosaves attach `last_sync` and respect a reject. */ + force?: boolean + /** Save came from the reactive autosave mirror, not an explicit user + * action (Ctrl/Cmd+S flush, discard, fork, overwrite). */ + auto?: boolean + /** Source handle opts into the "Enable auto-save" toggle. Only the + * full-page editors (script / flow / app / raw app) set it; their + * `auto` saves are suppressed while the toggle is off (latest opts + * still park in `pendingSaveOpts` for an explicit flush). Drawer + * editors (variables / resources / triggers) leave it unset and always + * sync. Explicit saves always go through. */ + canBeDisabled?: boolean +} + +/** + * Snapshot of a rejected save. `localLastSync` is what we sent (or `null` + * if never synced); `serverTimestamp` is the row's current `created_at`, + * surfaced so the resolution UI can show how recent the conflict was. + */ +export type DraftConflictInfo = { + serverTimestamp: string + localLastSync: string | null +} + +export type UserDraftLastSyncQuery = { + workspace: string + itemKind: UserDraftItemKind + path: string +} + +/** + * Autosave lifecycle for a single draft: + * - `saving`: a POST is in flight (coalescing runner busy). + * - `pending`: a change is queued in the debouncer, not yet fired. + * - `failed`: the last POST threw (network / 5xx) and no later attempt + * succeeded. Conflicts go through the modal, not here. + * - `none`: in sync (or nothing happened). + * Render priority `saving` > `pending` > `failed` > `none`: an active + * retry outranks the prior failure so the indicator shows it in-flight. + */ +export type UserDraftSyncState = 'none' | 'pending' | 'saving' | 'failed' + +export type UserDraftStateHandle = { + /** Reactive: re-runs as the draft moves through the pipeline. */ + readonly state: UserDraftSyncState + /** When `state === 'failed'`, the message from the thrown error. + * `undefined` otherwise. */ + readonly failureMessage: string | undefined + /** Reactive: bumped each time `flush()` completes, INCLUDING the no-op + * path. Lets the indicator flash "Saved" on Ctrl/Cmd+S even when the + * autosave already landed (otherwise the shortcut is silent then). */ + readonly flushCount: number +} + +/** + * Two-stage pipeline per draft key. The debouncer collapses keystroke + * bursts (1500ms reset, 10000ms ceiling so sustained typing still flushes + * within 10s). When it fires, `opts` goes to the coalescing runner, which + * keeps at most one POST in flight per key plus one "latest" follow-up — + * newer submissions replace any prior pending, so the server never sees + * stale-then-fresh out of order. + * + * Imperative awaits (delete-then-refetch) MUST NOT rely on `save()`'s + * promise: it resolves when the work is queued, not when the POST lands. + * Use the `immediate` bypass for those. + */ +const debouncer = createDebouncerByKey({ debounceMs: 1500, maxDebounceMs: 10000 }) +const runner = createCoalescingKeyedRunner() + +/** + * "Enable auto-save" preference (AutosaveIndicator popover toggle). + * Browser-wide and persisted. While off, `auto: true` saves and the + * unload keepalive flush are suppressed — nothing leaves the tab except + * explicit saves (latest opts still park in `pendingSaveOpts` for flush). + */ +const AUTOSAVE_ENABLED_LS_KEY = 'userDraftAutosaveEnabled' +function readAutosaveEnabled(): boolean { + try { + return localStorage.getItem(AUTOSAVE_ENABLED_LS_KEY) !== 'false' + } catch { + return true + } +} +let autosaveEnabledState = $state(readAutosaveEnabled()) + +/** + * Latest unconfirmed `save` opts per draft key. The unload flush fires a + * `keepalive` POST for each entry. Cleared by `postSave` on success, but + * only when the entry is still the same object the success was for — a + * newer `save()` during the in-flight POST must survive for the next round. + */ +const pendingSaveOpts = new Map() + +/** + * Conflict snapshots, populated when the server rejects a save (row + * `created_at` newer than our `last_sync`). Read via `getConflict(query)` + * to drive the resolution modal. + */ +const conflicts = new SvelteMap() + +/** + * Draft keys whose last save threw (network / 5xx) → extracted error + * message. Cleared on the next success. Drives the AutosaveIndicator's + * "Save failed" label so a silent failure can't masquerade as "Saved". + * Conflicts are tracked separately and don't populate this map. + */ +const failures = new SvelteMap() + +/** + * Reactive per-key counter bumped every time `flush()` completes — even + * when there was nothing to flush. See `UserDraftStateHandle.flushCount`. + */ +const flushes = new SvelteMap() + +/** + * Best-effort error → readable string. The generated client wraps HTTP + * failures as `ApiError` (`body` / `statusText`); raw fetch errors are a + * plain `Error`. Falls back to `String(e)` to avoid `[object Object]`. + */ +function formatSaveError(e: unknown): string { + if (e == null) return 'Unknown error' + if (typeof e === 'string') return e + const obj = e as Record + const body = obj.body + if (typeof body === 'string' && body) return body + if (body && typeof body === 'object') { + const inner = body.error?.message ?? body.message ?? body.error + if (typeof inner === 'string' && inner) return inner + } + if (typeof obj.message === 'string' && obj.message) return obj.message + if (typeof obj.statusText === 'string' && obj.statusText) return obj.statusText + return String(e) +} + +async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { + const key = draftKey(opts.workspace, opts.itemKind, opts.path) + const lastSync = getLastSyncEntry(key)?.lastSync + try { + const resp = await DraftService.updateDraft({ + workspace: opts.workspace, + kind: opts.itemKind, + path: opts.path, + requestBody: { + value: opts.value as any, + // Force-saves skip the conflict check; autosaves attach + // `last_sync` so the server can reject stale writes. Omitting + // `last_sync` (first-ever save) hits the backend's "treat as + // fresh" branch. + last_sync: opts.force ? undefined : lastSync, + force: opts.force ?? false + } + }) + if (resp.status === 'conflict') { + // Someone advanced the row past our `last_sync`. Park the + // snapshot for the UI; do NOT touch `lastSync` — the next save + // retries from the same baseline so the conflict persists until + // resolved. + conflicts.set(key, { + serverTimestamp: resp.current_timestamp, + localLastSync: lastSync ?? null + }) + return + } + // resp.status === 'saved' — advance lastSync (or drop on delete). + if (opts.value === null) { + clearLastSync(opts.workspace, opts.itemKind, opts.path) + } else { + setLastSync(opts.workspace, opts.itemKind, opts.path, resp.current_timestamp) + } + // postSave is the only place a draft's server-side existence changes, + // so it's the single source for the list pages' `*` hint + // (value !== null → exists). Every delete path clears the hint for + // free instead of maintaining a separate source of truth. + setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, opts.value !== null) + conflicts.delete(key) + failures.delete(key) + // Clear pending only if it's still the opts we just saved — a + // newer `save()` that arrived during the POST replaces the entry + // and must survive for the next flush / debouncer round. + if (pendingSaveOpts.get(key) === opts) pendingSaveOpts.delete(key) + } catch (e) { + console.error('UserDraftDbSyncer.save failed', e) + // Leave pending opts in place so the next attempt retries the same + // payload — we don't pretend the edit landed when it didn't. + failures.set(key, formatSaveError(e)) + } +} + +/** Keys whose `lastSync` was made stale by a `pagehide` keepalive flush + * (the POST advances `created_at` but its response is unreadable). + * Consumed by the `pageshow` handler: on a bfcache restore the SAME + * document comes back alive, and a save carrying the pre-flush + * `last_sync` would conflict against the user's own keepalive write. */ +const staleSyncAfterHideFlush = new Set() + +/** + * True-unload flush on `pagehide`. Uses `keepalive` so the request commits + * after the JS context is torn down (response discarded). Bypasses the + * debouncer/runner — both are async-scheduled and won't run post-hide. The + * keepalive body is capped (~64KB in Chrome); oversized payloads are + * rejected and lost (logged, still better than dropping every pending + * save). The POST advances `created_at` but we can't read it to update + * `lastSync` — fine here, the next mount reseeds via `recordRemoteSync`. + */ +function flushOnPageHide(): void { + if (pendingSaveOpts.size === 0) return + for (const [key, opts] of pendingSaveOpts) { + // Auto-save off: page-editor opts are dropped with the page; + // drawer-kind pendings (no `canBeDisabled`) still flush. + if (!autosaveEnabledState && opts.auto && opts.canBeDisabled) continue + debouncer.cancel(key) + try { + // `encodeURI` (not `encodeURIComponent`), mirroring the + // generated client, so slashes in the path pass through. + const url = + OpenAPI.BASE + + `/w/${encodeURI(opts.workspace)}` + + `/drafts/update/${encodeURI(opts.itemKind)}` + + `/${encodeURI(opts.path)}` + const lastSync = getLastSyncEntry(key)?.lastSync + void fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + value: opts.value, + last_sync: opts.force ? undefined : lastSync, + force: opts.force ?? false + }), + keepalive: true + }).catch((e) => { + console.error('UserDraftDbSyncer: keepalive flush failed', e) + }) + // POST advanced the row past `lastSync` and we can't read the + // response — mark the key so a bfcache restore drops it. + staleSyncAfterHideFlush.add(key) + } catch (e) { + console.error('UserDraftDbSyncer: keepalive flush threw', e) + } + } + pendingSaveOpts.clear() +} + +if (typeof document !== 'undefined') { + // Only `pagehide` means the document is truly going away. Do NOT use + // `visibilitychange → hidden`: it fires on every tab switch with the + // page surviving, where the debounced POST still fires normally — a + // flush there would spuriously conflict against the user's own write. + window.addEventListener('pagehide', flushOnPageHide) + // bfcache restore: drop the keepalive-stale entries so the next save + // omits `last_sync` (first-push). Safe — the server copy it overwrites + // is this document's own flush. Without this, that save is rejected as + // a conflict and the modal opens against the user's own write. + window.addEventListener('pageshow', (e: PageTransitionEvent) => { + if (!e.persisted) return + for (const key of staleSyncAfterHideFlush) { + lastSyncMap.delete(key) + } + staleSyncAfterHideFlush.clear() + }) +} + +/** + * Server-side persistence for `UserDraft`. Every write goes through the + * debouncer + coalescing runner above so autosave spam can't become one + * POST per keystroke or out-of-order writes under slow networks. + * + * Concurrency: every save attaches the per-tab `last_sync` (from + * `recordRemoteSync` or the prior success). The server rejects a stale + * `last_sync` and `postSave` surfaces a `DraftConflictInfo` for the modal. + */ +export const UserDraftDbSyncer = { + /** + * Reactive autosave-state handle for a draft. The key is captured at + * call time; if the consumer's `(workspace, itemKind, path)` can change, + * recompute the handle in a `$derived`. + */ + getState(query: UserDraftLastSyncQuery): UserDraftStateHandle { + const key = draftKey(query.workspace, query.itemKind, query.path) + return { + get state(): UserDraftSyncState { + if (runner.isRunning(key)) return 'saving' + if (debouncer.isPending(key)) return 'pending' + if (failures.has(key)) return 'failed' + return 'none' + }, + get failureMessage(): string | undefined { + // Suppress while saving/pending: an in-flight retry must not + // show the stale message from the failure it's retrying. + if (runner.isRunning(key) || debouncer.isPending(key)) return undefined + return failures.get(key) + }, + get flushCount(): number { + return flushes.get(key) ?? 0 + } + } + }, + + async save(opts: UserDraftDbSyncerSaveOpts): Promise { + const key = draftKey(opts.workspace, opts.itemKind, opts.path) + // Park the latest opts BEFORE the pipeline so the unload flush has + // something to send even if the page hides before the debouncer fires. + pendingSaveOpts.set(key, opts) + if (opts.immediate) { + // Drop the queued autosave — firing it after our POST would + // re-save the pre-delete value. + debouncer.cancel(key) + runner.cancel(key) + await runner.submitAndWait(key, () => postSave(opts)) + return + } + // Auto-save off: opts stay parked (above) for an explicit flush but + // never schedule a POST. Only for handles that opted into the toggle. + if (opts.auto && opts.canBeDisabled && !autosaveEnabledState) return + // Optimistically light the list-page `*` so it tracks the editor's + // unsaved-changes banner without waiting for the debounced POST; + // postSave reconciles to the confirmed server state. + if (opts.value !== null) { + setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, true) + } + debouncer.schedule(key, () => { + runner.submit(key, () => postSave(opts)) + }) + }, + + /** "Enable auto-save" preference — see the module-level doc. Turning it + * back ON re-schedules every parked draft so edits made while off catch + * up without waiting for the next keystroke. */ + get autosaveEnabled(): boolean { + return autosaveEnabledState + }, + set autosaveEnabled(enabled: boolean) { + autosaveEnabledState = enabled + try { + localStorage.setItem(AUTOSAVE_ENABLED_LS_KEY, String(enabled)) + } catch {} + if (enabled) { + for (const [key, opts] of pendingSaveOpts) { + debouncer.schedule(key, () => { + runner.submit(key, () => postSave(opts)) + }) + } + } + }, + + /** + * Seed the per-tab `last_sync` after an editor reads a draft from the + * server. Pass the response's `draft_saved_at` so the next save sends a + * matching `last_sync`; pass `undefined` when no draft existed (next + * save omits `last_sync`, the backend's first-push branch). + */ + recordRemoteSync(query: UserDraftLastSyncQuery, draftSavedAt: string | undefined): void { + const key = draftKey(query.workspace, query.itemKind, query.path) + if (draftSavedAt) { + setLastSync(query.workspace, query.itemKind, query.path, draftSavedAt) + } else { + clearLastSync(query.workspace, query.itemKind, query.path) + } + // Back in sync with the server: clear any conflict / failure. + conflicts.delete(key) + failures.delete(key) + }, + + /** Reactive conflict snapshot (if any) for a draft. */ + getConflict(query: UserDraftLastSyncQuery): { + readonly conflict: DraftConflictInfo | undefined + } { + const key = draftKey(query.workspace, query.itemKind, query.path) + return { + get conflict() { + return conflicts.get(key) + } + } + }, + + /** + * Clear the conflict snapshot. `recordRemoteSync` does this implicitly + * on a fresh read, so the only standalone use is "dismiss without + * resolving". + */ + clearConflict(query: UserDraftLastSyncQuery): void { + conflicts.delete(draftKey(query.workspace, query.itemKind, query.path)) + }, + + /** + * Force-save: bypass the `last_sync` check and overwrite the server row + * (conflict modal's "Overwrite the remote"). Resolves only after the + * POST lands so the caller can `await` before navigating / refetching. + */ + async overwrite(opts: Omit): Promise { + await this.save({ ...opts, immediate: true, force: true }) + }, + + /** + * Flush the draft's queued autosave NOW (explicit Ctrl/Cmd+S). Re-submits + * the parked opts with `immediate: true` and resolves only after the POST + * lands, so callers can `await flush(...); show "Saved"`. + * + * No-op when nothing is pending. "No pending" does NOT mean "nothing to + * save" — Monaco may hold unmaterialized text; flush the editor + * (`Editor.flushPendingChanges()`) and await `tick()` first so its + * bind:code reaches our save() before this. + */ + async flush(query: UserDraftLastSyncQuery): Promise { + const key = draftKey(query.workspace, query.itemKind, query.path) + try { + const opts = pendingSaveOpts.get(key) + if (!opts) return + await this.save({ ...opts, immediate: true }) + } finally { + // Signal the indicator even on the no-op path so Ctrl/Cmd+S + // shows "Saved" even when the autosave already landed. + flushes.set(key, (flushes.get(key) ?? 0) + 1) + } + } +} diff --git a/frontend/src/lib/userDraftToast.ts b/frontend/src/lib/userDraftToast.ts index 7e3e48d211..dbb16b9c3d 100644 --- a/frontend/src/lib/userDraftToast.ts +++ b/frontend/src/lib/userDraftToast.ts @@ -1,31 +1,91 @@ -/** - * "Restored from local storage" toast, shown when an editor reopens on a - * local autosave that differs from the backend. Owns only the wording and - * which reset actions are offered; the reset side-effects live at each call - * site (route-specific state). - */ +/** Toast helpers for when a per-user draft is loaded from the server. */ import { sendUserToast } from '$lib/toast' +import { UserDraft } from '$lib/userDraft.svelte' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import type { UserDraftItemKind } from '$lib/gen' -export type RestoreFromLocalActions = { - /** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */ - onResetToSavedDraft?: () => void | Promise - /** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */ - onResetToDeployed?: () => void | Promise +/** + * Run a "reset to deployed" with autosave suspended: the reset's own + * wipe-and-reload writes would otherwise re-create the draft just deleted. + * Sync re-arms on the user's next interaction, not via `tick()` — editors + * keep cascading writes after remount (Monaco acks, schema re-infer, iframe + * acks) that would resurrect the draft. Shared by the load-time toast and + * the AutosaveIndicator popover. + */ +export async function runResetToDeployed(opts: { + workspace: string + itemKind: UserDraftItemKind + path: string + onResetToDeployed: () => void | Promise +}): Promise { + UserDraft.stopSync(opts.itemKind, opts.path, { workspace: opts.workspace }) + UserDraftDbSyncer.save({ + workspace: opts.workspace, + itemKind: opts.itemKind, + path: opts.path, + value: null + }).catch((e) => console.error('Reset to deployed: draft delete failed', e)) + try { + await opts.onResetToDeployed() + } catch (e: any) { + sendUserToast(`Could not reset to deployed: ${e?.body ?? e}`, true) + } finally { + armRestartOnFirstInteraction(opts.workspace, opts.itemKind, opts.path) + } } -/** Show the toast with up to two reset actions, gated by what the backend has. */ -export function notifyRestoredFromLocal( - hasBackendDraft: boolean, - hasDeployed: boolean, - { onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions +/** + * Post-deploy draft cleanup. Deletes the draft at its CANONICAL slot key + * (the path the handle is keyed on, NOT the just-typed deploy path, which + * differs for draft-only items) with sync muted and the delete flushed. + * Mute + flush both matter: `remove` only QUEUES the delete in the + * debouncer, and editors still mounted through the navigation (AppEditor, + * RawAppEditor) keep mirroring their value — one such write displaces the + * queued delete and re-saves the draft. Sync re-arms on next interaction. + */ +export function discardDraftAfterDeploy(opts: { + workspace: string + itemKind: UserDraftItemKind + path: string +}): void { + UserDraft.stopSync(opts.itemKind, opts.path, { workspace: opts.workspace }) + UserDraft.remove(opts.itemKind, opts.path, { workspace: opts.workspace }) + void UserDraftDbSyncer.flush({ + workspace: opts.workspace, + itemKind: opts.itemKind, + path: opts.path + }) + armRestartOnFirstInteraction(opts.workspace, opts.itemKind, opts.path) +} + +/** + * Defer `UserDraft.restartSync` to the first user interaction, with a 5s + * fallback so the suspension can't leak when the user navigates away + * untouched. Brackets bootstrap mutations that would otherwise POST as the + * "first edit" (template fills, conflict reloads, reset-to-deployed); pair + * with `UserDraft.stopSync` at the bootstrap start. + */ +export function armRestartOnFirstInteraction( + workspace: string, + itemKind: UserDraftItemKind, + path: string ): void { - const actions: Array<{ label: string; callback: () => void | Promise }> = [] - if (hasBackendDraft && onResetToSavedDraft) { - actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft }) + let restarted = false + const restart = () => { + if (restarted) return + restarted = true + document.removeEventListener('keydown', restart, true) + document.removeEventListener('input', restart, true) + document.removeEventListener('pointerdown', restart, true) + clearTimeout(fallback) + UserDraft.restartSync(itemKind, path, { workspace }) } - if (hasDeployed && onResetToDeployed) { - actions.push({ label: 'Reset to deployed', callback: onResetToDeployed }) - } - if (actions.length === 0) return - sendUserToast('Restored from local storage', false, actions) + // Three events: keydown misses pointer-driven UI (sliders, pickers, the + // path popover OK); pointerdown misses pure-keyboard Monaco edits. + document.addEventListener('keydown', restart, { capture: true, once: true }) + document.addEventListener('input', restart, { capture: true, once: true }) + document.addEventListener('pointerdown', restart, { capture: true, once: true }) + // Fallback if the user navigates away before interacting, else the + // suspension leaks and autosave stays off next visit. + const fallback = setTimeout(restart, 5000) } diff --git a/frontend/src/lib/userNamespace.ts b/frontend/src/lib/userNamespace.ts new file mode 100644 index 0000000000..4839e46f6e --- /dev/null +++ b/frontend/src/lib/userNamespace.ts @@ -0,0 +1,31 @@ +import { get } from 'svelte/store' +import { userStore } from '$lib/stores' + +/** + * Workspace username for the authed user, suitable as the `u/{X}/...` + * namespace in editor draft paths. + * + * The `/add` → `/edit/u/{username}/draft_{uuid}` redirects in each + * editor's `+page.ts` run during SvelteKit's load phase — BEFORE the + * (logged) layout's async `getUserExt` populates `userStore`. Reading + * `get(userStore)?.username` from there returns `undefined` on every + * fresh nav, falling back to the `'me'` placeholder and producing + * `u/me/draft_{uuid}` paths instead of the real namespace. + * + * The layout writes `username` to `localStorage` on every successful + * `getUserExt`, so this helper: + * 1. Reads `userStore` first (live value when it IS populated, e.g. + * same-tab re-navs after the first load). + * 2. Falls back to `localStorage` (warm-cache from the previous + * session — covers the load-phase race on cold reload). + * 3. Falls back to `'me'` (true first-ever load with no session yet). + */ +export function getUsernameForNamespace(): string { + const live = get(userStore)?.username + if (live) return live + try { + const cached = localStorage.getItem('username') + if (cached) return cached + } catch {} + return 'me' +} diff --git a/frontend/src/lib/userScopedDb.test.ts b/frontend/src/lib/userScopedDb.test.ts new file mode 100644 index 0000000000..f97ec7cc09 --- /dev/null +++ b/frontend/src/lib/userScopedDb.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { IDBFactory } from 'fake-indexeddb' +import { openDB, type DBSchema, type IDBPDatabase } from 'idb' + +// scopedKey resolves the email from userStore via a BROWSER-gated subscription. +vi.mock('esm-env', async (importOriginal) => ({ + ...(await importOriginal()), + BROWSER: true +})) + +import { userStore, type UserExt } from '$lib/stores' +import { userScopedDb, type UserScopedDbMigrateDeps } from './userScopedDb' + +interface TestSchema extends DBSchema { + items: { key: string; value: { id: string; v: number } } +} + +function upgrade(db: IDBPDatabase) { + if (!db.objectStoreNames.contains('items')) { + db.createObjectStore('items', { keyPath: 'id' }) + } +} + +function asUser(email: string): UserExt { + return { email, username: email.split('@')[0] } as unknown as UserExt +} + +beforeEach(() => { + // Fresh in-memory IndexedDB per test for isolation. + ;(globalThis as any).indexedDB = new IDBFactory() + userStore.set(undefined) +}) + +describe('userScopedDb', () => { + it('returns undefined while no user is logged in', async () => { + const dbh = userScopedDb('t', { version: 1, upgrade }) + expect(await dbh.whenReady()).toBeUndefined() + }) + + it('isolates data between users and restores it on return', async () => { + const dbh = userScopedDb('t', { version: 1, upgrade }) + + userStore.set(asUser('a@x.com')) + const dbA = await dbh.whenReady() + await dbA!.put('items', { id: 'i1', v: 1 }) + + // Switch user: whenReady reopens the other user's (empty) DB — A's record + // is not visible. + userStore.set(asUser('b@y.com')) + const dbB = await dbh.whenReady() + expect(await dbB!.count('items')).toBe(0) + await dbB!.put('items', { id: 'i2', v: 2 }) + + // Back to A: their record is intact, B's is not present. + userStore.set(asUser('a@x.com')) + const dbA2 = await dbh.whenReady() + expect((await dbA2!.getAll('items')).map((x) => x.id)).toEqual(['i1']) + }) + + it('runs migrate once per scoped name and claims+deletes the legacy DB', async () => { + // Seed a legacy (un-namespaced) DB, mirroring the chat-history pattern. + const legacy = await openDB('t', 1, { upgrade }) + await legacy.put('items', { id: 'legacy1', v: 9 }) + legacy.close() + + const migrate = vi.fn(async (db: IDBPDatabase, deps: UserScopedDbMigrateDeps) => { + if ((await db.count('items')) > 0) return + const src = await deps.openDB('t', 1, { upgrade }) + const all = await src.getAll('items') + const tx = db.transaction('items', 'readwrite') + await Promise.all([...all.map((x) => tx.store.put(x)), tx.done]) + src.close() + await deps.deleteDB('t') + }) + + const dbh = userScopedDb('t', { version: 1, upgrade, migrate }) + userStore.set(asUser('a@x.com')) + + const db = await dbh.whenReady() + expect((await db!.getAll('items')).map((x) => x.id)).toEqual(['legacy1']) + // Legacy bare DB was deleted. + const names = (await indexedDB.databases()).map((d) => d.name) + expect(names).not.toContain('t') + expect(names).toContain('t::a@x.com') + + // migrate is gated to once per scoped name even across repeated whenReady. + await dbh.whenReady() + expect(migrate).toHaveBeenCalledTimes(1) + }) + + it('degrades to undefined (no throw) when the DB cannot be opened', async () => { + const failingOpen = vi.fn(async () => { + throw new Error('blocked') + }) as unknown as typeof openDB + const dbh = userScopedDb('t', { version: 1, upgrade, openDB: failingOpen }) + userStore.set(asUser('a@x.com')) + expect(await dbh.whenReady()).toBeUndefined() + }) + + it('clears the handle on logout', async () => { + const dbh = userScopedDb('t', { version: 1, upgrade }) + userStore.set(asUser('a@x.com')) + expect(await dbh.whenReady()).toBeDefined() + userStore.set(undefined) + expect(await dbh.whenReady()).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/userScopedDb.ts b/frontend/src/lib/userScopedDb.ts new file mode 100644 index 0000000000..4b7242f35a --- /dev/null +++ b/frontend/src/lib/userScopedDb.ts @@ -0,0 +1,103 @@ +import { openDB as idbOpenDB, deleteDB as idbDeleteDB, type DBSchema, type IDBPDatabase } from 'idb' +import { scopedKey } from '$lib/userScopedStorage' + +// Per-user IndexedDB lifecycle, shared by the session list and the copilot +// chat-history stores. The effective DB name is the base name namespaced by the +// logged-in user's email (scopedKey), so two users on a shared browser never +// touch the same physical database. +// +// `whenReady()` is name-aware: it computes the current scoped name on every call +// and transparently closes + reopens when the email changes, so the handle +// self-heals on user switch WITHOUT subscribing to onUserChange. That matters +// because there is one handle per HistoryManager instance (singleton + one per +// session runtime) — a per-instance subscription would leak callbacks. + +export interface UserScopedDbMigrateDeps { + openDB: typeof idbOpenDB + deleteDB: typeof idbDeleteDB +} + +export interface UserScopedDbOptions { + version: number + upgrade: (db: IDBPDatabase) => void + // Invoked once per scoped name right after a successful open. The fn owns its + // own "already migrated / not applicable" gate (e.g. checking a store's + // count) — claim-then-delete legacy data lives here. + migrate?: (db: IDBPDatabase, deps: UserScopedDbMigrateDeps) => Promise + // Injectable for tests (defaults to the real idb implementations). + openDB?: typeof idbOpenDB + deleteDB?: typeof idbDeleteDB +} + +export interface UserScopedDb { + // Resolves to the open DB for the current user, or undefined when no user is + // logged in yet or the open failed (degrade to in-memory; never rejects). + whenReady(): Promise | undefined> + close(): void +} + +export function userScopedDb( + baseName: string, + opts: UserScopedDbOptions +): UserScopedDb { + const openDB = opts.openDB ?? idbOpenDB + const deleteDB = opts.deleteDB ?? idbDeleteDB + const migratedNames = new Set() + + let openName: string | undefined + let openPromise: Promise | undefined> | undefined + + function closeCurrent() { + const prev = openPromise + if (prev) void prev.then((db) => db?.close()).catch(() => {}) + openPromise = undefined + openName = undefined + } + + async function open(name: string): Promise | undefined> { + try { + const db = await openDB(name, opts.version, { + upgrade(database) { + opts.upgrade(database) + } + }) + if (opts.migrate && !migratedNames.has(name)) { + migratedNames.add(name) + try { + await opts.migrate(db, { openDB, deleteDB }) + } catch (e) { + // A failed migration is non-fatal: the (open) DB is still usable, so + // we log and return it — unlike a failed open below, which yields + // undefined. Worst case the legacy claim is missed, not the store. + console.error(`userScopedDb(${baseName}): migration failed`, e) + } + } + return db + } catch (e) { + // Failed open (blocked / corrupt / private-browsing): degrade to + // in-memory by resolving undefined (callers no-op their writes). The + // undefined is cached for this name so we don't hammer the open. + console.error(`userScopedDb(${baseName}): could not open database`, e) + return undefined + } + } + + return { + whenReady() { + const name = scopedKey(baseName) + if (!name) { + closeCurrent() + return Promise.resolve(undefined) + } + if (name !== openName) { + closeCurrent() + openName = name + openPromise = open(name) + } + return openPromise! + }, + close() { + closeCurrent() + } + } +} diff --git a/frontend/src/lib/userScopedStorage.test.ts b/frontend/src/lib/userScopedStorage.test.ts new file mode 100644 index 0000000000..bc25734171 --- /dev/null +++ b/frontend/src/lib/userScopedStorage.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The userStore subscription is gated on BROWSER; the vitest "server" env +// reports BROWSER=false. +vi.mock('esm-env', async (importOriginal) => ({ + ...(await importOriginal()), + BROWSER: true +})) + +import { userStore, type UserExt } from '$lib/stores' +import { + scopedKey, + getCurrentUserEmail, + onUserChange, + migrateLegacyLocalStorage +} from './userScopedStorage' + +function asUser(email: string): UserExt { + return { email, username: email.split('@')[0] } as unknown as UserExt +} + +beforeEach(() => { + localStorage.clear() + userStore.set(undefined) +}) + +describe('scopedKey / getCurrentUserEmail', () => { + it('returns undefined while no user is logged in', () => { + expect(getCurrentUserEmail()).toBeUndefined() + expect(scopedKey('windmill_sessions')).toBeUndefined() + }) + + it('namespaces by email and isolates distinct users', () => { + userStore.set(asUser('a@x.com')) + expect(scopedKey('windmill_sessions')).toBe('windmill_sessions::a@x.com') + userStore.set(asUser('b@y.com')) + expect(scopedKey('windmill_sessions')).toBe('windmill_sessions::b@y.com') + }) +}) + +describe('onUserChange', () => { + it('fires immediately with the current email, then on every change', () => { + userStore.set(asUser('a@x.com')) + const calls: Array<[string | undefined, string | undefined]> = [] + onUserChange((email, prev) => calls.push([email, prev])) + // Immediate fire with current email, prev undefined. + expect(calls).toEqual([['a@x.com', undefined]]) + userStore.set(asUser('b@y.com')) + expect(calls).toEqual([ + ['a@x.com', undefined], + ['b@y.com', 'a@x.com'] + ]) + // No-op when the email is unchanged. + userStore.set(asUser('b@y.com')) + expect(calls).toHaveLength(2) + }) +}) + +describe('migrateLegacyLocalStorage', () => { + it('claims a legacy key into the target and deletes the legacy copy', () => { + localStorage.setItem('ai-chat-autonomy-mode', 'yolo') + migrateLegacyLocalStorage('ai-chat-autonomy-mode', 'ai-chat-autonomy-mode::a@x.com') + expect(localStorage.getItem('ai-chat-autonomy-mode::a@x.com')).toBe('yolo') + expect(localStorage.getItem('ai-chat-autonomy-mode')).toBeNull() + }) + + it('does not overwrite an existing target', () => { + localStorage.setItem('ai-chat-autonomy-mode', 'yolo') + localStorage.setItem('ai-chat-autonomy-mode::a@x.com', 'acceptedit') + migrateLegacyLocalStorage('ai-chat-autonomy-mode', 'ai-chat-autonomy-mode::a@x.com') + expect(localStorage.getItem('ai-chat-autonomy-mode::a@x.com')).toBe('acceptedit') + // Legacy left untouched since the target was already populated. + expect(localStorage.getItem('ai-chat-autonomy-mode')).toBe('yolo') + }) + + it('is a no-op when the target key is undefined (no user)', () => { + localStorage.setItem('ai-chat-autonomy-mode', 'yolo') + migrateLegacyLocalStorage('ai-chat-autonomy-mode', undefined) + expect(localStorage.getItem('ai-chat-autonomy-mode')).toBe('yolo') + }) +}) diff --git a/frontend/src/lib/userScopedStorage.ts b/frontend/src/lib/userScopedStorage.ts new file mode 100644 index 0000000000..905d609b61 --- /dev/null +++ b/frontend/src/lib/userScopedStorage.ts @@ -0,0 +1,85 @@ +import { BROWSER } from 'esm-env' +import { userStore } from '$lib/stores' +import { getLocalSetting, storeLocalSetting } from '$lib/utils' + +// Browser-persisted AI-session state (the `/sessions` list, unread markers, +// chat-history IndexedDB, autonomy mode) must be scoped to the logged-in +// instance user so a shared browser never leaks one user's data to the next. +// +// The scoping identity is the instance user **email** — the only identity +// stable across a fork family (a single session deliberately spans multiple +// workspace_ids; `username` varies per workspace, `email` does not). +// +// The email arrives asynchronously (userStore is populated after the layout's +// getUserExt), while several consumers initialise eagerly at module import. +// This module owns the single subscription and lets each surface register a +// hydrate+migrate callback that fires once on registration (covering late +// registrants whose email already resolved) and again on every email change. + +type UserChangeCallback = (email: string | undefined, prevEmail: string | undefined) => void + +let currentEmail: string | undefined = undefined +const callbacks = new Set() + +if (BROWSER) { + // userStore.subscribe fires synchronously with the current value (undefined + // at import time) and again whenever the user resolves / changes. Logout is + // a full page reload, and email is constant across workspace switches, so in + // practice the only transition is the initial undefined → email hydration. + userStore.subscribe((u) => { + const next = u?.email + if (next === currentEmail) return + const prev = currentEmail + currentEmail = next + for (const cb of callbacks) { + try { + cb(next, prev) + } catch (e) { + console.error('userScopedStorage: onUserChange callback failed', e) + } + } + }) +} + +// Current logged-in user's email, or undefined before it resolves / when +// logged out. +export function getCurrentUserEmail(): string | undefined { + return currentEmail +} + +// Namespace a base storage key (localStorage key or IndexedDB name) by the +// current user's email. Returns undefined when no user is known — callers must +// treat that as "do not read/write" so we never touch a browser-global key. +export function scopedKey(base: string): string | undefined { + if (!currentEmail) return undefined + return `${base}::${currentEmail}` +} + +// Register a callback invoked whenever the scoping email changes. Fired once +// immediately with the current email (prevEmail undefined) so a surface that +// registers after the email already resolved still hydrates. +export function onUserChange(cb: UserChangeCallback): void { + callbacks.add(cb) + try { + cb(currentEmail, undefined) + } catch (e) { + console.error('userScopedStorage: initial onUserChange callback failed', e) + } +} + +// One-shot claim of pre-namespacing data: if the scoped key is empty and a +// legacy un-namespaced key exists, move it under the scoped key and delete the +// legacy copy. The first user to log in on a previously single-user browser +// keeps their data; subsequent users start clean. +export function migrateLegacyLocalStorage(legacyKey: string, targetKey: string | undefined): void { + if (!BROWSER || !targetKey) return + try { + if (getLocalSetting(targetKey) != null) return + const legacy = getLocalSetting(legacyKey) + if (legacy == null) return + storeLocalSetting(targetKey, legacy) + storeLocalSetting(legacyKey, undefined) + } catch (e) { + console.error('userScopedStorage: legacy localStorage migration failed', e) + } +} diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index d5e1ba0c89..124f7d8078 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { getQueryStmtCountHeuristic } from './utils' +import { cleanValueProperties, getQueryStmtCountHeuristic } from './utils' describe('getQueryStmtCountHeuristic', () => { describe('basic statements', () => { @@ -283,3 +283,73 @@ DELETE FROM logs WHERE timestamp < NOW() - INTERVAL '30 days' }) }) }) + +describe('cleanValueProperties', () => { + const serverManagedKeys = [ + 'parent_hash', + 'draft', + 'draft_only', + 'draft_saved_at', + 'draft_created_at', + 'is_draft', + 'other_drafts_users', + 'created_at', + 'created_by', + 'workspace_id', + 'parent_hashes', + 'lock_error_logs' + ] + + it('strips every server-managed bookkeeping key', () => { + const input: any = { summary: 'hi' } + for (const key of serverManagedKeys) { + input[key] = 'noise' + } + const cleaned = cleanValueProperties(input) as any + for (const key of serverManagedKeys) { + expect(cleaned).not.toHaveProperty(key) + } + }) + + it('preserves user-editable keys', () => { + const input: any = { + summary: 'my script', + description: 'does things', + content: 'export function main() {}', + schema: { properties: { x: { type: 'string' } } }, + language: 'bun', + created_at: '2024-01-01' + } + const cleaned = cleanValueProperties(input) as any + expect(cleaned.summary).toBe('my script') + expect(cleaned.description).toBe('does things') + expect(cleaned.content).toBe('export function main() {}') + expect(cleaned.schema).toEqual({ properties: { x: { type: 'string' } } }) + expect(cleaned.language).toBe('bun') + expect(cleaned).not.toHaveProperty('created_at') + }) + + it('preserves lock so version-to-version diffs still surface lockfile changes', () => { + const cleaned = cleanValueProperties({ summary: 'hi', lock: 'resolved deps' } as any) as any + expect(cleaned.lock).toBe('resolved deps') + }) + + it('preserves extra_perms so folder workspace/fork diffs still surface permission changes', () => { + const cleaned = cleanValueProperties({ + summary: 'hi', + extra_perms: { 'u/foo': true } + } as any) as any + expect(cleaned.extra_perms).toEqual({ 'u/foo': true }) + }) + + it('returns non-object values unchanged', () => { + expect(cleanValueProperties('hello' as any)).toBe('hello') + expect(cleanValueProperties(42 as any)).toBe(42) + }) + + it('does not mutate the input object', () => { + const input: any = { summary: 'hi', created_at: '2024-01-01' } + cleanValueProperties(input) + expect(input).toHaveProperty('created_at') + }) +}) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 2e6302fb6c..ba8f4219fb 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1296,13 +1296,39 @@ function replaceFalseWithUndefinedRec(obj: any) { return obj } +// Keys that are server-managed bookkeeping, never user-editable, and therefore +// must not surface in any value diff or unsaved-change comparison. `getScriptByPath` +// (and the flow/app equivalents) return the full DB row, so the editing object +// carries these while the deployed side is fetched trimmed — leaving them in would +// render as spurious metadata diff. +// +// `lock` and `extra_perms` are deliberately NOT in this set: both are legitimate, +// user-meaningful fields in some diff contexts (lockfile changes in version-to-version +// diffs, folder sharing-permission changes in workspace/fork diffs). The script-editor +// noise they would otherwise cause is stripped at the source instead (the deployed side +// in `ScriptBuilder.syncWithDeployed`, the current side in `ScriptBuilder.openDiffDrawer`). +const CLEANED_VALUE_KEYS = new Set([ + 'parent_hash', + 'draft', + 'draft_only', + 'draft_saved_at', + 'draft_created_at', + 'is_draft', + 'other_drafts_users', + 'created_at', + 'created_by', + 'workspace_id', + 'parent_hashes', + 'lock_error_logs' +]) + export function cleanValueProperties(obj: Value) { if (typeof obj !== 'object') { return obj } else { let newObj: any = {} for (const key of Object.keys(obj)) { - if (key !== 'parent_hash' && key !== 'draft' && key !== 'draft_only') { + if (!CLEANED_VALUE_KEYS.has(key)) { newObj[key] = structuredClone(stateSnapshot(obj[key])) } } diff --git a/frontend/src/lib/utils/editInFork.ts b/frontend/src/lib/utils/editInFork.ts index 2a0768694d..aa6d653961 100644 --- a/frontend/src/lib/utils/editInFork.ts +++ b/frontend/src/lib/utils/editInFork.ts @@ -9,13 +9,13 @@ export function buildForkEditUrl(itemType: ItemType, itemPath: string): string { editPath = `${base}/scripts/edit/${itemPath}` break case 'flow': - editPath = `${base}/flows/edit/${itemPath}?nodraft=true` + editPath = `${base}/flows/edit/${itemPath}` break case 'app': - editPath = `${base}/apps/edit/${itemPath}?nodraft=true` + editPath = `${base}/apps/edit/${itemPath}` break case 'raw_app': - editPath = `${base}/apps_raw/edit/${itemPath}?nodraft=true` + editPath = `${base}/apps_raw/edit/${itemPath}` break } return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPath)}` diff --git a/frontend/src/lib/utils/uuid.ts b/frontend/src/lib/utils/uuid.ts new file mode 100644 index 0000000000..4a9f092dac --- /dev/null +++ b/frontend/src/lib/utils/uuid.ts @@ -0,0 +1,7 @@ +export function randomUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts new file mode 100644 index 0000000000..a4da771bb7 --- /dev/null +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -0,0 +1,500 @@ +/** + * Draft deploy/discard orchestration for the compare page's "draft" mode. + * + * Drafts only exist for scripts, flows and apps (the `draft_type` enum). A draft + * is the editor's serialized state stored in the `draft` table; deploying it is + * the same create/update call the editor makes on "Deploy", which auto-deletes + * the matching draft server-side (unless `skip_draft_deletion`) — so we never + * call `deleteDraft` after a successful deploy. The lock/dependency job runs + * async, exactly as in the editor. + * + * Discarding branches on `draft_only`: a `draft_only` item exists only as a + * draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`); + * a draft on an already-deployed item just deletes the draft row. + */ +import { get, writable } from 'svelte/store' +import { + DraftService, + ScriptService, + FlowService, + AppService, + VariableService, + ResourceService, + ScheduleService, + HttpTriggerService, + WebsocketTriggerService, + PostgresTriggerService, + KafkaTriggerService, + NatsTriggerService, + MqttTriggerService, + SqsTriggerService, + GcpTriggerService, + AzureTriggerService, + EmailTriggerService, + type UserDraftItemKind +} from '$lib/gen' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import type { DeployResult } from '$lib/utils_workspace_deploy' +import { deployRawAppDraft } from '$lib/rawAppDeploy' +import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' +import { setLocalDraftHint } from '$lib/localDraftHints.svelte' +import { userStore } from '$lib/stores' +import { deployTriggers, type Trigger } from '$lib/components/triggers/utils' +import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' +import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils' +import { saveWebsocketTriggerFromCfg } from '$lib/components/triggers/websocket/utils' +import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils' +import { saveKafkaTriggerFromCfg } from '$lib/components/triggers/kafka/utils' +import { saveNatsTriggerFromCfg } from '$lib/components/triggers/nats/utils' +import { saveMqttTriggerFromCfg } from '$lib/components/triggers/mqtt/utils' +import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils' +import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils' +import { saveAzureTriggerFromCfg } from '$lib/components/triggers/azure/utils' +import { saveEmailTriggerFromCfg } from '$lib/components/triggers/email/utils' + +export type DraftKind = UserDraftItemKind + +/** Kind → "get by path with draft overlay" call, for kinds whose draft is the + * editor's flat config (all but script/flow/app, handled below). Feature-gated + * trigger services 404 when the backend lacks the kind; the caller surfaces it. */ +const OVERLAY_GETTERS: Partial< + Record Promise> +> = { + variable: (workspace, path) => + VariableService.getVariable({ workspace, path, decryptSecret: false, getDraft: true }), + resource: (workspace, path) => ResourceService.getResource({ workspace, path, getDraft: true }), + trigger_schedule: (workspace, path) => + ScheduleService.getSchedule({ workspace, path, getDraft: true }), + trigger_http: (workspace, path) => + HttpTriggerService.getHttpTrigger({ workspace, path, getDraft: true }), + trigger_websocket: (workspace, path) => + WebsocketTriggerService.getWebsocketTrigger({ workspace, path, getDraft: true }), + trigger_postgres: (workspace, path) => + PostgresTriggerService.getPostgresTrigger({ workspace, path, getDraft: true }), + trigger_kafka: (workspace, path) => + KafkaTriggerService.getKafkaTrigger({ workspace, path, getDraft: true }), + trigger_nats: (workspace, path) => + NatsTriggerService.getNatsTrigger({ workspace, path, getDraft: true }), + trigger_mqtt: (workspace, path) => + MqttTriggerService.getMqttTrigger({ workspace, path, getDraft: true }), + trigger_sqs: (workspace, path) => + SqsTriggerService.getSqsTrigger({ workspace, path, getDraft: true }), + trigger_gcp: (workspace, path) => + GcpTriggerService.getGcpTrigger({ workspace, path, getDraft: true }), + trigger_azure: (workspace, path) => + AzureTriggerService.getAzureTrigger({ workspace, path, getDraft: true }), + trigger_email: (workspace, path) => + EmailTriggerService.getEmailTrigger({ workspace, path, getDraft: true }) +} + +/** Strip the per-user draft-overlay metadata, returning `{deployed, draft}`. */ +function splitOverlay(r: any): { deployed: any; draft: any } { + const { + draft, + is_draft: _i, + draft_saved_at: _c, + no_deployed: _n, + other_drafts_users: _o, + ...deployed + } = r + return { deployed, draft: draft ?? deployed } +} + +export interface DraftDiffValues { + deployed: unknown + draft: unknown +} + +// Empty-but-valid "deployed" shapes for draft_only items. A bare `{}` breaks +// the flow graph diff (needs `value.modules`) and hangs the drawer, so each +// listed kind gets a minimal shape; unlisted kinds fall back to `{}`. +const EMPTY_DEPLOYED: Partial unknown>> = { + script: (draft) => ({ content: '', language: draft?.language, schema: {} }), + flow: () => ({ summary: '', value: { modules: [] }, schema: {} }), + app: () => ({ summary: '', value: {}, policy: {} }) +} + +/** + * Fetch the deployed value and the draft value for an item, for the DiffDrawer + * (`mode: 'simple'`, original = deployed, current = draft). For a `draft_only` + * item there is no real deployed value, so the deployed side is a minimal + * empty-but-valid shape and the draft shows as entirely new. DiffDrawer cleans + * both sides via `cleanValueProperties`, so raw objects are fine here. + */ +export async function getDraftDiffValues( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false +): Promise { + // A `draft_only` item can keep its content in the row itself with no separate + // draft-table row (e.g. a flow created via createFlow(draft_only: true), like + // `u/admin/new`). There `draft` is null, so the draft side must fall back to + // the row's own value — otherwise the diff "after" is empty and nothing shows. + // Strip overlay metadata (is_draft / draft_saved_at / no_deployed / + // other_drafts_users) from the deployed side so the diff doesn't show the + // per-user markers as noise. + if (kind === 'script') { + const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any + const { + draft, + is_draft: _i, + draft_saved_at: _c, + no_deployed: _n, + other_drafts_users: _o, + hash: _h, + ...deployed + } = r + const draftValue = draft ?? deployed + return { + deployed: draftOnly ? EMPTY_DEPLOYED.script!(draftValue) : deployed, + draft: draftValue + } + } else if (kind === 'flow') { + const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any + const { + draft, + is_draft: _i, + draft_saved_at: _c, + no_deployed: _n, + other_drafts_users: _o, + ...deployed + } = r + const draftValue = draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.flow!(draftValue) : deployed, draft: draftValue } + } else if (kind === 'app' || kind === 'raw_app') { + // A never-deployed raw app has no `app` row; the backend resolves the + // draft kind from `rawApp`, so it MUST be set or the lookup 404s. + const r = (await AppService.getAppByPath({ + workspace, + path, + getDraft: true, + rawApp: kind === 'raw_app' + })) as any + const deployed = { + summary: r.summary, + value: r.value, + policy: r.policy, + path: r.path, + custom_path: r.custom_path + } + const draftValue = r.draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.app!(draftValue) : deployed, draft: draftValue } + } else { + // Variables / resources / schedules / triggers: the draft is the + // editor's flat config object and the deployed shape diffs cleanly + // as a plain object — one overlay GET covers both sides. + const getter = OVERLAY_GETTERS[kind] + if (!getter) { + throw new Error(`Draft diff not supported for kind ${kind}`) + } + const { deployed, draft } = splitOverlay(await getter(workspace, path)) + return { deployed: draftOnly ? {} : deployed, draft } + } +} + +/** + * Deploy a script/flow draft's trigger changes the same way the editors do. + * Scripts and flows can carry `draft_triggers`; the create/update call below + * deletes the draft row, so without this the saved trigger edits would be + * silently lost. Uses the shared `deployTriggers` (a throwaway `usedTriggerKinds` + * store is fine — it only tracks kinds for the editor UI). `isNew` forces each + * trigger's `script_path` to the deployed path (matches the editors' new path). + */ +async function deployDraftTriggers( + draftTriggers: Trigger[] | undefined, + workspace: string, + path: string, + isNew: boolean +): Promise { + const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig) + if (triggers.length === 0) return + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + await deployTriggers(triggers, workspace, isAdmin, writable([]), path, isNew) +} + +/** + * Promote a draft to deployed by replaying the editor's create/update call with + * the stored draft value. The matching draft row is deleted server-side by the + * create/update handler. Returns the same `{ success, error? }` shape as the + * fork-merge `deployItem`, so callers can reuse the `deploymentStatus` pattern. + */ +export async function deployDraft( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false, + rawApp = false +): Promise { + try { + if (kind === 'raw_app' || (kind === 'app' && rawApp)) { + // Raw apps bundle their source files and deploy via the raw-app + // endpoints. Reached as `kind === 'raw_app'` (Review & Deploy) or + // `kind === 'app'` + `rawApp` (editor). Must route here: the + // visual-app branch would `updateApp` with no `value` (RawAppDraft + // has none) and silently drop the draft's files. + await deployRawAppDraft(workspace, path) + } else if (kind === 'script') { + const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any + const d = r.draft ?? r + // Drop editor-only / server-managed keys; deploy as a real (non-draft) version. + const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d + const scriptPath = d.path ?? path + // Deploy at the draft's path so a rename in the draft is honored (same as + // the editor: createScript at the new path with parent_hash links lineage). + await ScriptService.createScript({ + workspace, + requestBody: { ...rest, path: scriptPath, parent_hash: r.hash } + }) + // Then deploy any draft trigger edits, so they aren't dropped with the draft. + await deployDraftTriggers(draftTriggers, workspace, scriptPath, true) + } else if (kind === 'flow') { + const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any + const d = r.draft ?? r + const requestBody = { + // Honor a renamed draft path; the URL `path` stays the existing item key. + path: d.path ?? path, + summary: d.summary ?? '', + description: d.description ?? '', + value: d.value, + schema: d.schema, + tag: d.tag, + dedicated_worker: d.dedicated_worker, + ws_error_handler_muted: d.ws_error_handler_muted, + visible_to_runner_only: d.visible_to_runner_only, + on_behalf_of_email: d.on_behalf_of_email, + labels: d.labels + } + // Draft-only flows have NO flow row (they live solely in the + // draft table), so they deploy via createFlow; a draft on a + // deployed flow updates it. + if (draftOnly) { + await FlowService.createFlow({ workspace, requestBody }) + } else { + await FlowService.updateFlow({ workspace, path, requestBody }) + } + // Then deploy any draft trigger edits, so they aren't dropped with the draft. + await deployDraftTriggers(d.draft_triggers, workspace, d.path ?? path, draftOnly) + } else if (kind === 'app') { + // `raw_app` is handled above; only visual apps reach here. + const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any + const d = r.draft ?? { + value: r.value, + summary: r.summary, + policy: r.policy, + path: r.path, + custom_path: r.custom_path + } + // custom_path requires admin on app update. Non-admins send undefined so + // the backend preserves the existing route (no RequireAdmin 403). For + // admins, fall back to the *deployed* route (`r.custom_path`) when the + // draft doesn't carry one — the visual-app draft value usually omits + // custom_path, and sending `''` would clear the existing route. An + // explicit '' in the draft still clears (`'' ?? x === ''`). + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const requestBody = { + value: d.value, + summary: d.summary ?? '', + policy: d.policy ?? { execution_mode: 'publisher' }, + path: d.path ?? path, + custom_path: isAdmin ? (d.custom_path ?? r.custom_path) : undefined + } + // Same as flows: draft-only apps have no app row → create; + // drafts on a deployed app update it. + if (draftOnly) { + await AppService.createApp({ workspace, requestBody }) + } else { + await AppService.updateApp({ workspace, path, requestBody }) + } + } else if (kind === 'variable') { + const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.variable!(workspace, path)) + // VariableEditor's `VariableState` draft shape: + // { path, variable: { value, is_secret, description }, labels?, wsSpecific } + if (draftOnly) { + await VariableService.createVariable({ + workspace, + requestBody: { + path: d.path ?? path, + value: d.variable?.value ?? '', + is_secret: !!d.variable?.is_secret, + description: d.variable?.description ?? '', + labels: d.labels, + ws_specific: d.wsSpecific + } + }) + } else { + await VariableService.updateVariable({ + workspace, + path, + requestBody: { + path: d.path !== path ? d.path : undefined, + // '' = untouched secret value; sending it would blank the secret. + value: d.variable?.value === '' ? undefined : d.variable?.value, + is_secret: d.variable?.is_secret, + description: d.variable?.description, + labels: d.labels, + ws_specific: d.wsSpecific + } + }) + } + void deployed + } else if (kind === 'resource') { + const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.resource!(workspace, path)) + // ResourceEditor's `ResourceState` draft shape: + // { path, description, args, resource_type?, labels?, wsSpecific } + if (draftOnly) { + await ResourceService.createResource({ + workspace, + requestBody: { + path: d.path ?? path, + value: d.args ?? {}, + description: d.description ?? '', + resource_type: d.resource_type ?? deployed.resource_type, + labels: d.labels, + ws_specific: d.wsSpecific + } + }) + } else { + await ResourceService.updateResource({ + workspace, + path, + requestBody: { + path: d.path ?? path, + value: d.args ?? {}, + description: d.description ?? '', + labels: d.labels, + ws_specific: d.wsSpecific + } + }) + } + } else if (kind === 'trigger_schedule') { + const { draft: d } = splitOverlay(await OVERLAY_GETTERS.trigger_schedule!(workspace, path)) + // The schedule editor's draft IS the cfg shape `saveScheduleFromCfg` + // consumes — same save the editor's Deploy button runs. + const ok = await saveScheduleFromCfg({ ...d, path: d.path ?? path }, !draftOnly, workspace) + if (!ok) { + return { success: false, error: 'Schedule save failed' } + } + } else if (kind in TRIGGER_SAVERS) { + const getter = OVERLAY_GETTERS[kind]! + const { draft: d } = splitOverlay(await getter(workspace, path)) + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const ok = await TRIGGER_SAVERS[kind]!( + path, + { ...d, path: d.path ?? path }, + !draftOnly, + workspace, + isAdmin + ) + if (!ok) { + return { success: false, error: 'Trigger save failed' } + } + } else { + return { success: false, error: `Deploy not supported for draft kind ${kind}` } + } + // Delete the draft at its STORAGE path (the row key, = the `path` arg). + // Two reasons it must happen here for every kind, mirroring the editors' + // post-deploy `discardDraftAfterDeploy(draftPath)`: + // - Drawer kinds (variable / resource / triggers) aren't deleted by + // their create/update endpoints at all. + // - script/flow/app/raw_app DO delete server-side, but only the draft at + // the *deployed* path (`d.path`). A renamed draft_only item lives at a + // synthetic `u/{user}/draft_{uuid}` storage path ≠ `d.path`, so its + // draft row survives the deploy and keeps listing. Deleting the + // storage-path draft removes it (a no-op when the server already did). + await UserDraftDbSyncer.save({ + workspace, + itemKind: kind, + path, + value: null, + immediate: true + }) + // Mutated the workspace's Server Drafts — refresh every mounted reader. + invalidateWorkspaceDrafts(workspace) + // For script/flow/app the server-side delete bypasses UserDraftDbSyncer, + // so the syncer-owned hint won't auto-clear — clear it explicitly. + // (Idempotent: the drawer-kind delete above already cleared it.) + setLocalDraftHint(workspace, kind, path, false) + return { success: true } + } catch (e: any) { + return { success: false, error: e?.body ?? e?.message ?? String(e) } + } +} + +/** Kind → editor save helper for the standalone trigger kinds, all sharing the + * `(initialPath, cfg, edit, workspace, isAdmin?)` shape. The throwaway + * `usedTriggerKinds` store only feeds the editors' kind-usage UI. */ +const TRIGGER_SAVERS: Partial< + Record< + DraftKind, + ( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + isAdmin: boolean + ) => Promise + > +> = { + trigger_http: (p, cfg, edit, ws, isAdmin) => + saveHttpRouteFromCfg(p, cfg, edit, ws, isAdmin, writable([])), + trigger_websocket: (p, cfg, edit, ws) => + saveWebsocketTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_postgres: (p, cfg, edit, ws) => + savePostgresTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_kafka: (p, cfg, edit, ws) => + saveKafkaTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_nats: (p, cfg, edit, ws) => + saveNatsTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_mqtt: (p, cfg, edit, ws) => + saveMqttTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_sqs: (p, cfg, edit, ws) => + saveSqsTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_gcp: (p, cfg, edit, ws) => + saveGcpTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_azure: (p, cfg, edit, ws) => + saveAzureTriggerFromCfg(p, cfg, edit, ws, writable([])), + trigger_email: (p, cfg, edit, ws, isAdmin) => + saveEmailTriggerFromCfg(p, cfg, edit, ws, isAdmin, writable([])) +} + +/** + * Discard a draft. Draft-only items exist only as a draft-table row, so + * deleting that row is the whole discard in every case. `save({ value: null })` + * is the canonical "drop my draft for this path" POST. + * + * A legacy draft (workspace-level, `email IS NULL`) isn't owned by the authed + * user, so the email-scoped syncer delete can't reach it. Discard it via a + * direct `legacy` delete instead — then clear the local hint + invalidate as + * the syncer path would. + */ +export async function discardDraft( + kind: DraftKind, + path: string, + workspace: string, + _draftOnly = false, + legacy = false +): Promise { + try { + if (legacy) { + await DraftService.updateDraft({ + workspace, + kind, + path, + requestBody: { value: null, legacy: true } + }) + setLocalDraftHint(workspace, kind, path, false) + invalidateWorkspaceDrafts(workspace) + return { success: true } + } + // postSave clears the syncer-owned `*` hint on the delete. `immediate` + // so the await resolves after the POST lands — else it resolves at + // enqueue time and the invalidate below refetches before the delete, + // re-listing the just-discarded draft. + await UserDraftDbSyncer.save({ workspace, itemKind: kind, path, value: null, immediate: true }) + invalidateWorkspaceDrafts(workspace) + return { success: true } + } catch (e: any) { + return { success: false, error: e?.body ?? e?.message ?? String(e) } + } +} diff --git a/frontend/src/lib/workspaceDrafts.svelte.ts b/frontend/src/lib/workspaceDrafts.svelte.ts new file mode 100644 index 0000000000..d9c852599d --- /dev/null +++ b/frontend/src/lib/workspaceDrafts.svelte.ts @@ -0,0 +1,96 @@ +/** + * Workspace Drafts — the single source of truth for "which Server Drafts exist + * in a workspace". Lists the deployable Draft Items once; the Draft Count is + * simply that list's length — never a separate query. This is what makes the + * count reliable: count ≡ list, by construction. + * + * Backed by `GET /w/{ws}/drafts/list` — one query over the `draft` table on + * the server, covering every kind (scripts, flows, apps, variables, resources, + * schedules, triggers) with a per-kind `draft_only` flag. + * + * Reactivity: `useWorkspaceDrafts(() => ws)` is a component-scoped `runed` + * resource — it fetches on mount and when `ws` changes, and is disposed on + * unmount, so a re-opened view always shows a fresh count (no persistent cache + * to go stale). `invalidateWorkspaceDrafts(ws)` bumps a per-workspace version so + * every *mounted* consumer re-fetches after a Server-Draft mutation. + */ +import { resource } from 'runed' +import { DraftService, type UserDraftItemKind } from '$lib/gen' + +export type DraftKind = UserDraftItemKind + +export interface DraftItem { + kind: DraftKind + path: string + summary?: string + /** User-typed friendly path (from the draft JSON's `draft_path`) when it + * differs from the storage `path` — e.g. a never-deployed item parked at + * `u/{user}/draft_{uuid}`. Display this instead of `path` when present. */ + draft_path?: string + /** Never deployed — exists only as a draft. */ + draft_only: boolean + /** Legacy workspace-level draft (email NULL) predating the per-user drafts + * migration. Not tied to any user, so anyone with access to the path sees it. */ + legacy_draft: boolean + /** App is a raw app (deploys via the raw-app endpoints). Always false for non-apps. */ + raw_app: boolean +} + +export async function getDraftItems(workspace: string): Promise { + const rows = await DraftService.listDrafts({ workspace }) + return rows.map((r) => ({ + kind: r.kind, + path: r.path, + summary: r.summary, + draft_path: r.draft_path, + draft_only: r.draft_only, + legacy_draft: r.legacy_draft, + raw_app: r.kind === 'raw_app' + })) +} + +// Per-workspace invalidation version. Bumping it changes the resource key for +// that workspace, so mounted consumers re-fetch. Plain $state record. +const versions: Record = $state({}) + +export function invalidateWorkspaceDrafts(workspace: string | undefined): void { + if (!workspace) return + versions[workspace] = (versions[workspace] ?? 0) + 1 +} + +export interface WorkspaceDraftsHandle { + readonly items: DraftItem[] + readonly count: number + readonly loading: boolean + /** Imperative re-fetch (e.g. right after a mutation in the same component). */ + refresh: () => void +} + +/** + * Reactive Workspace Drafts for the given workspace. Call at component init. + * Re-fetches on mount, when `workspace` changes, and when + * `invalidateWorkspaceDrafts(workspace)` is called while mounted. + */ +export function useWorkspaceDrafts(workspace: () => string | undefined): WorkspaceDraftsHandle { + const res = resource( + () => { + const ws = workspace() + return { ws, v: ws ? (versions[ws] ?? 0) : 0 } + }, + async ({ ws }) => (ws ? getDraftItems(ws) : []) + ) + return { + get items() { + return res.current ?? [] + }, + get count() { + return (res.current ?? []).length + }, + get loading() { + return res.loading + }, + refresh() { + void res.refetch() + } + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 01461b78cb..86712720d8 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -59,7 +59,7 @@ import MenuButton from '$lib/components/sidebar/MenuButton.svelte' import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte' import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration' - import { gcUserDrafts } from '$lib/userDraft.svelte' + import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration' import { setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -136,6 +136,16 @@ if (!deepEqual(user, $userStore)) { userStore.set(user) } + // Persist the workspace username so the synchronous `/add` → + // `/edit/u/{user}/draft_{uuid}` redirects (in each editor's + // `+page.ts`) can land on the right namespace without waiting + // for this async layout fetch. Without this they fall back to + // the `'me'` placeholder on every fresh nav. + try { + if (user?.username) localStorage.setItem('username', user.username) + } catch (e) { + console.error('Could not persist username to local storage', e) + } if (isCloudHosted() && user?.is_admin) { isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace })) } @@ -424,18 +434,19 @@ $effect(() => { $workspaceStore && untrack(() => onLoad()) }) + // One-shot UserDraft migration chain. `migrateLegacyUserDrafts` folds + // the legacy `flow` / `app-…` / `rawapp-…` LS keys into the + // `userdraft/w/{ws}/{kind}/{path}` format; `migrateUserDraftsToDb` + // then pushes those onto the server-side draft table and clears LS + // on success. The order matters — the second step only sees what + // the first one normalized. $effect(() => { - if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!)) - }) - // Sweep UserDraft entries that haven't been touched in 30 days. Runs - // once on mount and on a 30-min timer so a single very long session - // also clears out stale autosaves over time. Live entries stamp - // `lastWrittenAt` on every persist, so the sweep only touches truly - // dormant records. - $effect(() => { - gcUserDrafts() - const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000) - return () => clearInterval(interval) + if ($workspaceStore) { + untrack(() => { + migrateLegacyUserDrafts($workspaceStore!) + void migrateUserDraftsToDb() + }) + } }) $effect(() => { innerWidth && untrack(() => changeCollapsed()) @@ -602,6 +613,13 @@ /> + +
+ +
+ +
{#if $workspaceStore == 'admins'}
diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.js b/frontend/src/routes/(root)/(logged)/apps/add/+page.js deleted file mode 100644 index d0265aa9ec..0000000000 --- a/frontend/src/routes/(root)/(logged)/apps/add/+page.js +++ /dev/null @@ -1,5 +0,0 @@ -export function load() { - return { - stuff: { title: `New App` } - } -} diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte index ae0e5ebfb1..e54568a9ed 100644 --- a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte @@ -1,155 +1,5 @@ - - -{#if value} -
- {#key value} - { - goto(`/apps/edit/${path}`) - }} - {summary} - app={value} - path={''} - {policy} - fromHub={hubId != null} - newApp={true} - replaceStateFn={(path) => replaceState(path, page.state)} - gotoFn={(path, opt) => goto(path, opt)} - > - {#snippet unsavedConfirmationModal({ - diffDrawer, - additionalExitAction, - getInitialAndModifiedValues - })} - - {/snippet} - - {/key} -
-{/if} + diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps/add/+page.ts new file mode 100644 index 0000000000..52ecb242b7 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('apps/edit') diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index e2e1029d0e..cf81fe26e1 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -1,227 +1,252 @@ - - + { + // AppEditor's `stateApp` is captured at mount and ignores prop changes, + // so `redraw++` remounts it against the fresh `app`. + await loadApp() + redraw++ + }} + getLocalDraft={() => app?.value} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'app', + path, + onResetToDeployed: reloadDeployed + }) + }} /> {#key redraw} {#if app}
{ goto(`/apps/edit/${url}`) if (app) { @@ -308,29 +343,20 @@ on:restore={onRestore} summary={app.summary} app={app.value} - newPath={app.path} + newPath={app.value?.draft_path ?? app.path} path={page.params.path ?? ''} policy={app.policy} bind:savedApp {diffDrawer} version={app.versions ? app.versions[app.versions.length - 1] : undefined} - newApp={false} - initialRevs={currentRevs} + newApp={isNewApp} replaceStateFn={(path) => replaceState(path, page.state)} gotoFn={(path, opt) => goto(path, opt)} - > - {#snippet unsavedConfirmationModal({ - diffDrawer, - additionalExitAction, - getInitialAndModifiedValues - })} - - {/snippet} - + onResetToDeployed={reloadDeployed} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} + />
{/if} {/key} diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index 737ebb2e33..460b2957e0 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -81,7 +81,7 @@ size="sm" startIcon={{ icon: Pen }} variant="subtle" - href="{base}/apps/edit/{app.path}?nodraft=true">EditEdit
{/if} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js deleted file mode 100644 index 1f2d07eb57..0000000000 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js +++ /dev/null @@ -1,5 +0,0 @@ -export function load() { - return { - stuff: { title: `New Raw App` } - } -} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 716000215f..e54568a9ed 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -1,688 +1,5 @@ - - -{#if templatePicker} - -
- -
-

Summary

- -
- - -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- - -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- - {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable this - feature. - {/if} -
- {:else} -
- -
- Default settings for new tables -
-
-
- - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists - {/if} -
-
-
-
- - -
- -
- - -
- dataTableDrawer?.openDrawer()} - onRemove={(index) => { - preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) - }} - /> -
-
- {/if} - - - -
-

- - Start with AI - (optional) -

- - {#if !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. -
- {#if $userStore?.is_admin} - Configure AI in - workspace settings - - to enable this feature. - {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. - {/if} -
- {:else} -
- -

- Leave empty to start with a blank template, or describe your app to get AI assistance - right away. -

-
- {/if} -
- - -
- - {#if isAiEnabled} - - {/if} -
- -
-{/if} -{#key reloadCounter} - { - goto(`/apps_raw/edit/${event.detail}`) - }} - bind:files - bind:runnables - bind:data - {policy} - path={''} - liveEditorDraftStoragePath="" - bind:summary - newApp - /> -{/key} - - { - preWhitelistedTables = [...preWhitelistedTables, ref] - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts new file mode 100644 index 0000000000..ea96e67846 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('apps_raw/edit') diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 84ce23e7a5..982e62f339 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -1,15 +1,9 @@ - - + loadApp()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path, + onResetToDeployed: reloadDeployed + }) + }} /> + + {#if files} {#key redraw}
{ - UserDraft.remove('raw_app', path) + draftSync.remove() goto(`/apps_raw/edit/${event.detail}`) newPath = event.detail }} @@ -392,13 +439,18 @@ bind:runnables bind:data bind:summary + bind:pendingDraftPath {newPath} path={page.params.path ?? ''} liveEditorDraftStoragePath={path} {policy} bind:savedApp {diffDrawer} - newApp={false} + newApp={isNewApp} + onResetToDeployed={reloadDeployed} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} />
{/key} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte index eefa19fcd3..9fdf4c5c7d 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte @@ -58,7 +58,7 @@ size="sm" startIcon={{ icon: Pen }} variant="subtle" - href="{base}/apps_raw/edit/{page.params.path}?nodraft=true">EditEdit {/if} diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte index a8d0b566b8..2d03b85398 100644 --- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/edit/${e.path}?selected=${e.id}`) - }} - onDeploy={(e) => { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onDetails={(e) => { - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - {initialPath} - {pathStoreInit} - liveEditorDraftStoragePath="" - bind:this={flowBuilder} - newFlow - {initialArgs} - {flowStore} - {flowStateStore} - {selectedId} - {loading} - {draftTriggersFromUrl} - {selectedTriggerIndexFromUrl} - noInitial -> - - + diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.ts b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts new file mode 100644 index 0000000000..153c2255df --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('flows/edit') diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index b979a09758..1e1fcdccbc 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -1,35 +1,30 @@ - - + loadFlow()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + } + }) + }} /> {#if notFound}
@@ -404,25 +441,36 @@ {:else if renderEditor} { - UserDraft.remove('flow', flowDraftPath) + // stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} - onSaveDraftOnlyAtNewPath={(e) => { - goto(`/flows/edit/${e.path}?selected=${e.selectedId}`) - }} onHistoryRestore={() => { loadFlow() }} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} onNavigate={(item) => goto(editPathFor(item))} {flowStore} {flowStateStore} - initialPath={page.params.path ?? ''} + bind:initialPath={flowInitialPath} liveEditorDraftStoragePath={flowDraftPath} - newFlow={false} + newFlow={isNewFlow} {selectedId} {initialArgs} {loading} @@ -434,10 +482,5 @@ {selectedTriggerIndexFromUrl} {version} {loadedFromHistoryFromUrl} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index dff54785ea..8741a8b93b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -329,7 +329,7 @@ onClick: async () => { const app = createAppFromFlow(flow.path, flow.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, unifiedSize: 'md', variant: 'subtle', @@ -341,7 +341,7 @@ buttons.push({ label: 'Edit', buttonProps: { - href: `${base}/flows/edit/${path}?nodraft=true`, + href: `${base}/flows/edit/${path}`, variant: 'accent', unifiedSize: 'md', disabled: !can_write || !showEditButtons, @@ -538,6 +538,7 @@ errorHandlerKind="flow" tag={flow?.tag ?? ''} labels={flow?.labels} + inheritedLabels={flow?.inherited_labels} summary={flow?.summary} path={flow?.path} onSaved={can_write diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.js b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js index 712d677d68..2e9ca0e335 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.js +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js @@ -1,5 +1,5 @@ export function load() { return { - stuff: { title: 'Compare / Deploy to main workspace' } + stuff: { title: 'Compare & Deploy' } } } diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index a6be7b1044..2710167325 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -1,9 +1,11 @@ - - {#if isFork} -
+ +
+ + {#if isFork} -
- {/if} + {/if} +
- {#if currentWorkspaceId && parentWorkspaceId} - - {/if} {#if !currentWorkspaceId} No workspace selected - {:else if !parentWorkspaceId} + {:else if mode === 'draft'} + + {:else if parentWorkspaceId} + + {:else} workspace {currentWorkspaceId} has no parent workspace {/if}
@@ -151,7 +264,8 @@ Archive forked workspace {currentWorkspaceId}? It will be hidden from the workspace picker; a superadmin can restore it from instance - settings later. + settings later. Its content is kept and its workspace id stays reserved — use Delete fork + instead if you want to reuse the id for a new fork.

diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index bb65a27065..6e59e7be5e 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - -{#if scriptHandle.draft} - { - // "Deploy & Stay here" / lib: stay on the editor (just confirm). - if (e.stay) { - sendUserToast('Deployed') - return - } - goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) - }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - searchParams={page.url.searchParams} - bind:script={scriptHandle.draft} - {showMeta} - > - - -{:else} - -{/if} + diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts new file mode 100644 index 0000000000..05237ef0db --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('scripts/edit') diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index b76c03a804..f04ef93188 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -1,95 +1,40 @@ - - + loadScript()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + } + }) + }} /> - - -{#if scriptHandle.draft && renderEditor} +{#if draftSync.draft && renderEditor} (othersModalOpen = true)} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + }} onDeploy={(e) => { // "Deploy & Stay here" / lib: stay on the editor (just confirm). if (e.stay) { sendUserToast('Deployed') return } - UserDraft.remove('script', draftPath) + // stopSync-bracketed immediate delete; see restoreDeployed for the race. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} onSeeDetails={(e) => { goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} onNavigate={(item) => goto(editPathFor(item))} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index 12ca517490..24bf997c50 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -422,7 +422,7 @@ onClick: async () => { const app = createAppFromScript(script.path, script.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, disabled: !showEditButtons, unifiedSize: 'md', @@ -666,6 +666,7 @@ scriptOrFlowPath={script?.path ?? ''} tag={script?.tag ?? ''} labels={script?.labels} + inheritedLabels={script?.inherited_labels} on:seeTriggers={() => { rightPaneSelected = 'triggers' }} diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index b70730bd8c..586d2077d2 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -1,4 +1,5 @@