diff --git a/.agents/skills/adding-a-trigger/SKILL.md b/.agents/skills/adding-a-trigger/SKILL.md
new file mode 100644
index 0000000000..7d8643b862
--- /dev/null
+++ b/.agents/skills/adding-a-trigger/SKILL.md
@@ -0,0 +1,267 @@
+---
+name: adding-a-trigger
+description: Checklist for adding a new TriggerCrud-based trigger type to Windmill (Azure, GCP, Kafka, etc.). Use when wiring a new trigger kind across backend, frontend, CLI, and capture infrastructure.
+---
+
+# Skill: Adding a New Trigger Type
+
+Use this skill when adding a trigger kind that implements `TriggerCrud` (Kafka, GCP, Azure, MQTT, SQS, NATS, Postgres, Email…). For native triggers (Nextcloud, Google Drive — things wired through `windmill-native-triggers`), use the `native-trigger` skill instead.
+
+The goal of this doc is to enumerate every file that needs to change. Missing any one of them leads to silent regressions: sync drops the trigger, capture button does nothing, workspace forks lose it, sidebar counters undercount. Follow the checklist top-to-bottom — each section is independent enough to be validated on its own.
+
+Throughout this doc, substitute `{kind}` for the new trigger kind (`azure`, `kafka`, …), `{Kind}` for PascalCase (`Azure`, `Kafka`), `{KIND}` for SCREAMING (`AZURE`, `KAFKA`).
+
+## Reference implementations
+
+- **GCP** — closest analogue to Azure. Has push + pull, OIDC auth, ARM-like resource paths, capture handler. Grep for `gcp_trigger` / `GcpTrigger`.
+- **Kafka** — simpler (pull-only, streaming). Good for trivial integrations.
+- **Azure** — most recently added (2026). Shared-secret push auth, Event Grid namespaces + basic topics, ARM resource discovery, Namespace-pull data-plane. Grep for `azure_trigger` / `AzureTrigger`.
+
+## 1. Database migration
+
+Create a migration: `cargo sqlx migrate add -r add_{kind}_trigger` from `backend/`. Never write timestamps manually.
+
+The `up.sql` usually defines:
+- An optional enum type (e.g. `AZURE_MODE`) if the trigger has sub-kinds
+- The `{kind}_trigger` table with at minimum these columns (mirrored from kafka/gcp):
+ - primary: `(workspace_id, path)`
+ - `script_path`, `is_flow`, `enabled`, `mode`, `permissioned_as`, `edited_by`, `email`
+ - `edited_at`, `error`, `server_id`, `last_server_ping`
+ - `error_handler_path`, `error_handler_args jsonb`, `retry jsonb`
+ - trigger-specific fields
+- Indexes on foreign keys + any frequently-filtered columns
+- Foreign key to `workspace`
+
+Down migration drops the table and any enum types.
+
+## 2. Backend crate (`windmill-trigger-{kind}`)
+
+Create a new crate under `backend/windmill-trigger-{kind}/` with:
+
+- `Cargo.toml`: features `enterprise`, `private` if EE, standard deps
+- `src/lib.rs`: `pub use mod_ee::*;` behind `#[cfg(all(feature = "enterprise", feature = "private"))]`
+- `src/mod_ee.rs`: core types + helpers
+- `src/handler_ee.rs`: `TriggerCrud` impl + route handlers
+- `src/listener_ee.rs`: (only if streaming/pull-based) `Listener` trait impl
+
+Required in `mod_ee.rs`:
+- `{Kind}Config` struct (persisted shape, `FromRow`)
+- `{Kind}ConfigRequest` struct (what API receives — usually similar to Config but with validation fields)
+- `{Kind}Trigger` unit struct (implements the traits)
+- `impl TriggerJobArgs for {Kind}Trigger` — sets `TRIGGER_KIND`, `Payload`, `v1_payload_fn`
+
+Required in `handler_ee.rs`:
+- `#[async_trait] impl TriggerCrud for {Kind}Trigger` with:
+ - `type Trigger = Trigger<{Kind}Config>`
+ - `type TriggerConfigRequest = {Kind}ConfigRequest`
+ - `const ROUTE_PREFIX: &'static str = "/{kind}_triggers";`
+ - `const TABLE_NAME`, `ADDITIONAL_SELECT_FIELDS`
+ - `get_deployed_object`, `validate_config`, `create_trigger`, `update_trigger`, `delete_trigger`, `test_connection`
+ - `additional_routes` (optional — mount extra endpoints for things like ARM resource listing, topic discovery)
+
+Register the crate in `backend/Cargo.toml` as a workspace member and as a dep of `windmill-api` behind the feature flag.
+
+## 3. Wire into `windmill-api` (feature-gated everywhere)
+
+**`backend/windmill-api/src/triggers/handler.rs`** — mount the trigger crate:
+```rust
+#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
+{
+ use crate::triggers::{kind}::{Kind}Trigger;
+ router = router.nest({Kind}Trigger::ROUTE_PREFIX, complete_trigger_routes({Kind}Trigger));
+}
+```
+
+**`backend/windmill-api/src/triggers/{kind}/mod.rs`** — re-export the crate:
+```rust
+pub use windmill_trigger_{kind}::*;
+```
+
+**`backend/windmill-api/src/lib.rs`** — if the trigger receives inbound pushes, add a webhook route:
+```rust
+.nest("/{kind}/w/{workspace_id}", {
+ #[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
+ { triggers::{kind}::handler_oss::{kind}_push_route_handler() }
+ #[cfg(not(...))]
+ { Router::new() }
+})
+```
+
+## 4. `TriggerKind` enum (`backend/windmill-types/src/triggers.rs`)
+
+Already has slots for most triggers but verify your variant exists:
+- Add `{Kind}` to the `TriggerKind` enum
+- Add match arm in `to_key()`
+- Add match arm in `from_str`
+- Add match arm in `JobTriggerKind` (if jobs need kind tagging)
+
+## 5. OpenAPI (`backend/windmill-api/openapi.yaml`)
+
+This file is huge and the single most-forgotten place. Add:
+
+- `/w/{workspace}/{kind}_triggers/create` + `/update/{path}` + `/delete/{path}` + `/get/{path}` + `/list` + `/exists/{path}` + `/setmode/{path}` + `/test` paths (mirror gcp section)
+- Any `additional_routes` your handler exposes (resource discovery, etc.)
+- Schemas: `{Kind}Trigger`, `{Kind}TriggerData`, `{Kind}Mode` (if enum), `{Kind}DeliveryConfig`, helper request/response types
+- Add `{kind}` to `CaptureTriggerKind` enum
+- Add `{kind}_used: boolean` to the `UsedTriggers` response schema
+
+Regenerate frontend client: `npm run generate-backend-client` from `frontend/`.
+
+## 6. `UsedTriggers` + workspace export
+
+**`backend/windmill-api-workspaces/src/workspaces.rs`** — add `{kind}_used: bool` to the `UsedTriggers` struct and add an `EXISTS(SELECT 1 FROM {kind}_trigger …)` to the `get_used_triggers` query.
+
+**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON). The block re-uses the `trigger_ignore_keys` variable so the new kind automatically participates in fork-export stripping (`mode` field is omitted when the source workspace is a fork — keeps fork→parent merges from flipping the parent's enabled state).
+
+**Fork cloning (`clone_triggers_and_schedules` in workspaces.rs)** — add an `INSERT INTO {kind}_trigger ... SELECT ...` block that copies all rows from the parent workspace, forcing `mode = 'disabled'::TRIGGER_MODE`. Always runs at fork creation; forgetting this means users can't carry `{kind}` triggers into their forks.
+
+## 6.5 Hardcoded trigger-kind arrays (silent-failure hotspots)
+
+Several files keep **hardcoded arrays** of trigger kind strings. Miss one and ACL checks / user offboarding / trash drop your kind:
+
+- **`backend/windmill-api-groups/src/granular_acls.rs`** — `KINDS: [&str; N]`. **Increment N** (the compile error is cryptic otherwise). Controls which kinds accept granular ACL operations.
+- **`backend/windmill-api-users/src/users.rs`** (`extra_perms_tables`) — which tables get `extra_perms` entries cleaned when a user is deleted.
+- **`backend/windmill-api/src/offboarding.rs`** — three separate arrays (enumeration, fork-copy, and delete paths). **All three** need the new kind.
+- **`backend/windmill-api/src/trash.rs`** — `valid_tables` for the trash / restore API.
+- **`backend/windmill-git-sync/src/lib.rs`** — add a test assertion for `DeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger"` (the `get_kind` match arm itself lives in the enum impl — already required by the Rust compiler).
+- **`backend/windmill-api-auth/src/scopes.rs`** — add the `{Kind}Triggers` variant to `ScopeDomain` enum + `as_str` match + `from_str` match. Required for the OAuth/token system to recognise `{kind}_triggers:read|write` scopes.
+- **`backend/windmill-api/src/token.rs`** (`build_trigger_scope_domains` → `TRIGGER_DOMAINS`) — add `("{kind}_triggers", "{Kind display name}")` so the CreateToken UI's scope selector surfaces the `read` / `write` checkboxes.
+
+**OpenAPI enums** to extend (do NOT forget — generated client will allow it but server rejects as 400):
+- `CaptureTriggerKind` enum
+- Three `kind` enums under `/w/{workspace}/acls/{get,add,remove}/{kind}/{path}` (yes, same list repeated three times)
+
+After editing any of these, run a full `cargo check` with your feature flag + `gcp_trigger` + other core flags — the `KINDS: [&str; N]` length mismatch only surfaces when the crate compiles.
+
+## 7. Capture infrastructure (`backend/windmill-api/src/capture.rs`)
+
+If the trigger supports push delivery, it also needs a capture endpoint so users can test it:
+
+- `{Kind}TriggerConfig` struct (gated by feature flags)
+- `TriggerConfig::{Kind}` variant
+- `set_{kind}_trigger_config` function (creates the subscription/equivalent pointing at the capture URL — use your `manage_{kind}_subscription` helper with `trigger_mode=false`)
+- Both real + no-op versions behind feature gates
+- `TriggerKind::{Kind} => set_{kind}_trigger_config(...)` arm in `set_config`
+- `{kind}_payload` async handler — validates auth (if any), processes payload, calls `insert_capture_payload`
+- Route: `.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload))` inside `workspaced_unauthed_service` — and expand the surrounding `#[cfg(any(...))]` to include your feature flag
+
+## 8. CLI (`cli/`) — easy to miss, breaks sync silently
+
+Check all of these:
+
+**`cli/src/types.ts`:**
+- Add `"{kind}"` to `TRIGGER_TYPES` array
+- Add `"{kind}_trigger"` to `getTypeStrFromPath` return union
+- Add match case in `getTypeStrFromPath`'s `typeEnding ===` chain
+- Add `pushTrigger("{kind}", ...)` branch in `pushObj`
+
+**`cli/src/commands/trigger/trigger.ts`:**
+- Import `{Kind}Trigger` type
+- Add `{kind}: {Kind}Trigger` to the `Trigger` type map
+- Add `{kind}: wmill.get{Kind}Trigger`, `update{Kind}Trigger`, `create{Kind}Trigger` to each function map
+- Add `{kind}: { ... }` template to `triggerTemplates`
+- Add `list{Kind}Triggers` call + spread in the `list` aggregation
+- Update `--kind` option descriptions to mention the new kind
+
+**`cli/src/commands/sync/sync.ts`:**
+- Add `path.endsWith(".{kind}_trigger" + ext)` in the file-type filter
+- Add `typ == "{kind}_trigger"` in `getTypeOrder`
+- Add `"{kind}_trigger"` to the delete-suffix regex (~line 3092)
+- Add a `case "{kind}_trigger"` in the delete switch
+
+**`cli/src/guidance/skills.ts`** — **DO NOT EDIT DIRECTLY**. It's auto-generated by `system_prompts/generate.py`. Instead:
+- Edit `system_prompts/utils.py` → append `('{Kind}Trigger', '{kind}_trigger')` to the `SCHEMA_MAPPINGS['triggers']` list (this is the master list — the one in `generate.py` is duplicated and `utils.py` wins)
+- Then run `python3 system_prompts/generate.py` — it regenerates `cli/src/guidance/skills.ts` with the schema extracted from `backend/windmill-api/openapi.yaml`
+- Commit the regenerated file
+
+## 9. Frontend — editor + drawer
+
+Under `frontend/src/lib/components/triggers/{kind}/`:
+
+- `{Kind}TriggerPanel.svelte` — the tile shown in the triggers listing
+- `{Kind}TriggerEditor.svelte` — outer drawer wrapper
+- `{Kind}TriggerEditorInner.svelte` — state + business logic; must expose:
+ - `openEdit(path, isFlow, defaultValues?)` method
+ - `isEditor` prop, `onConfigChange` + `onCaptureConfigChange` callbacks
+ - `get{Kind}Config()` + `get{Kind}CaptureConfig()` helpers
+ - `captureConfig = $derived.by(untrack(() => isEditor) ? get{Kind}CaptureConfig : () => ({}))`
+ - `$effect(() => { const args = [captureConfig, isValid] as const; untrack(() => onCaptureConfigChange?.(...args)) })`
+- `{Kind}TriggerEditorConfigSection.svelte` — form fields; use design-system components (`TextInput`, `Select`, `Toggle`, `ToggleButtonGroup`), never raw ` `
+- `{Kind}Capture.svelte` — capture panel; wraps `CaptureSection` with `captureType="{kind}"`
+- `utils.ts` — `requestBody` builders and any trigger-type-specific helpers
+
+## 10. Frontend — global integration
+
+Easy to miss:
+
+- **`frontend/src/lib/components/triggers.ts`** — add `'{kind}'` to the `TriggerKind` union
+- **`frontend/src/lib/components/triggers/CaptureWrapper.svelte`**:
+ - Import `{Kind}Capture`
+ - Add to `isStreamingCapture()` array (streaming = pull-style; push-style is typically `false`)
+ - Add `{:else if captureType === '{kind}'}` branch with the `<{Kind}Capture>` render
+- **`frontend/src/lib/components/sidebar/SidebarContent.svelte`** — import the icon, add the nav entry
+- **`frontend/src/lib/components/sidebar/OperatorMenu.svelte`** — add the operator-mode entry
+- **`frontend/src/routes/(root)/(logged)/+layout.svelte`** — destructure `{kind}_used` from `/get_used_triggers` response, push `'{kind}'` into `usedKinds`
+- **`frontend/src/lib/components/search/GlobalSearchModal.svelte`** — import icon, add "Go to {Kind} ..." entry
+- **`frontend/src/lib/components/offboarding-utils.ts`** — add mappings `{kind}_trigger: '{kind}_triggers'` and `{kind}_trigger: '{kind} trigger'`
+- **`frontend/src/lib/components/icons/{Kind}Icon.svelte`** — single-path SVG, `fill={color ?? 'currentColor'}`, `size` prop default 16 (match existing icons — don't hardcode colors, don't use `width`/`height` props)
+- **`frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte`** — listing page (mirror `gcp_triggers/+page.svelte` for push+pull, `kafka_triggers` for pure streaming)
+- **`frontend/src/lib/components/CompareWorkspaces.svelte`** — workspace fork / compare tool. Needs: service import, editor import, `{kind}Editor` `$state`, `case '{kind}'` in `openTriggerDetails()`, entry in `triggerServices` object (list/delete/normalize), and `<{Kind}TriggerEditor bind:this={{kind}Editor} />` in the template
+
+## 10.5 AI system prompts (`system_prompts/`)
+
+- **`system_prompts/utils.py`** — append `('{Kind}Trigger', '{kind}_trigger')` to `SCHEMA_MAPPINGS['triggers']` (master list used by code generation + CLI skills)
+- **`system_prompts/generate.py`** — also has a duplicated `schema_types` list (~line 903) for the AI `triggers` skill content. Add `('{Kind}Trigger', '{kind}_trigger')` there too
+- **`system_prompts/generate.py`** `schema_names` (~line 1192) — add `'{Kind}Trigger'` (add `'New{Kind}Trigger'` only if the OpenAPI declares one; GCP and Azure don't)
+- Run `python3 system_prompts/generate.py` — this rewrites `cli/src/guidance/skills.ts` and all `auto-generated/` docs. Commit the regenerated files
+
+## 11. Validation
+
+Run all of these before declaring done:
+
+```bash
+# Backend
+cd backend
+cargo check --features enterprise,{kind}_trigger,private # minimal
+cargo check --features enterprise,azure_trigger,private,gcp_trigger,http_trigger,mqtt_trigger,postgres_trigger,sqs_trigger,kafka,nats,smtp,websocket # full
+
+# SQLx offline data (never run `cargo sqlx prepare` directly — use the wrapper)
+./update_sqlx.sh
+
+# Frontend
+cd frontend
+npm run generate-backend-client
+npm run check:fast
+```
+
+Smoke test in the UI: create a trigger, save, check it appears in sidebar + search, delete, re-create via CLI `wmill sync`.
+
+## 12. Common pitfalls
+
+- **Forgetting feature gates in `workspaced_unauthed_service()`** — the surrounding `#[cfg(any(...))]` expression must include your feature flag, not just the inner `#[cfg]` on the route
+- **`.route(path, ...).route(path, ...)` with same path and different methods** — older axum replaced; use `.route(path, post(h1).options(h2))` to chain methods on the same `MethodRouter`
+- **`on:event` directives** — legacy Svelte 4, no-op in runes mode. Use callback props (`onSelected`, `onConfigChange`)
+- **`$bindable(default_value)` on optional props** — banned by project CLAUDE.md. Use `$bindable()` + `$derived(prop ?? default)` instead
+- **CORS layer intercepting OPTIONS** — tower-http CorsLayer short-circuits OPTIONS before reaching your handler. For server-to-server webhook endpoints, drop the CORS layer entirely (CORS is browser-only)
+- **DeliveryAttributeMappings / custom headers for auth** — prefer HMAC or sha256-hashed shared secrets over opaque JWTs when the provider doesn't support signed tokens natively. Store only the hash; regenerate secret on every save
+- **ARM / API resource-listing cascades** — if the trigger's resource type is deep (Azure: subscription → RG → namespace → topic), offer dropdowns in the UI populated from the provider's APIs using the user's credential resource
+- **Clearing stale selections on dependency change** — when a dropdown's underlying data reloads (e.g., user changes SP or edition), clear selections that no longer match the new list
+- **Workspace-scoped tag compatibility** — if the trigger has tags, verify forked workspaces handle them (see commit `0773b5bc85` for a historical fix)
+
+## 13. EE file split
+
+If the trigger is enterprise-only, the code lives in `windmill-ee-private__worktrees/.../windmill-trigger-{kind}/src/*_ee.rs` and is symlinked into the OSS tree. The `windmill-ee-private__worktrees/` directory holds the real files; changes propagate via symlinks. See `docs/enterprise.md` for the workflow.
+
+## 14. Final checklist before PR
+
+- [ ] Migration up/down tested (revert + re-apply)
+- [ ] `./update_sqlx.sh` committed the updated `.sqlx/` offline data
+- [ ] `cargo check` passes with your feature flag + with all trigger features
+- [ ] `npm run check:fast` passes
+- [ ] Trigger visible in sidebar with correct icon weight (not oversized/colored — use `currentColor`)
+- [ ] Create, edit, delete flow all work in the UI
+- [ ] Capture button works (if push-capable)
+- [ ] Trigger appears in `/get_used_triggers` → sidebar pulse
+- [ ] `wmill sync pull` + `wmill sync push` both round-trip the trigger
+- [ ] `wmill trigger list` includes it
+- [ ] OpenAPI schemas are complete (no `null` in generated types)
diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md
index d610fa3f65..114531570e 100644
--- a/.agents/skills/commit/SKILL.md
+++ b/.agents/skills/commit/SKILL.md
@@ -1,5 +1,6 @@
---
name: commit
+user_invocable: true
description: Create a git commit with conventional commit format. MUST use anytime you want to commit changes.
---
@@ -52,8 +53,6 @@ chore: upgrade sqlx to 0.7
4. Stage ONLY the modified/relevant files: `git add ...`
5. Create the commit with conventional format:
```bash
- git commit -m ":
-
- Co-Authored-By: Claude Opus 4.5 "
+ git commit -m ": "
```
6. Run `git status` to verify the commit succeeded
diff --git a/.agents/skills/local-review/SKILL.md b/.agents/skills/local-review/SKILL.md
index ad701ac367..316daa0fea 100644
--- a/.agents/skills/local-review/SKILL.md
+++ b/.agents/skills/local-review/SKILL.md
@@ -1,97 +1,98 @@
---
name: local-review
-description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code.
+description: Code review the current PR (or branch diff against main) for bugs, security, and AGENTS.md compliance. MUST use when asked to review code.
---
-# Local Code Review Skill
+# Local Code Review
-Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only.
+Run the same review locally that the GitHub auto-review actions run on PRs (Claude / Codex / Pi). The review policy lives in `REVIEW.md`.
-## Review Philosophy
+**Why a subagent**: the review MUST run in a fresh context — not inline in the current session. If the user has been iterating on the diff, the main session has absorbed their reasoning and rationalizations, so it anchors and misses things CI catches. A subagent starts cold, like CI does.
-- **Only flag issues you are certain about.** If you are not sure an issue is real, do not flag it. False positives erode trust and waste reviewer time.
-- Think like a senior engineer doing a final review — flag things that would cause incidents, not things that are merely imperfect.
+## Steps
-## What to Flag
+1. **Determine the PR scope** (cheap, do this in the main session):
+ - If an argument is provided, treat it as a PR number or branch.
+ - Otherwise, detect from the current branch vs `main`.
+ - Confirm the PR/branch exists (`gh pr view ` or `git rev-parse `).
-- Code that won't compile or parse (syntax errors, type errors, missing imports)
-- Code that will definitely produce wrong results regardless of inputs
-- Clear, unambiguous CLAUDE.md violations (quote the exact rule being violated)
-- Security issues in introduced code (injection, auth bypass, data exposure)
-- Incorrect logic that will fail in production
+2. **Delegate the review to a fresh-context subagent** with a self-contained prompt. The prompt MUST include:
+ - The PR number or branch name to review.
+ - The instruction to read `REVIEW.md` first for the policy, then `AGENTS.md` files in directories touched by the diff.
+ - The exact output format (see below).
+ - Whether `--comment` was requested (so the subagent emits inline-comment payloads if needed).
+ - Any "Additional reviewer instructions" the user provided.
-## What NOT to Flag
+ - **Claude Code**: use the `Agent` tool with `subagent_type: branch-diff-reviewer` (read-only tools, purpose-built for this). If unavailable, fall back to `general-purpose`.
+ - **Codex / Pi**: if the CLI exposes a fresh-session subagent mechanism, use it. Otherwise tell the user to run the skill in a fresh CLI session and stop — running inline in the current session defeats the purpose.
-- Code style or quality concerns
-- Potential issues that depend on specific inputs or runtime state
-- Subjective suggestions or improvements
-- Pre-existing issues not introduced by this PR
-- Pedantic nitpicks a senior engineer wouldn't flag
-- Issues a linter or type checker will catch
-- General quality concerns unless explicitly prohibited in CLAUDE.md
-- Issues silenced via lint ignore comments
+3. **Receive the findings** from the subagent and relay them to the user verbatim. Do not re-summarize, re-judge, or filter — the whole point of fresh context is to surface what the main session would dismiss.
-## Execution Steps
+4. **Post comments if `--comment` was requested**: use the `gh` commands below with the subagent's output as the body. The main session does the posting because the subagent is read-only.
-1. **Determine the PR scope**:
- - If an argument is provided, use it as the PR number or branch
- - Otherwise, detect from the current branch vs main
- - Run `gh pr view` if a PR exists, or use `git diff main...HEAD`
+## Subagent prompt template
-2. **Find relevant CLAUDE.md files**:
- - Read the root `CLAUDE.md`
- - Check for CLAUDE.md files in directories containing changed files
+```
+Review against main per the policy in REVIEW.md.
-3. **Get the diff and metadata**:
- - `gh pr diff` or `git diff main...HEAD` for the full diff
- - `gh pr view` or `git log main..HEAD --oneline` for context
+Steps:
+1. Read REVIEW.md (repo root) for the full policy: severity triage, public-surface
+ checklist, AGENTS.md compliance, test coverage assessment.
+2. Read AGENTS.md (repo root) and any AGENTS.md in directories touched by the diff.
+3. Get the diff: `gh pr diff ` (if PR) or `git diff main...`.
+4. Get context: `gh pr view ` (if PR) or `git log main.. --oneline`.
+5. Read changed files only when the diff alone is insufficient to validate a finding.
+6. Self-validate each finding: "is this definitely a real issue a senior engineer
+ would flag?" Discard if uncertain.
+7. Output findings in the exact format below. Do not modify any files.
-4. **Read changed files** where the diff alone is insufficient to understand context
+
-5. **Review for**:
- - CLAUDE.md compliance — check each rule against the changed code
- - Bugs and logic errors — will this code work correctly?
- - Security issues — injection, auth, data exposure in new code
+
+Additionally emit a JSON array of inline comments suitable for the GitHub reviews
+API, one per finding that maps to a specific line:
+[{"path": "...", "line": N, "side": "RIGHT", "body": "[P1] ..."}, ...]
+```
-6. **Self-validate each finding**: Before reporting, ask yourself:
- - "Is this definitely a real issue, not a false positive?"
- - "Would a senior engineer flag this in review?"
- - If the answer to either is no, discard the finding
-
-7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag)
-
-## Output Format
+## Output format
```
## Code review
+
+
Found N issues:
-1. ()
+1. [P0|P1|P2]
-2. ()
+2. [P0|P1|P2]
```
+End with a `Test coverage` section per the shared policy.
+
If no issues are found:
```
## Code review
-No issues found. Checked for bugs and CLAUDE.md compliance.
+Good to merge.
+
+No issues found. Checked for bugs, security, and AGENTS.md compliance.
```
-## Posting Comments (--comment flag)
+## Posting comments (`--comment`)
-If the user passes `--comment`, post findings as inline PR comments using:
+For a top-level PR comment:
```bash
-gh pr review --comment --body ""
+gh pr review --comment --body ""
```
-Or for inline comments on specific lines:
+For inline comments on specific lines (using the JSON the subagent emitted):
```bash
-gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]"
+gh api repos/{owner}/{repo}/pulls/{pr}/reviews \
+ -f body="" -f event="COMMENT" -f comments=""
```
diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md
index 781e200d0c..ae38f70b32 100644
--- a/.agents/skills/native-trigger/SKILL.md
+++ b/.agents/skills/native-trigger/SKILL.md
@@ -607,7 +607,18 @@ In `frontend/src/lib/components/triggers/TriggersEditor.svelte`:
Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete.
-### Step 17: Update OpenAPI Spec and Regenerate Types
+### Step 17: Update `getUsedTriggers` for Sidebar Visibility
+
+The sidebar (`frontend/src/lib/components/sidebar/SidebarContent.svelte`) shows native-trigger links only if `$usedTriggerKinds` includes the service — without this, your trigger page will never appear in the nav bar even when triggers exist.
+
+1. **Backend** — add `{service}_used: bool` to the `UsedTriggers` struct and SELECT in `backend/windmill-api-workspaces/src/workspaces.rs::get_used_triggers()`:
+ ```rust
+ EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = '{service}'::native_trigger_service) AS "{service}_used!"
+ ```
+2. **OpenAPI** — add `{service}_used: boolean` to the response schema for `GET /w/{workspace}/workspaces/used_triggers` (under both `properties` and `required`).
+3. **Layout** — in `frontend/src/routes/(root)/(logged)/+layout.svelte::loadUsedTriggerKinds()`, destructure `{service}_used` and push `'{service}'` to `usedKinds`.
+
+### Step 18: Update OpenAPI Spec and Regenerate Types
Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then:
diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md
index 2efcc4e0a6..ef52d6e110 100644
--- a/.agents/skills/pr/SKILL.md
+++ b/.agents/skills/pr/SKILL.md
@@ -1,5 +1,6 @@
---
name: pr
+user_invocable: true
description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR.
---
@@ -50,22 +51,22 @@ The body MUST be explicit about what changed. Structure:
## Test plan
- [ ]
- [ ]
-
----
-Generated with [Claude Code](https://claude.com/claude-code)
```
+The harness/tooling that invoked the skill may add its own attribution trailer; the skill itself does not prescribe one.
+
## 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. Check if remote branch exists and is up to date:
+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:
```bash
git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream"
```
-5. Push to remote if needed: `git push -u origin HEAD`
-6. Create draft PR using gh CLI:
+6. Push to remote if needed: `git push -u origin HEAD`
+7. Create draft PR using gh CLI:
```bash
gh pr create --draft --title ": " --body "$(cat <<'EOF'
## Summary
@@ -78,13 +79,10 @@ Generated with [Claude Code](https://claude.com/claude-code)
## Test plan
- [ ]
- [ ]
-
- ---
- Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
-7. Return the PR URL to the user
+8. Return the PR URL to the user
## EE Companion PR (when `*_ee.rs` files were modified)
@@ -100,9 +98,6 @@ Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific deta
```bash
gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF'
Companion PR for windmill-labs/windmill#
-
- ---
- Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
diff --git a/.agents/skills/refine/SKILL.md b/.agents/skills/refine/SKILL.md
index b96e97e8a2..aaf747cd29 100644
--- a/.agents/skills/refine/SKILL.md
+++ b/.agents/skills/refine/SKILL.md
@@ -1,5 +1,6 @@
---
name: refine
+user_invocable: true
description: End-of-session reflection. Reviews friction encountered during the session and proposes updates to docs/ to capture lessons learned.
---
diff --git a/.agents/skills/update-sqlx/SKILL.md b/.agents/skills/update-sqlx/SKILL.md
new file mode 100644
index 0000000000..ce4eaa5ff7
--- /dev/null
+++ b/.agents/skills/update-sqlx/SKILL.md
@@ -0,0 +1,82 @@
+---
+name: update-sqlx
+description: How to safely update SQLx offline query cache. MUST use when SQL queries change.
+---
+
+# SQLx Offline Query Cache
+
+Windmill uses `SQLX_OFFLINE=true` in CI, which requires all `sqlx::query!` / `sqlx::query_as!` macros to have matching cached query data in `backend/.sqlx/`.
+
+## When to Run
+
+Run after any change to SQL queries in Rust source files. Without it, CI will fail with:
+```
+error: `SQLX_OFFLINE=true` but there is no cached data for this query
+```
+
+## The Problem
+
+`cargo sqlx prepare --workspace` **deletes all existing cache files** and regenerates only the ones found in the current compilation. If you don't compile with every feature flag (especially `private` for EE files), you will **silently delete EE query caches**, breaking CI for enterprise tests.
+
+The standard `./update_sqlx.sh` script tries to compile with all features, but it often fails locally because the EE symlinks can be out of sync with `main`.
+
+## Safe Procedure
+
+Always preserve the existing EE caches from `origin/main`. Use this workflow:
+
+```bash
+cd backend
+
+# 1. Restore the full cache from main (includes EE caches)
+git checkout origin/main -- .sqlx/
+
+# 2. Run prepare with OSS features (what compiles locally)
+# This regenerates OSS caches to match your code changes.
+cargo sqlx prepare --workspace -- --workspace --features all_sqlx_features
+
+# 3. Restore any EE caches that were deleted in step 2.
+# These are files present in origin/main but missing after prepare.
+git ls-tree origin/main backend/.sqlx/ \
+ | awk '{print $4}' | sed 's|backend/\.sqlx/||' | sort > /tmp/main_files.txt
+
+find backend/.sqlx -name "*.json" -printf '%P\n' | sort > /tmp/current_files.txt
+
+comm -23 /tmp/main_files.txt /tmp/current_files.txt > /tmp/missing_files.txt
+
+while read f; do
+ git show "origin/main:backend/.sqlx/$f" > "backend/.sqlx/$f"
+done < /tmp/missing_files.txt
+
+# 4. Verify nothing was lost from main
+find backend/.sqlx -name "*.json" -printf '%P\n' | sort > /tmp/current_files.txt
+comm -23 /tmp/main_files.txt /tmp/current_files.txt | wc -l
+# Should output: 0
+```
+
+## If EE Compiles Locally
+
+If your EE repo happens to be in sync, you can use the full script (faster):
+
+```bash
+cd backend
+./update_sqlx.sh
+```
+
+But if it fails with EE compilation errors, use the safe procedure above.
+
+## What NOT to Do
+
+- **Never** run `cargo sqlx prepare --workspace` with only OSS features and commit the result — it will delete EE caches.
+- **Never** set `SQLX_OFFLINE=true` for local `cargo sqlx prepare` — use a live database per CLAUDE.md. (CI runs with `SQLX_OFFLINE=true`, which is why the cache must be complete.)
+- **Never** skip the verification step (step 4 above).
+
+## Verification
+
+After committing, the diff against `origin/main` should show:
+- A few **new** cache files (for your changed queries)
+- A few **deleted** cache files (for old queries that no longer exist)
+- **Zero** net deletions from the EE cache set
+
+```bash
+git diff origin/main --stat backend/.sqlx/
+```
diff --git a/.claude/review-prompt.md b/.claude/review-prompt.md
index 6814089bea..b3b6df0d74 100644
--- a/.claude/review-prompt.md
+++ b/.claude/review-prompt.md
@@ -1,25 +1,4 @@
-# Code Review Instructions
+# Claude output format
-Review this pull request and provide comprehensive feedback.
-
-## Focus Areas
-
-- **Code quality and best practices** — does the code follow established patterns?
-- **Potential bugs or issues** — will this code work correctly in all cases?
-- **Performance considerations** — are there unnecessary allocations, N+1 queries, or bottlenecks?
-- **Security implications** — injection, auth bypass, data exposure?
-
-## CLAUDE.md Compliance
-
-Read all relevant CLAUDE.md files (root and in directories containing changed files). Check each rule against the changed code. Quote the exact rule when flagging a violation.
-
-## Review Guidelines
-
-- Provide detailed feedback using inline comments for specific issues
-- Use top-level comments for general observations or praise
-- Only flag issues introduced by this PR, not pre-existing problems
-- Self-validate each finding: "Is this definitely a real issue?" If uncertain, discard it
-
-## Testing Instructions
-
-At the end of your review, add complete instructions to reproduce the added changes through the app interface. These instructions will be given to a tester so they can verify the changes. It should be a short descriptive text (not a step-by-step or a list) on how to navigate the app (what page, what action, what input, etc.) to see the changes.
+- Use inline comments at the relevant lines for specific issues.
+- Use a top-level comment for the summary, severity-tagged finding list, AGENTS.md compliance check, and the test-coverage assessment.
diff --git a/.claude/skills/adding-a-trigger/SKILL.md b/.claude/skills/adding-a-trigger/SKILL.md
deleted file mode 100644
index 7d8643b862..0000000000
--- a/.claude/skills/adding-a-trigger/SKILL.md
+++ /dev/null
@@ -1,267 +0,0 @@
----
-name: adding-a-trigger
-description: Checklist for adding a new TriggerCrud-based trigger type to Windmill (Azure, GCP, Kafka, etc.). Use when wiring a new trigger kind across backend, frontend, CLI, and capture infrastructure.
----
-
-# Skill: Adding a New Trigger Type
-
-Use this skill when adding a trigger kind that implements `TriggerCrud` (Kafka, GCP, Azure, MQTT, SQS, NATS, Postgres, Email…). For native triggers (Nextcloud, Google Drive — things wired through `windmill-native-triggers`), use the `native-trigger` skill instead.
-
-The goal of this doc is to enumerate every file that needs to change. Missing any one of them leads to silent regressions: sync drops the trigger, capture button does nothing, workspace forks lose it, sidebar counters undercount. Follow the checklist top-to-bottom — each section is independent enough to be validated on its own.
-
-Throughout this doc, substitute `{kind}` for the new trigger kind (`azure`, `kafka`, …), `{Kind}` for PascalCase (`Azure`, `Kafka`), `{KIND}` for SCREAMING (`AZURE`, `KAFKA`).
-
-## Reference implementations
-
-- **GCP** — closest analogue to Azure. Has push + pull, OIDC auth, ARM-like resource paths, capture handler. Grep for `gcp_trigger` / `GcpTrigger`.
-- **Kafka** — simpler (pull-only, streaming). Good for trivial integrations.
-- **Azure** — most recently added (2026). Shared-secret push auth, Event Grid namespaces + basic topics, ARM resource discovery, Namespace-pull data-plane. Grep for `azure_trigger` / `AzureTrigger`.
-
-## 1. Database migration
-
-Create a migration: `cargo sqlx migrate add -r add_{kind}_trigger` from `backend/`. Never write timestamps manually.
-
-The `up.sql` usually defines:
-- An optional enum type (e.g. `AZURE_MODE`) if the trigger has sub-kinds
-- The `{kind}_trigger` table with at minimum these columns (mirrored from kafka/gcp):
- - primary: `(workspace_id, path)`
- - `script_path`, `is_flow`, `enabled`, `mode`, `permissioned_as`, `edited_by`, `email`
- - `edited_at`, `error`, `server_id`, `last_server_ping`
- - `error_handler_path`, `error_handler_args jsonb`, `retry jsonb`
- - trigger-specific fields
-- Indexes on foreign keys + any frequently-filtered columns
-- Foreign key to `workspace`
-
-Down migration drops the table and any enum types.
-
-## 2. Backend crate (`windmill-trigger-{kind}`)
-
-Create a new crate under `backend/windmill-trigger-{kind}/` with:
-
-- `Cargo.toml`: features `enterprise`, `private` if EE, standard deps
-- `src/lib.rs`: `pub use mod_ee::*;` behind `#[cfg(all(feature = "enterprise", feature = "private"))]`
-- `src/mod_ee.rs`: core types + helpers
-- `src/handler_ee.rs`: `TriggerCrud` impl + route handlers
-- `src/listener_ee.rs`: (only if streaming/pull-based) `Listener` trait impl
-
-Required in `mod_ee.rs`:
-- `{Kind}Config` struct (persisted shape, `FromRow`)
-- `{Kind}ConfigRequest` struct (what API receives — usually similar to Config but with validation fields)
-- `{Kind}Trigger` unit struct (implements the traits)
-- `impl TriggerJobArgs for {Kind}Trigger` — sets `TRIGGER_KIND`, `Payload`, `v1_payload_fn`
-
-Required in `handler_ee.rs`:
-- `#[async_trait] impl TriggerCrud for {Kind}Trigger` with:
- - `type Trigger = Trigger<{Kind}Config>`
- - `type TriggerConfigRequest = {Kind}ConfigRequest`
- - `const ROUTE_PREFIX: &'static str = "/{kind}_triggers";`
- - `const TABLE_NAME`, `ADDITIONAL_SELECT_FIELDS`
- - `get_deployed_object`, `validate_config`, `create_trigger`, `update_trigger`, `delete_trigger`, `test_connection`
- - `additional_routes` (optional — mount extra endpoints for things like ARM resource listing, topic discovery)
-
-Register the crate in `backend/Cargo.toml` as a workspace member and as a dep of `windmill-api` behind the feature flag.
-
-## 3. Wire into `windmill-api` (feature-gated everywhere)
-
-**`backend/windmill-api/src/triggers/handler.rs`** — mount the trigger crate:
-```rust
-#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
-{
- use crate::triggers::{kind}::{Kind}Trigger;
- router = router.nest({Kind}Trigger::ROUTE_PREFIX, complete_trigger_routes({Kind}Trigger));
-}
-```
-
-**`backend/windmill-api/src/triggers/{kind}/mod.rs`** — re-export the crate:
-```rust
-pub use windmill_trigger_{kind}::*;
-```
-
-**`backend/windmill-api/src/lib.rs`** — if the trigger receives inbound pushes, add a webhook route:
-```rust
-.nest("/{kind}/w/{workspace_id}", {
- #[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
- { triggers::{kind}::handler_oss::{kind}_push_route_handler() }
- #[cfg(not(...))]
- { Router::new() }
-})
-```
-
-## 4. `TriggerKind` enum (`backend/windmill-types/src/triggers.rs`)
-
-Already has slots for most triggers but verify your variant exists:
-- Add `{Kind}` to the `TriggerKind` enum
-- Add match arm in `to_key()`
-- Add match arm in `from_str`
-- Add match arm in `JobTriggerKind` (if jobs need kind tagging)
-
-## 5. OpenAPI (`backend/windmill-api/openapi.yaml`)
-
-This file is huge and the single most-forgotten place. Add:
-
-- `/w/{workspace}/{kind}_triggers/create` + `/update/{path}` + `/delete/{path}` + `/get/{path}` + `/list` + `/exists/{path}` + `/setmode/{path}` + `/test` paths (mirror gcp section)
-- Any `additional_routes` your handler exposes (resource discovery, etc.)
-- Schemas: `{Kind}Trigger`, `{Kind}TriggerData`, `{Kind}Mode` (if enum), `{Kind}DeliveryConfig`, helper request/response types
-- Add `{kind}` to `CaptureTriggerKind` enum
-- Add `{kind}_used: boolean` to the `UsedTriggers` response schema
-
-Regenerate frontend client: `npm run generate-backend-client` from `frontend/`.
-
-## 6. `UsedTriggers` + workspace export
-
-**`backend/windmill-api-workspaces/src/workspaces.rs`** — add `{kind}_used: bool` to the `UsedTriggers` struct and add an `EXISTS(SELECT 1 FROM {kind}_trigger …)` to the `get_used_triggers` query.
-
-**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON). The block re-uses the `trigger_ignore_keys` variable so the new kind automatically participates in fork-export stripping (`mode` field is omitted when the source workspace is a fork — keeps fork→parent merges from flipping the parent's enabled state).
-
-**Fork cloning (`clone_triggers_and_schedules` in workspaces.rs)** — add an `INSERT INTO {kind}_trigger ... SELECT ...` block that copies all rows from the parent workspace, forcing `mode = 'disabled'::TRIGGER_MODE`. Always runs at fork creation; forgetting this means users can't carry `{kind}` triggers into their forks.
-
-## 6.5 Hardcoded trigger-kind arrays (silent-failure hotspots)
-
-Several files keep **hardcoded arrays** of trigger kind strings. Miss one and ACL checks / user offboarding / trash drop your kind:
-
-- **`backend/windmill-api-groups/src/granular_acls.rs`** — `KINDS: [&str; N]`. **Increment N** (the compile error is cryptic otherwise). Controls which kinds accept granular ACL operations.
-- **`backend/windmill-api-users/src/users.rs`** (`extra_perms_tables`) — which tables get `extra_perms` entries cleaned when a user is deleted.
-- **`backend/windmill-api/src/offboarding.rs`** — three separate arrays (enumeration, fork-copy, and delete paths). **All three** need the new kind.
-- **`backend/windmill-api/src/trash.rs`** — `valid_tables` for the trash / restore API.
-- **`backend/windmill-git-sync/src/lib.rs`** — add a test assertion for `DeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger"` (the `get_kind` match arm itself lives in the enum impl — already required by the Rust compiler).
-- **`backend/windmill-api-auth/src/scopes.rs`** — add the `{Kind}Triggers` variant to `ScopeDomain` enum + `as_str` match + `from_str` match. Required for the OAuth/token system to recognise `{kind}_triggers:read|write` scopes.
-- **`backend/windmill-api/src/token.rs`** (`build_trigger_scope_domains` → `TRIGGER_DOMAINS`) — add `("{kind}_triggers", "{Kind display name}")` so the CreateToken UI's scope selector surfaces the `read` / `write` checkboxes.
-
-**OpenAPI enums** to extend (do NOT forget — generated client will allow it but server rejects as 400):
-- `CaptureTriggerKind` enum
-- Three `kind` enums under `/w/{workspace}/acls/{get,add,remove}/{kind}/{path}` (yes, same list repeated three times)
-
-After editing any of these, run a full `cargo check` with your feature flag + `gcp_trigger` + other core flags — the `KINDS: [&str; N]` length mismatch only surfaces when the crate compiles.
-
-## 7. Capture infrastructure (`backend/windmill-api/src/capture.rs`)
-
-If the trigger supports push delivery, it also needs a capture endpoint so users can test it:
-
-- `{Kind}TriggerConfig` struct (gated by feature flags)
-- `TriggerConfig::{Kind}` variant
-- `set_{kind}_trigger_config` function (creates the subscription/equivalent pointing at the capture URL — use your `manage_{kind}_subscription` helper with `trigger_mode=false`)
-- Both real + no-op versions behind feature gates
-- `TriggerKind::{Kind} => set_{kind}_trigger_config(...)` arm in `set_config`
-- `{kind}_payload` async handler — validates auth (if any), processes payload, calls `insert_capture_payload`
-- Route: `.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload))` inside `workspaced_unauthed_service` — and expand the surrounding `#[cfg(any(...))]` to include your feature flag
-
-## 8. CLI (`cli/`) — easy to miss, breaks sync silently
-
-Check all of these:
-
-**`cli/src/types.ts`:**
-- Add `"{kind}"` to `TRIGGER_TYPES` array
-- Add `"{kind}_trigger"` to `getTypeStrFromPath` return union
-- Add match case in `getTypeStrFromPath`'s `typeEnding ===` chain
-- Add `pushTrigger("{kind}", ...)` branch in `pushObj`
-
-**`cli/src/commands/trigger/trigger.ts`:**
-- Import `{Kind}Trigger` type
-- Add `{kind}: {Kind}Trigger` to the `Trigger` type map
-- Add `{kind}: wmill.get{Kind}Trigger`, `update{Kind}Trigger`, `create{Kind}Trigger` to each function map
-- Add `{kind}: { ... }` template to `triggerTemplates`
-- Add `list{Kind}Triggers` call + spread in the `list` aggregation
-- Update `--kind` option descriptions to mention the new kind
-
-**`cli/src/commands/sync/sync.ts`:**
-- Add `path.endsWith(".{kind}_trigger" + ext)` in the file-type filter
-- Add `typ == "{kind}_trigger"` in `getTypeOrder`
-- Add `"{kind}_trigger"` to the delete-suffix regex (~line 3092)
-- Add a `case "{kind}_trigger"` in the delete switch
-
-**`cli/src/guidance/skills.ts`** — **DO NOT EDIT DIRECTLY**. It's auto-generated by `system_prompts/generate.py`. Instead:
-- Edit `system_prompts/utils.py` → append `('{Kind}Trigger', '{kind}_trigger')` to the `SCHEMA_MAPPINGS['triggers']` list (this is the master list — the one in `generate.py` is duplicated and `utils.py` wins)
-- Then run `python3 system_prompts/generate.py` — it regenerates `cli/src/guidance/skills.ts` with the schema extracted from `backend/windmill-api/openapi.yaml`
-- Commit the regenerated file
-
-## 9. Frontend — editor + drawer
-
-Under `frontend/src/lib/components/triggers/{kind}/`:
-
-- `{Kind}TriggerPanel.svelte` — the tile shown in the triggers listing
-- `{Kind}TriggerEditor.svelte` — outer drawer wrapper
-- `{Kind}TriggerEditorInner.svelte` — state + business logic; must expose:
- - `openEdit(path, isFlow, defaultValues?)` method
- - `isEditor` prop, `onConfigChange` + `onCaptureConfigChange` callbacks
- - `get{Kind}Config()` + `get{Kind}CaptureConfig()` helpers
- - `captureConfig = $derived.by(untrack(() => isEditor) ? get{Kind}CaptureConfig : () => ({}))`
- - `$effect(() => { const args = [captureConfig, isValid] as const; untrack(() => onCaptureConfigChange?.(...args)) })`
-- `{Kind}TriggerEditorConfigSection.svelte` — form fields; use design-system components (`TextInput`, `Select`, `Toggle`, `ToggleButtonGroup`), never raw ` `
-- `{Kind}Capture.svelte` — capture panel; wraps `CaptureSection` with `captureType="{kind}"`
-- `utils.ts` — `requestBody` builders and any trigger-type-specific helpers
-
-## 10. Frontend — global integration
-
-Easy to miss:
-
-- **`frontend/src/lib/components/triggers.ts`** — add `'{kind}'` to the `TriggerKind` union
-- **`frontend/src/lib/components/triggers/CaptureWrapper.svelte`**:
- - Import `{Kind}Capture`
- - Add to `isStreamingCapture()` array (streaming = pull-style; push-style is typically `false`)
- - Add `{:else if captureType === '{kind}'}` branch with the `<{Kind}Capture>` render
-- **`frontend/src/lib/components/sidebar/SidebarContent.svelte`** — import the icon, add the nav entry
-- **`frontend/src/lib/components/sidebar/OperatorMenu.svelte`** — add the operator-mode entry
-- **`frontend/src/routes/(root)/(logged)/+layout.svelte`** — destructure `{kind}_used` from `/get_used_triggers` response, push `'{kind}'` into `usedKinds`
-- **`frontend/src/lib/components/search/GlobalSearchModal.svelte`** — import icon, add "Go to {Kind} ..." entry
-- **`frontend/src/lib/components/offboarding-utils.ts`** — add mappings `{kind}_trigger: '{kind}_triggers'` and `{kind}_trigger: '{kind} trigger'`
-- **`frontend/src/lib/components/icons/{Kind}Icon.svelte`** — single-path SVG, `fill={color ?? 'currentColor'}`, `size` prop default 16 (match existing icons — don't hardcode colors, don't use `width`/`height` props)
-- **`frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte`** — listing page (mirror `gcp_triggers/+page.svelte` for push+pull, `kafka_triggers` for pure streaming)
-- **`frontend/src/lib/components/CompareWorkspaces.svelte`** — workspace fork / compare tool. Needs: service import, editor import, `{kind}Editor` `$state`, `case '{kind}'` in `openTriggerDetails()`, entry in `triggerServices` object (list/delete/normalize), and `<{Kind}TriggerEditor bind:this={{kind}Editor} />` in the template
-
-## 10.5 AI system prompts (`system_prompts/`)
-
-- **`system_prompts/utils.py`** — append `('{Kind}Trigger', '{kind}_trigger')` to `SCHEMA_MAPPINGS['triggers']` (master list used by code generation + CLI skills)
-- **`system_prompts/generate.py`** — also has a duplicated `schema_types` list (~line 903) for the AI `triggers` skill content. Add `('{Kind}Trigger', '{kind}_trigger')` there too
-- **`system_prompts/generate.py`** `schema_names` (~line 1192) — add `'{Kind}Trigger'` (add `'New{Kind}Trigger'` only if the OpenAPI declares one; GCP and Azure don't)
-- Run `python3 system_prompts/generate.py` — this rewrites `cli/src/guidance/skills.ts` and all `auto-generated/` docs. Commit the regenerated files
-
-## 11. Validation
-
-Run all of these before declaring done:
-
-```bash
-# Backend
-cd backend
-cargo check --features enterprise,{kind}_trigger,private # minimal
-cargo check --features enterprise,azure_trigger,private,gcp_trigger,http_trigger,mqtt_trigger,postgres_trigger,sqs_trigger,kafka,nats,smtp,websocket # full
-
-# SQLx offline data (never run `cargo sqlx prepare` directly — use the wrapper)
-./update_sqlx.sh
-
-# Frontend
-cd frontend
-npm run generate-backend-client
-npm run check:fast
-```
-
-Smoke test in the UI: create a trigger, save, check it appears in sidebar + search, delete, re-create via CLI `wmill sync`.
-
-## 12. Common pitfalls
-
-- **Forgetting feature gates in `workspaced_unauthed_service()`** — the surrounding `#[cfg(any(...))]` expression must include your feature flag, not just the inner `#[cfg]` on the route
-- **`.route(path, ...).route(path, ...)` with same path and different methods** — older axum replaced; use `.route(path, post(h1).options(h2))` to chain methods on the same `MethodRouter`
-- **`on:event` directives** — legacy Svelte 4, no-op in runes mode. Use callback props (`onSelected`, `onConfigChange`)
-- **`$bindable(default_value)` on optional props** — banned by project CLAUDE.md. Use `$bindable()` + `$derived(prop ?? default)` instead
-- **CORS layer intercepting OPTIONS** — tower-http CorsLayer short-circuits OPTIONS before reaching your handler. For server-to-server webhook endpoints, drop the CORS layer entirely (CORS is browser-only)
-- **DeliveryAttributeMappings / custom headers for auth** — prefer HMAC or sha256-hashed shared secrets over opaque JWTs when the provider doesn't support signed tokens natively. Store only the hash; regenerate secret on every save
-- **ARM / API resource-listing cascades** — if the trigger's resource type is deep (Azure: subscription → RG → namespace → topic), offer dropdowns in the UI populated from the provider's APIs using the user's credential resource
-- **Clearing stale selections on dependency change** — when a dropdown's underlying data reloads (e.g., user changes SP or edition), clear selections that no longer match the new list
-- **Workspace-scoped tag compatibility** — if the trigger has tags, verify forked workspaces handle them (see commit `0773b5bc85` for a historical fix)
-
-## 13. EE file split
-
-If the trigger is enterprise-only, the code lives in `windmill-ee-private__worktrees/.../windmill-trigger-{kind}/src/*_ee.rs` and is symlinked into the OSS tree. The `windmill-ee-private__worktrees/` directory holds the real files; changes propagate via symlinks. See `docs/enterprise.md` for the workflow.
-
-## 14. Final checklist before PR
-
-- [ ] Migration up/down tested (revert + re-apply)
-- [ ] `./update_sqlx.sh` committed the updated `.sqlx/` offline data
-- [ ] `cargo check` passes with your feature flag + with all trigger features
-- [ ] `npm run check:fast` passes
-- [ ] Trigger visible in sidebar with correct icon weight (not oversized/colored — use `currentColor`)
-- [ ] Create, edit, delete flow all work in the UI
-- [ ] Capture button works (if push-capable)
-- [ ] Trigger appears in `/get_used_triggers` → sidebar pulse
-- [ ] `wmill sync pull` + `wmill sync push` both round-trip the trigger
-- [ ] `wmill trigger list` includes it
-- [ ] OpenAPI schemas are complete (no `null` in generated types)
diff --git a/.claude/skills/adding-a-trigger/SKILL.md b/.claude/skills/adding-a-trigger/SKILL.md
new file mode 120000
index 0000000000..a2060ad897
--- /dev/null
+++ b/.claude/skills/adding-a-trigger/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/adding-a-trigger/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md
deleted file mode 100644
index 2094dbab06..0000000000
--- a/.claude/skills/commit/SKILL.md
+++ /dev/null
@@ -1,60 +0,0 @@
----
-name: commit
-user_invocable: true
-description: Create a git commit with conventional commit format. MUST use anytime you want to commit changes.
----
-
-# Git Commit Skill
-
-Create a focused, single-line commit following conventional commit conventions.
-
-## Instructions
-
-1. **Analyze changes**: Run `git status` and `git diff` to understand what was modified
-2. **Stage only modified files**: Add files individually by name. NEVER use `git add -A` or `git add .`
-3. **Write commit message**: Follow the conventional commit format as a single line
-
-## Conventional Commit Format
-
-```
-:
-```
-
-### Types
-- `feat`: New feature or capability
-- `fix`: Bug fix
-- `refactor`: Code change that neither fixes a bug nor adds a feature
-- `docs`: Documentation only changes
-- `style`: Formatting, missing semicolons, etc (no code change)
-- `test`: Adding or correcting tests
-- `chore`: Maintenance tasks, dependency updates, etc
-- `perf`: Performance improvement
-
-### Rules
-- Message MUST be a single line (no multi-line messages)
-- Description should be lowercase, imperative mood ("add" not "added")
-- No period at the end
-- Keep under 72 characters total
-
-### Examples
-```
-feat: add token usage tracking for AI providers
-fix: resolve null pointer in job executor
-refactor: extract common validation logic
-docs: update API endpoint documentation
-chore: upgrade sqlx to 0.7
-```
-
-## Execution Steps
-
-1. Run `git status` to see all changes
-2. Run `git diff` to understand the changes in detail
-3. Run `git log --oneline -5` to see recent commit style
-4. Stage ONLY the modified/relevant files: `git add ...`
-5. Create the commit with conventional format:
- ```bash
- git commit -m ":
-
- Co-Authored-By: Claude Opus 4.5 "
- ```
-6. Run `git status` to verify the commit succeeded
diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md
new file mode 120000
index 0000000000..11493a3d1e
--- /dev/null
+++ b/.claude/skills/commit/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/commit/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md
deleted file mode 100644
index 0399ad7294..0000000000
--- a/.claude/skills/local-review/SKILL.md
+++ /dev/null
@@ -1,69 +0,0 @@
----
-name: local-review
-user_invocable: true
-description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code.
----
-
-# Local Code Review Skill
-
-Run the same review locally that the GitHub Claude Auto Review action runs on PRs. The shared review instructions live in `.claude/review-prompt.md` — read that file first and follow its instructions.
-
-## Execution Steps
-
-1. **Read `.claude/review-prompt.md`** for the review criteria and focus areas
-
-2. **Determine the PR scope**:
- - If an argument is provided, use it as the PR number or branch
- - Otherwise, detect from the current branch vs main
- - Run `gh pr view` if a PR exists, or use `git diff main...HEAD`
-
-3. **Get the diff and metadata**:
- - `gh pr diff` or `git diff main...HEAD` for the full diff
- - `gh pr view` or `git log main..HEAD --oneline` for context
-
-4. **Read changed files** where the diff alone is insufficient to understand context
-
-5. **Apply the review instructions from `.claude/review-prompt.md`**
-
-6. **Self-validate each finding**: Before reporting, ask yourself:
- - "Is this definitely a real issue, not a false positive?"
- - "Would a senior engineer flag this in review?"
- - If the answer to either is no, discard the finding
-
-7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag)
-
-## Output Format
-
-```
-## Code review
-
-Found N issues:
-
-1. ()
-
-
-2. ()
-
-```
-
-If no issues are found:
-
-```
-## Code review
-
-No issues found. Checked for bugs and CLAUDE.md compliance.
-```
-
-## Posting Comments (--comment flag)
-
-If the user passes `--comment`, post findings as inline PR comments using:
-
-```bash
-gh pr review --comment --body ""
-```
-
-Or for inline comments on specific lines:
-
-```bash
-gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]"
-```
diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md
new file mode 120000
index 0000000000..8072aff10d
--- /dev/null
+++ b/.claude/skills/local-review/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/local-review/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/native-trigger/SKILL.md b/.claude/skills/native-trigger/SKILL.md
deleted file mode 100644
index ae38f70b32..0000000000
--- a/.claude/skills/native-trigger/SKILL.md
+++ /dev/null
@@ -1,793 +0,0 @@
----
-name: native-trigger
-description: Guidance for adding native trigger services to Windmill. Use when implementing or modifying native trigger integrations across the backend and frontend.
----
-
-# Skill: Adding Native Trigger Services
-
-This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
-
-## Architecture Overview
-
-The native trigger system consists of:
-
-1. **Database Layer** - PostgreSQL tables and enum types
-2. **Backend Rust Implementation** - Core trait, handlers, and service modules in the `windmill-native-triggers` crate
-3. **Frontend Svelte Components** - Configuration forms and UI components
-
-### Key Files
-
-| Component | Path |
-|-----------|------|
-| Core module with `External` trait | `backend/windmill-native-triggers/src/lib.rs` |
-| Generic CRUD handlers | `backend/windmill-native-triggers/src/handler.rs` |
-| Background sync logic | `backend/windmill-native-triggers/src/sync.rs` |
-| OAuth/workspace integration | `backend/windmill-native-triggers/src/workspace_integrations.rs` |
-| Re-export shim (windmill-api) | `backend/windmill-api/src/native_triggers/mod.rs` |
-| TriggerKind enum | `backend/windmill-common/src/triggers.rs` |
-| JobTriggerKind enum | `backend/windmill-common/src/jobs.rs` |
-| Frontend service registry | `frontend/src/lib/components/triggers/native/utils.ts` |
-| Frontend trigger utilities | `frontend/src/lib/components/triggers/utils.ts` |
-| Trigger badges (icons + counts) | `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte` |
-| Workspace integrations UI | `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte` |
-| OAuth config form component | `frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte` |
-| OpenAPI spec | `backend/windmill-api/openapi.yaml` |
-| Reference: Nextcloud module | `backend/windmill-native-triggers/src/nextcloud/` |
-| Reference: Google module | `backend/windmill-native-triggers/src/google/` |
-
-### Crate Structure
-
-The native trigger code lives in the `windmill-native-triggers` crate (`backend/windmill-native-triggers/`). The `windmill-api` crate re-exports everything via a shim:
-
-```rust
-// backend/windmill-api/src/native_triggers/mod.rs
-pub use windmill_native_triggers::*;
-```
-
-All new service modules go in `backend/windmill-native-triggers/src/`.
-
----
-
-## Core Concepts
-
-### The `External` Trait
-
-Every native trigger service implements the `External` trait defined in `lib.rs`:
-
-```rust
-#[async_trait]
-pub trait External: Send + Sync + 'static {
- // Associated types:
- type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync;
- type TriggerData: Debug + Serialize + Send + Sync;
- type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync;
- type CreateResponse: DeserializeOwned + Send + Sync;
-
- // Constants:
- const SUPPORT_WEBHOOK: bool;
- const SERVICE_NAME: ServiceName;
- const DISPLAY_NAME: &'static str;
- const TOKEN_ENDPOINT: &'static str;
- const REFRESH_ENDPOINT: &'static str;
- const AUTH_ENDPOINT: &'static str;
-
- // Required methods:
- async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result;
- async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result;
- async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result;
- async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>;
- async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result;
- async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors);
- fn external_id_and_metadata_from_response(&self, resp) -> (String, Option);
-
- // Methods with defaults:
- async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result;
- fn service_config_from_create_response(&self, data, resp) -> Option;
- fn additional_routes(&self) -> axum::Router;
- async fn http_client_request(&self, url, method, workspace_id, tx, db, headers, body) -> Result;
-}
-```
-
-Key design points:
-- **`update()` returns `serde_json::Value`** - the resolved service_config to store. Each service is responsible for building the final config.
-- **`maintain_triggers()`** - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels).
-- **No `list_all()` in the trait** - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies.
-- **No `get_external_id_from_trigger_data()` or `extract_service_config_from_trigger_data()`** - removed in favor of the `maintain_triggers` pattern.
-
-### Create Lifecycle: Two Paths
-
-The `create_native_trigger` handler in `handler.rs` supports two creation flows, controlled by `service_config_from_create_response()`:
-
-**Path A: Short (Google pattern)** - `service_config_from_create_response()` returns `Some(config)`:
-1. `create()` registers on external service
-2. `external_id_and_metadata_from_response()` extracts the ID
-3. `service_config_from_create_response()` builds the config directly from input data + response metadata
-4. Stores trigger in DB -- done, no extra round-trip
-
-Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL).
-
-**Path B: Long (Nextcloud pattern)** - `service_config_from_create_response()` returns `None` (default):
-1. `create()` registers on external service (webhook URL has no external_id yet)
-2. `external_id_and_metadata_from_response()` extracts the ID
-3. `update()` is called to fix the webhook URL with the now-known external_id
-4. `update()` returns the resolved service_config
-5. Stores trigger in DB
-
-Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation.
-
-### OAuth Token Storage (Three-Table Pattern)
-
-OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly:
-
-| Table | What's Stored |
-|-------|---------------|
-| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable |
-| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column |
-| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` |
-
-The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct:
-```rust
-pub struct OAuthConfig {
- pub base_url: String,
- pub access_token: String, // decrypted from variable
- pub refresh_token: Option, // from account table
- pub client_id: String, // from oauth_data or instance settings
- pub client_secret: String, // from oauth_data or instance settings
-}
-```
-
-Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations.
-
-### URL Resolution
-
-The `resolve_endpoint()` helper handles both absolute and relative OAuth URLs:
-
-```rust
-pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String {
- if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
- endpoint.to_string() // Google: absolute URLs
- } else {
- format!("{}{}", base_url, endpoint) // Nextcloud: relative paths
- }
-}
-```
-
-### ServiceName Methods
-
-`ServiceName` is the central registry enum. Each variant must implement these match arms:
-
-| Method | Purpose |
-|--------|---------|
-| `as_str()` | Lowercase identifier (e.g., `"google"`) |
-| `as_trigger_kind()` | Maps to `TriggerKind` enum |
-| `as_job_trigger_kind()` | Maps to `JobTriggerKind` enum |
-| `token_endpoint()` | OAuth token endpoint (relative or absolute) |
-| `auth_endpoint()` | OAuth authorization endpoint |
-| `oauth_scopes()` | Space-separated OAuth scopes |
-| `resource_type()` | Resource type for token storage (e.g., `"gworkspace"`) |
-| `extra_auth_params()` | Extra OAuth params (e.g., Google needs `access_type=offline`, `prompt=consent`) |
-| `integration_service()` | Maps to the workspace integration service (usually `*self`) |
-| `TryFrom` | Parse from string |
-| `Display` | Delegates to `as_str()` |
-
----
-
-## Step-by-Step Implementation Guide
-
-### Step 1: Database Migration
-
-Create a new migration file: `backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql`
-
-```sql
--- Add the service to the native_trigger_service enum
-ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice';
-
--- Add to TRIGGER_KIND enum (used for trigger tracking)
-ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice';
-
--- Add to job_trigger_kind enum (used for job tracking)
-ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice';
-```
-
-Also create the corresponding down migration.
-
-### Step 2: Update windmill-common Enums
-
-#### `backend/windmill-common/src/triggers.rs`
-
-Add variant to `TriggerKind` enum, and update `to_key()` and `fmt()` implementations.
-
-#### `backend/windmill-common/src/jobs.rs`
-
-Add variant to `JobTriggerKind` enum and update the `Display` implementation.
-
-### Step 3: Backend Service Module
-
-Create a new directory: `backend/windmill-native-triggers/src/newservice/`
-
-#### `mod.rs` - Type Definitions
-
-```rust
-use serde::{Deserialize, Serialize};
-
-pub mod external;
-// pub mod routes; // Only if you need additional service-specific routes
-
-/// OAuth data deserialized from the three-table pattern.
-/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations.
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct NewServiceOAuthData {
- pub base_url: String, // from workspace_integrations.oauth_data
- pub access_token: String, // decrypted from variable table
- pub refresh_token: Option, // from account table
- // Note: client_id and client_secret are in OAuthConfig, not here
- // unless the service needs them at runtime for API calls
-}
-
-/// Configuration provided by user when creating/updating a trigger.
-/// Stored as JSON in native_trigger.service_config.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct NewServiceConfig {
- // Service-specific configuration fields
- pub folder_path: String,
- pub file_filter: Option,
-}
-
-/// Data retrieved from the external service about a trigger.
-/// Returned by the get() method and shown in the UI.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct NewServiceTriggerData {
- pub folder_path: String,
- pub file_filter: Option,
- // Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)]
-}
-
-/// Response from external service when creating a trigger/webhook.
-#[derive(Debug, Deserialize)]
-pub struct CreateTriggerResponse {
- pub id: String,
-}
-
-/// Handler struct (stateless, used for routing)
-#[derive(Copy, Clone)]
-pub struct NewService;
-```
-
-#### `external.rs` - External Trait Implementation
-
-```rust
-use async_trait::async_trait;
-use reqwest::Method;
-use sqlx::PgConnection;
-use std::collections::HashMap;
-use windmill_common::{
- error::{Error, Result},
- BASE_URL, DB,
-};
-
-use crate::{
- generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName,
- sync::{SyncError, TriggerSyncInfo},
-};
-use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse};
-
-#[async_trait]
-impl External for NewService {
- type ServiceConfig = NewServiceConfig;
- type TriggerData = NewServiceTriggerData;
- type OAuthData = NewServiceOAuthData;
- type CreateResponse = CreateTriggerResponse;
-
- const SERVICE_NAME: ServiceName = ServiceName::NewService;
- const DISPLAY_NAME: &'static str = "New Service";
- const SUPPORT_WEBHOOK: bool = true;
- const TOKEN_ENDPOINT: &'static str = "/oauth/token";
- const REFRESH_ENDPOINT: &'static str = "/oauth/token";
- const AUTH_ENDPOINT: &'static str = "/oauth/authorize";
-
- async fn create(
- &self,
- w_id: &str,
- oauth_data: &Self::OAuthData,
- webhook_token: &str,
- data: &NativeTriggerData,
- db: &DB,
- tx: &mut PgConnection,
- ) -> Result {
- let base_url = &*BASE_URL.read().await;
-
- // external_id is None during create (we get it from the response)
- let webhook_url = generate_webhook_service_url(
- base_url, w_id, &data.script_path, data.is_flow,
- None, Self::SERVICE_NAME, webhook_token,
- );
-
- let url = format!("{}/api/webhooks/create", oauth_data.base_url);
- let payload = serde_json::json!({
- "callback_url": webhook_url,
- "folder_path": data.service_config.folder_path,
- });
-
- let response: CreateTriggerResponse = self
- .http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload))
- .await?;
-
- Ok(response)
- }
-
- /// Update returns the resolved service_config as JSON.
- /// For services using the update+get pattern, call self.get() and serialize.
- async fn update(
- &self,
- w_id: &str,
- oauth_data: &Self::OAuthData,
- external_id: &str,
- webhook_token: &str,
- data: &NativeTriggerData,
- db: &DB,
- tx: &mut PgConnection,
- ) -> Result {
- let base_url = &*BASE_URL.read().await;
-
- let webhook_url = generate_webhook_service_url(
- base_url, w_id, &data.script_path, data.is_flow,
- Some(external_id), Self::SERVICE_NAME, webhook_token,
- );
-
- let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
- let payload = serde_json::json!({
- "callback_url": webhook_url,
- "folder_path": data.service_config.folder_path,
- });
-
- let _: serde_json::Value = self
- .http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload))
- .await?;
-
- // Fetch back the updated state to get the resolved config
- let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?;
- serde_json::to_value(&trigger_data)
- .map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e)))
- }
-
- async fn get(
- &self,
- w_id: &str,
- oauth_data: &Self::OAuthData,
- external_id: &str,
- db: &DB,
- tx: &mut PgConnection,
- ) -> Result {
- let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
- self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await
- }
-
- async fn delete(
- &self,
- w_id: &str,
- oauth_data: &Self::OAuthData,
- external_id: &str,
- db: &DB,
- tx: &mut PgConnection,
- ) -> Result<()> {
- let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
- let _: serde_json::Value = self
- .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None)
- .await
- .or_else(|e| match &e {
- Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
- _ => Err(e),
- })?;
- Ok(())
- }
-
- async fn exists(
- &self,
- w_id: &str,
- oauth_data: &Self::OAuthData,
- external_id: &str,
- db: &DB,
- tx: &mut PgConnection,
- ) -> Result {
- match self.get(w_id, oauth_data, external_id, db, tx).await {
- Ok(_) => Ok(true),
- Err(Error::NotFound(_)) => Ok(false),
- Err(e) => Err(e),
- }
- }
-
- /// Background maintenance. Choose the right pattern for your service:
- /// - For services with queryable external state: use reconcile_with_external_state()
- /// - For channel-based services with expiration: implement renewal logic
- async fn maintain_triggers(
- &self,
- db: &DB,
- workspace_id: &str,
- triggers: &[NativeTrigger],
- oauth_data: &Self::OAuthData,
- synced: &mut Vec,
- errors: &mut Vec,
- ) {
- // Option A: Reconcile with external state (Nextcloud pattern)
- // Fetch all triggers from external service and compare with DB
- let external_triggers = match self.list_all(workspace_id, oauth_data, db).await {
- Ok(triggers) => triggers,
- Err(e) => {
- errors.push(SyncError {
- resource_path: format!("workspace:{}", workspace_id),
- error_message: format!("Failed to list triggers: {}", e),
- error_type: "api_error".to_string(),
- });
- return;
- }
- };
-
- // Convert to (external_id, config_json) pairs
- let external_pairs: Vec<(String, serde_json::Value)> = external_triggers
- .into_iter()
- .map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default()))
- .collect();
-
- crate::sync::reconcile_with_external_state(
- db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
- ).await;
- }
-
- fn external_id_and_metadata_from_response(
- &self,
- resp: &Self::CreateResponse,
- ) -> (String, Option) {
- (resp.id.clone(), None)
- }
-
- // service_config_from_create_response: NOT overridden (returns None).
- // This means the handler uses the update+get pattern after create.
- // Override and return Some(...) to skip the update+get cycle (Google pattern).
-}
-
-impl NewService {
- /// Private helper to list all triggers from the external service.
- async fn list_all(
- &self,
- w_id: &str,
- oauth_data: &::OAuthData,
- db: &DB,
- ) -> Result::TriggerData>> {
- // Implementation depends on the external service's API
- todo!()
- }
-}
-```
-
-### Step 4: Update lib.rs Registry
-
-In `backend/windmill-native-triggers/src/lib.rs`:
-
-```rust
-// Service modules - add new services here:
-#[cfg(feature = "native_trigger")]
-pub mod newservice; // <-- Add this
-
-// ServiceName enum - add variant:
-pub enum ServiceName {
- Nextcloud,
- Google,
- NewService, // <-- Add this
-}
-
-// Then add match arms in ALL ServiceName methods:
-// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(),
-// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(),
-// integration_service(), TryFrom, Display
-```
-
-### Step 5: Update handler.rs Routes
-
-In `backend/windmill-native-triggers/src/handler.rs`:
-
-```rust
-pub fn generate_native_trigger_routers() -> Router {
- // ...
- #[cfg(feature = "native_trigger")]
- {
- use crate::newservice::NewService;
- return router
- .nest("/nextcloud", service_routes(NextCloud))
- .nest("/google", service_routes(Google))
- .nest("/newservice", service_routes(NewService)); // <-- Add this
- }
- // ...
-}
-```
-
-### Step 6: Update sync.rs
-
-In `backend/windmill-native-triggers/src/sync.rs`:
-
-```rust
-pub async fn sync_all_triggers(db: &DB) -> Result {
- // ...
- #[cfg(feature = "native_trigger")]
- {
- use crate::newservice::NewService;
-
- // ... existing service syncs ...
-
- // New service sync
- let (service_name, result) = sync_service_triggers(db, NewService).await;
- total_synced += result.synced_triggers.len();
- total_errors += result.errors.len();
- service_results.insert(service_name, result);
- }
- // ...
-}
-```
-
-### Step 7: Frontend Service Registry
-
-In `frontend/src/lib/components/triggers/native/utils.ts`:
-
-Add to `NATIVE_TRIGGER_SERVICES`, `getTriggerIconName()`, and `getServiceIcon()`.
-
-### Step 8: Frontend Trigger Form Component
-
-Create: `frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte`
-
-### Step 9: Frontend Icon Component
-
-Create: `frontend/src/lib/components/icons/NewServiceIcon.svelte`
-
-### Step 10: Update NativeTriggerEditor
-
-Check `frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte` to ensure it dynamically loads form components based on service name.
-
-### Step 11: Workspace Integration UI
-
-Add your service to the `supportedServices` map in `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte`:
-
-```typescript
-const supportedServices: Record = {
- // ... existing services ...
- newservice: {
- name: 'newservice',
- displayName: 'New Service',
- description: 'Connect to New Service for triggers',
- icon: NewServiceIcon,
- docsUrl: 'https://www.windmill.dev/docs/integrations/newservice',
- requiresBaseUrl: false, // false for cloud services, true for self-hosted
- setupInstructions: [
- 'Step 1: Create an OAuth app on the service',
- 'Step 2: Configure the redirect URI shown below',
- 'Step 3: Enter the client credentials below'
- ]
- }
-}
-```
-
-### Step 12: Update `frontend/src/lib/components/triggers/utils.ts`
-
-Update ALL of these maps/functions:
-1. `triggerIconMap` - import and add icon
-2. `triggerDisplayNamesMap` - add display name
-3. `triggerTypeOrder` in `sortTriggers()` - add type
-4. `getLightConfig()` - add case for your service
-5. `getTriggerLabel()` - add case for your service
-6. `jobTriggerKinds` - add to array
-7. `countPropertyMap` - add count property
-8. `triggerSaveFunctions` - add save function
-
-### Step 13: Update TriggersBadge Component
-
-In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`:
-
-1. Import the icon
-2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`)
-3. Add to the `allTypes` array
-
-### Step 14: Update TriggersWrapper.svelte
-
-In `frontend/src/lib/components/triggers/TriggersWrapper.svelte`:
-
-Add a `{:else if selectedTrigger.type === 'yourservice'}` case that renders `` with the same props pattern as the existing native trigger cases (e.g., `nextcloud`).
-
-### Step 15: Update AddTriggersButton.svelte
-
-In `frontend/src/lib/components/triggers/AddTriggersButton.svelte`:
-
-1. Add `yourserviceAvailable` state variable
-2. Add `setYourserviceState()` async function using `isServiceAvailable('yourservice', $workspaceStore!)`
-3. Call it at module level
-4. Add a dropdown entry to `addTriggerItems` with `hidden: !yourserviceAvailable`
-
-### Step 16: Update TriggersEditor.svelte Delete Handling
-
-In `frontend/src/lib/components/triggers/TriggersEditor.svelte`:
-
-Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete.
-
-### Step 17: Update `getUsedTriggers` for Sidebar Visibility
-
-The sidebar (`frontend/src/lib/components/sidebar/SidebarContent.svelte`) shows native-trigger links only if `$usedTriggerKinds` includes the service — without this, your trigger page will never appear in the nav bar even when triggers exist.
-
-1. **Backend** — add `{service}_used: bool` to the `UsedTriggers` struct and SELECT in `backend/windmill-api-workspaces/src/workspaces.rs::get_used_triggers()`:
- ```rust
- EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = '{service}'::native_trigger_service) AS "{service}_used!"
- ```
-2. **OpenAPI** — add `{service}_used: boolean` to the response schema for `GET /w/{workspace}/workspaces/used_triggers` (under both `properties` and `required`).
-3. **Layout** — in `frontend/src/routes/(root)/(logged)/+layout.svelte::loadUsedTriggerKinds()`, destructure `{service}_used` and push `'{service}'` to `usedKinds`.
-
-### Step 18: Update OpenAPI Spec and Regenerate Types
-
-Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then:
-
-```bash
-cd frontend && npm run generate-backend-client
-```
-
----
-
-## Special Patterns
-
-### Unified Service with `trigger_type` (Google Pattern)
-
-When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single `ServiceName` variant with a discriminator field:
-
-```rust
-pub enum GoogleTriggerType { Drive, Calendar }
-
-pub struct GoogleServiceConfig {
- pub trigger_type: GoogleTriggerType,
- // Drive-specific fields (only used when trigger_type = Drive)
- pub resource_id: Option,
- pub resource_name: Option,
- // Calendar-specific fields (only used when trigger_type = Calendar)
- pub calendar_id: Option,
- pub calendar_name: Option,
- // Metadata set after creation
- pub google_resource_id: Option,
- pub expiration: Option,
-}
-```
-
-Branch in trait methods based on `trigger_type`. Frontend uses a `ToggleButtonGroup` to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes).
-
-See `backend/windmill-native-triggers/src/google/` for the reference implementation.
-
-### Skipping update+get After Create (Google Pattern)
-
-Override `service_config_from_create_response()` to return `Some(config)` when the external_id is known before the create call:
-
-```rust
-fn service_config_from_create_response(
- &self,
- data: &NativeTriggerData,
- resp: &Self::CreateResponse,
-) -> Option {
- // Clone input config, add metadata from response
- let mut config = data.service_config.clone();
- config.google_resource_id = Some(resp.resource_id.clone());
- config.expiration = Some(resp.expiration.clone());
- Some(serde_json::to_value(&config).unwrap())
-}
-```
-
-### Services with Absolute OAuth Endpoints (Google)
-
-Unlike self-hosted services where OAuth endpoints are relative paths appended to `base_url`, services like Google have absolute URLs:
-
-```rust
-// Nextcloud: relative paths
-ServiceName::Nextcloud => "/apps/oauth2/api/v1/token",
-// Google: absolute URLs
-ServiceName::Google => "https://oauth2.googleapis.com/token",
-```
-
-The `resolve_endpoint()` function handles both. For services with absolute endpoints:
-- `base_url` can be empty
-- `requiresBaseUrl: false` in the frontend workspace integration config
-- Add `extra_auth_params()` if needed (Google requires `access_type=offline` and `prompt=consent`)
-
-### Channel-Based Push Notifications with Renewal (Google Pattern)
-
-For services using expiring watch channels instead of persistent webhooks:
-
-1. Store expiration in `service_config` (as part of `ServiceConfig`)
-2. In `maintain_triggers()`, implement renewal logic instead of using `reconcile_with_external_state()`:
- ```rust
- async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) {
- for trigger in triggers {
- if should_renew_channel(trigger) {
- self.renew_channel(db, trigger, oauth_data).await;
- }
- }
- }
- ```
-3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration
-4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left)
-
-### reconcile_with_external_state (Nextcloud Pattern)
-
-The reusable function in `sync.rs` compares external triggers with DB state:
-- Triggers missing externally: sets error "Trigger no longer exists on external service"
-- Triggers present externally: clears errors, updates service_config if it differs
-
-Usage in `maintain_triggers()`:
-```rust
-let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */;
-crate::sync::reconcile_with_external_state(
- db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
-).await;
-```
-
-### Webhook Payload Processing
-
-Override `prepare_webhook()` to parse service-specific payloads into script/flow args:
-
-```rust
-async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result {
- let mut args = HashMap::new();
- args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _);
- args.insert("payload".to_string(), Box::new(serde_json::from_str::(&body)?) as _);
- Ok(PushArgsOwned { extra: None, args })
-}
-```
-
-Then register in `prepare_native_trigger_args()` in `lib.rs`:
-```rust
-pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result> {
- match service_name {
- ServiceName::Google => { /* ... */ Ok(Some(args)) }
- ServiceName::NewService => { /* ... */ Ok(Some(args)) }
- ServiceName::Nextcloud => Ok(None), // Uses default body parsing
- }
-}
-```
-
-### Instance-Level OAuth Credentials
-
-When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces.
-
-The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`.
-
----
-
-## Testing Checklist
-
-- [ ] Database migration runs successfully
-- [ ] `cargo check -p windmill-native-triggers --features native_trigger` passes
-- [ ] `npx svelte-check --threshold error` passes (in frontend/)
-- [ ] Service appears in workspace integrations list
-- [ ] OAuth flow completes successfully
-- [ ] Can create a new trigger
-- [ ] Can view trigger details
-- [ ] Can update trigger configuration
-- [ ] Can delete trigger
-- [ ] Webhook receives and processes payloads
-- [ ] Background sync works correctly (reconciliation or channel renewal)
-- [ ] Error handling works (expired tokens, service unavailable)
-
----
-
-## Reference Implementations
-
-### Nextcloud (Self-Hosted, Update+Get Pattern)
-
-| File | Purpose |
-|------|---------|
-| `nextcloud/mod.rs` | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData |
-| `nextcloud/external.rs` | External trait: uses update+get pattern, reconcile_with_external_state for sync |
-| `nextcloud/routes.rs` | Additional route: `GET /events` |
-
-Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get().
-
-### Google (Cloud, Unified Service, Short Create)
-
-| File | Purpose |
-|------|---------|
-| `google/mod.rs` | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum |
-| `google/external.rs` | External trait: overrides service_config_from_create_response, channel renewal for sync |
-| `google/routes.rs` | Additional routes: `GET /calendars`, `GET /drive/files`, `GET /drive/shared_drives` |
-
-Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API).
diff --git a/.claude/skills/native-trigger/SKILL.md b/.claude/skills/native-trigger/SKILL.md
new file mode 120000
index 0000000000..18548efdba
--- /dev/null
+++ b/.claude/skills/native-trigger/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/native-trigger/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md
deleted file mode 100644
index 2c7bd691ca..0000000000
--- a/.claude/skills/pr/SKILL.md
+++ /dev/null
@@ -1,111 +0,0 @@
----
-name: pr
-user_invocable: true
-description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR.
----
-
-# Pull Request Skill
-
-Create a draft pull request with a clear title and explicit description of changes.
-
-## Instructions
-
-1. **Analyze branch changes**: Understand all commits since diverging from main
-2. **Push to remote**: Ensure all commits are pushed
-3. **Create draft PR**: Always open as draft for review before merging
-
-## PR Title Format
-
-Follow conventional commit format for the PR title:
-```
-:
-```
-
-### Types
-- `feat`: New feature or capability
-- `fix`: Bug fix
-- `refactor`: Code restructuring
-- `docs`: Documentation changes
-- `chore`: Maintenance tasks
-- `perf`: Performance improvements
-
-### Title Rules
-- Keep under 70 characters
-- Use lowercase, imperative mood
-- No period at the end
-- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] : `
-
-## PR Body Format
-
-The body MUST be explicit about what changed. Structure:
-
-```markdown
-## Summary
-
-
-## Changes
--
--
--
-
-## Test plan
-- [ ]
-- [ ]
-
----
-Generated with [Claude Code](https://claude.com/claude-code)
-```
-
-## 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. **Run `/local-review`** before creating the PR. 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:
- ```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:
- ```bash
- gh pr create --draft --title ": " --body "$(cat <<'EOF'
- ## Summary
-
-
- ## Changes
- -
- -
-
- ## Test plan
- - [ ]
- - [ ]
-
- ---
- Generated with [Claude Code](https://claude.com/claude-code)
- EOF
- )"
- ```
-8. Return the PR URL to the user
-
-## EE Companion PR (when `*_ee.rs` files were modified)
-
-The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes.
-
-Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific details:
-
-1. Find the EE repo/worktree: see "Finding the EE Repo" in `docs/enterprise.md`
-2. Check for changes: `git -C status --short`
- - If there are no changes in the EE repo, skip this entire section
-3. Follow steps 1–5 from the "EE PR Workflow" in `docs/enterprise.md`
-4. Create the companion PR (title does NOT get the `[ee]` prefix):
- ```bash
- gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF'
- Companion PR for windmill-labs/windmill#
-
- ---
- Generated with [Claude Code](https://claude.com/claude-code)
- EOF
- )"
- ```
-5. Commit `ee-repo-ref.txt` and push the updated windmill branch
diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md
new file mode 120000
index 0000000000..9458ad7097
--- /dev/null
+++ b/.claude/skills/pr/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/pr/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/refine/SKILL.md b/.claude/skills/refine/SKILL.md
deleted file mode 100644
index aaf747cd29..0000000000
--- a/.claude/skills/refine/SKILL.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-name: refine
-user_invocable: true
-description: End-of-session reflection. Reviews friction encountered during the session and proposes updates to docs/ to capture lessons learned.
----
-
-# Refine Skill
-
-Reflect on the current session and update documentation with lessons learned.
-
-## Instructions
-
-1. **Identify friction**: Review what happened in this session:
- - Run `git diff main...HEAD --stat` to see what files were touched
- - Think about: what was slow, what failed, what required multiple attempts, what information was missing or hard to find
-
-2. **Read current docs**: Read the docs that were relevant to this session:
- - `docs/validation.md`
- - `docs/enterprise.md`
- - `docs/autonomous-mode.md`
- - Any skills that were invoked
-
-3. **Propose updates**: For each piece of friction, decide if it warrants a doc update:
- - **Missing knowledge**: Information you had to discover that should be documented
- - **Wrong guidance**: Instructions that led you astray
- - **Missing validation rule**: A check that should be in the validation matrix
- - **New pattern**: A codebase pattern worth capturing for next time
-
-4. **Apply updates**: Edit the relevant `docs/` files. Keep changes minimal and specific — add only what would have saved time this session.
-
-5. **Report**: Summarize what was added/changed and why.
-
-## Rules
-
-- Only add knowledge confirmed by this session — no speculative additions
-- Keep docs concise — add a line or two, not a paragraph
-- If a whole new doc is needed, create it in `docs/` and add a pointer in `CLAUDE.md`
-- Don't update skills unless a coding pattern was genuinely wrong
-- Don't add things Claude already knows — only Windmill-specific knowledge
diff --git a/.claude/skills/refine/SKILL.md b/.claude/skills/refine/SKILL.md
new file mode 120000
index 0000000000..39580df5d0
--- /dev/null
+++ b/.claude/skills/refine/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/refine/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/rust-backend/SKILL.md b/.claude/skills/rust-backend/SKILL.md
deleted file mode 100644
index f0c52002bc..0000000000
--- a/.claude/skills/rust-backend/SKILL.md
+++ /dev/null
@@ -1,107 +0,0 @@
----
-name: rust-backend
-description: Rust coding guidelines for the Windmill backend. MUST use when writing or modifying Rust code in the backend directory.
----
-
-# Windmill Rust Patterns
-
-Apply these Windmill-specific patterns when writing Rust code in `backend/`.
-
-## Error Handling
-
-Use `Error` from `windmill_common::error`. Return `Result` or `JsonResult`:
-
-```rust
-use windmill_common::error::{Error, Result};
-
-pub async fn get_job(db: &DB, id: Uuid) -> Result {
- sqlx::query_as!(Job, "SELECT id, workspace_id FROM v2_job WHERE id = $1", id)
- .fetch_optional(db)
- .await?
- .ok_or_else(|| Error::NotFound("job not found".to_string()))?;
-}
-```
-
-Never panic in library code. Reserve `.unwrap()` for compile-time guarantees.
-
-## SQLx Patterns
-
-**Never use `SELECT *`** — always list columns explicitly. Critical for backwards compatibility when workers lag behind API version:
-
-```rust
-// Correct
-sqlx::query_as!(Job, "SELECT id, workspace_id, path FROM v2_job WHERE id = $1", id)
-
-// Wrong — breaks when columns are added
-sqlx::query_as!(Job, "SELECT * FROM v2_job WHERE id = $1", id)
-```
-
-Use batch operations to avoid N+1:
-
-```rust
-// Preferred — single query with IN clause
-sqlx::query!("SELECT ... WHERE id = ANY($1)", &ids[..]).fetch_all(db).await?
-```
-
-Use transactions for multi-step operations. Parameterize all queries.
-
-## JSON Handling
-
-Prefer `Box` over `serde_json::Value` when storing/passing JSON without inspection:
-
-```rust
-pub struct Job {
- pub args: Option>,
-}
-```
-
-Only use `serde_json::Value` when you need to inspect or modify the JSON.
-
-## Serde Optimizations
-
-```rust
-#[derive(Serialize, Deserialize)]
-pub struct Job {
- #[serde(skip_serializing_if = "Option::is_none")]
- pub parent_job: Option,
- #[serde(skip_serializing_if = "Vec::is_empty")]
- pub tags: Vec,
- #[serde(default)]
- pub priority: i32,
-}
-```
-
-## Async & Concurrency
-
-Never block the async runtime. Use `spawn_blocking` for CPU-intensive work:
-
-```rust
-let result = tokio::task::spawn_blocking(move || expensive_computation(&data)).await?;
-```
-
-**Mutex selection**: Prefer `std::sync::Mutex` (or `parking_lot::Mutex`) for data protection. Only use `tokio::sync::Mutex` when holding locks across `.await` points.
-
-Use `tokio::sync::mpsc` (bounded) for channels. Avoid `std::thread::sleep` in async contexts.
-
-## Module Structure & Visibility
-
-- Use `pub(crate)` instead of `pub` when possible
-- Place new code in the appropriate crate based on functionality
-- API endpoints go in `windmill-api/src/` organized by domain
-- Shared functionality goes in `windmill-common/src/`
-
-## Code Navigation
-
-Always use rust-analyzer LSP for go-to-definition, find-references, and type info. Do not guess at module paths.
-
-## Axum Handlers
-
-Destructure extractors directly in function signatures:
-
-```rust
-async fn process_job(
- Extension(db): Extension,
- Path((workspace, job_id)): Path<(String, Uuid)>,
- Query(pagination): Query,
-) -> Result> { ... }
-```
diff --git a/.claude/skills/rust-backend/SKILL.md b/.claude/skills/rust-backend/SKILL.md
new file mode 120000
index 0000000000..2500c55046
--- /dev/null
+++ b/.claude/skills/rust-backend/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/rust-backend/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/svelte-frontend/SKILL.md b/.claude/skills/svelte-frontend/SKILL.md
deleted file mode 100644
index 57cac70302..0000000000
--- a/.claude/skills/svelte-frontend/SKILL.md
+++ /dev/null
@@ -1,80 +0,0 @@
----
-name: svelte-frontend
-description: Svelte coding guidelines for the Windmill frontend. MUST use when writing or modifying code in the frontend directory.
----
-
-# Windmill Svelte Patterns
-
-Apply these Windmill-specific patterns when writing Svelte code in `frontend/`. For general Svelte 5 syntax (runes, snippets, event handling), use the Svelte MCP server.
-
-## Windmill UI Components (MUST use)
-
-Always use Windmill's design-system components. Never use raw HTML elements.
-
-### Buttons — ``
-
-```svelte
-
-
-Label
-
-```
-
-Props: `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`, `unifiedSize?: 'sm' | 'md' | 'lg'`, `startIcon?: { icon: SvelteComponent }`, `iconOnly?: boolean`, `disabled?: boolean`
-
-### Text inputs — ``
-
-```svelte
-
-
-
-```
-
-Props: `value?: string | number` (bindable), `placeholder?: string`, `disabled?: boolean`, `error?: string | boolean`, `size?: 'sm' | 'md' | 'lg'`
-
-### Selects — ``
-
-```svelte
-
-
-
-```
-
-Props: `items?: Array<{ label?: string; value: any }>`, `value` (bindable), `placeholder?: string`, `clearable?: boolean`, `size?: 'sm' | 'md' | 'lg'`
-
-### Icons — `lucide-svelte`
-
-Never write inline SVGs. Import from `lucide-svelte`:
-
-```svelte
-
-
-```
-
-## Form Components
-
-Form components (TextInput, Toggle, Select, etc.) should use the unified size system when placed together.
-
-## Styling
-
-- Use Tailwind CSS for all styling — no custom CSS
-- Use Windmill's theming classes for colors/surfaces (see `frontend/brand-guidelines.md`)
-- Read component props JSDoc before using them
-
-## Svelte MCP Server
-
-Use the Svelte MCP tools when working on Svelte code:
-
-1. **list-sections**: Call first to discover available docs
-2. **get-documentation**: Fetch relevant sections based on use_cases
-3. **svelte-autofixer**: MUST use on all Svelte code before finalizing — keep calling until no issues
-4. **playground-link**: Only after user confirms and code was NOT written to project files
diff --git a/.claude/skills/svelte-frontend/SKILL.md b/.claude/skills/svelte-frontend/SKILL.md
new file mode 120000
index 0000000000..a37bf39bd3
--- /dev/null
+++ b/.claude/skills/svelte-frontend/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/svelte-frontend/SKILL.md
\ No newline at end of file
diff --git a/.claude/skills/update-sqlx/SKILL.md b/.claude/skills/update-sqlx/SKILL.md
new file mode 120000
index 0000000000..5e75e2ceaa
--- /dev/null
+++ b/.claude/skills/update-sqlx/SKILL.md
@@ -0,0 +1 @@
+../../../.agents/skills/update-sqlx/SKILL.md
\ No newline at end of file
diff --git a/.github/codex/pr-review.prompt.md b/.github/codex/pr-review.prompt.md
index d3e6dfc4e8..fef52dba85 100644
--- a/.github/codex/pr-review.prompt.md
+++ b/.github/codex/pr-review.prompt.md
@@ -1,23 +1,5 @@
-You are reviewing a GitHub pull request for this repository.
+# Codex output format
-Review policy:
-- Read `CLAUDE.md` before reviewing code.
-- Only report issues you are confident are real and introduced by this pull request.
-- Focus on bugs, security problems, and clear `CLAUDE.md` violations.
-- Do not report style nits, speculative concerns, pre-existing issues, or problems that a normal linter/typechecker would obviously catch.
-- Keep the review high signal. If there is no clear issue, return no findings.
-
-Repository context:
-- Read `./.github/codex/pr-review-context.md` for the PR metadata and the exact diff commands to use.
-- Review only the changes introduced by this PR.
-- Read additional files only when the diff is not enough to validate a finding.
-- Do not modify any files.
-
-Output requirements:
-- Return a GitHub PR comment in markdown, not JSON.
-- Start with `## Codex Review`.
-- Give a short overall summary first.
-- If you found high-signal issues, list them in a short numbered list with file paths and line numbers when you know them confidently.
-- If you found no high-signal issues, say that explicitly.
-- End with a `### Reproduction instructions` section containing a short descriptive paragraph for a tester explaining how to navigate the app to observe the change. Do not make it a numbered list. If the diff is not enough to infer this safely, say that plainly.
-- Prefer at most 10 findings.
+- Read `./.github/codex/pr-review-context.md` for PR metadata and the diff commands.
+- Return a markdown PR comment starting with `## Codex Review`.
+- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
diff --git a/.github/pi/pr-review.prompt.md b/.github/pi/pr-review.prompt.md
new file mode 100644
index 0000000000..92f128c6b1
--- /dev/null
+++ b/.github/pi/pr-review.prompt.md
@@ -0,0 +1,6 @@
+# Pi output format
+
+- Read `./.github/pi/pr-review-context.md` for PR metadata and the diff commands.
+- Return a markdown PR comment starting with `## Pi Review`.
+- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
+- Output ONLY the final review markdown — no preamble, no thinking, no tool transcripts.
diff --git a/.github/workflows/claude-fast.yml b/.github/workflows/claude-fast.yml
deleted file mode 100644
index 4ad53d326f..0000000000
--- a/.github/workflows/claude-fast.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: Fast Claude
-
-on:
- issue_comment:
- types: [created]
- pull_request_review_comment:
- types: [created]
- issues:
- types: [opened, assigned]
- pull_request_review:
- types: [submitted]
-
-jobs:
- check-membership:
- if: |
- (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai-fast')) ||
- (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai-fast')) ||
- (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai-fast')) ||
- (github.event_name == 'issues' && contains(github.event.issue.body, '/ai-fast'))
- uses: ./.github/workflows/check-org-membership.yml
- secrets:
- access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
-
- claude-code-action:
- needs: check-membership
- if: |
- needs.check-membership.outputs.is_member == 'true'
- runs-on: ubicloud-standard-8
- permissions:
- contents: write
- pull-requests: write
- issues: write
- id-token: write
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - name: Run Claude PR Action
- uses: anthropics/claude-code-action@v1
- with:
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
- allowed_bots: "windmill-internal-app[bot]"
- trigger_phrase: "/ai-fast"
- settings: |
- {
- "env": {
- "SQLX_OFFLINE": "true"
- }
- }
- claude_args: |
- --allowedTools "Bash,WebFetch,WebSearch"
- --model opus
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
index 95436e3214..8052bb6b4c 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -1,4 +1,4 @@
-name: Claude PR Assistant
+name: Fast Claude
on:
issue_comment:
@@ -26,7 +26,6 @@ jobs:
if: |
needs.check-membership.outputs.is_member == 'true'
runs-on: ubicloud-standard-8
- timeout-minutes: 60
permissions:
contents: write
pull-requests: write
@@ -38,37 +37,6 @@ jobs:
with:
fetch-depth: 1
- - uses: actions/cache@v3
- with:
- path: ~/.npm
- key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- restore-keys: |
- ${{ runner.os }}-node-
-
- - name: Run npm install and generate-backend-client
- working-directory: ./frontend
- run: |
- # add a build directory for cargo check
- mkdir -p build
- npm install
- npm run generate-backend-client
-
- - name: install xmlsec1 and gssapi
- run: |
- sudo apt-get update
- sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev libcurl4-openssl-dev mold clang
-
- - uses: actions-rust-lang/setup-rust-toolchain@v1
- with:
- cache-workspaces: backend
- toolchain: 1.93.0
-
- - name: cargo check
- working-directory: ./backend
- timeout-minutes: 16
- run: |
- SQLX_OFFLINE=true cargo check --features all_sqlx_features
-
- name: Run Claude PR Action
uses: anthropics/claude-code-action@v1
with:
@@ -84,24 +52,3 @@ jobs:
claude_args: |
--allowedTools "Bash,WebFetch,WebSearch"
--model opus
- --system-prompt "## IMPORTANT INSTRUCTIONS
- - Your branch name should be a short description of the requested changes.
- - Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main.
-
- ## Code Quality Requirements
-
- After making any code changes, you MUST run the appropriate validation commands:
-
- **Frontend Changes:**
- - Run: \`npm run check\` in the frontend directory
- - Fix all warnings and errors before proceeding
-
- **Backend Changes:**
- - Run: \`cargo check --features all_sqlx_features\` in the backend directory
- - Fix all warnings and errors before proceeding
-
- **Pull Request Creation:**
- - DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue.
-
- ## Available Tools
- - Bash: Full access to run validation commands and git operations"
diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml
index e945f5fd45..e89a58b629 100644
--- a/.github/workflows/codex-pr-review.yml
+++ b/.github/workflows/codex-pr-review.yml
@@ -2,20 +2,60 @@ name: Codex Auto Review
on:
pull_request:
- types: [ready_for_review, opened]
+ types: [ready_for_review, opened, synchronize]
+ workflow_call:
+ inputs:
+ pr_number:
+ description: 'PR number to review'
+ required: true
+ type: number
+ extra_prompt:
+ description: 'Additional reviewer instructions appended to the standard review prompt'
+ required: false
+ type: string
+ default: ''
+ triggered_by:
+ description: 'GitHub username that triggered this review (for audit only)'
+ required: false
+ type: string
+ default: ''
+ secrets:
+ CODEX_AUTH_JSON:
+ required: false
+ WINDMILL_EE_PRIVATE_ACCESS:
+ required: false
concurrency:
- group: codex-review-${{ github.event.pull_request.number }}
+ group: codex-review-${{ inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
+ check-membership:
+ if: github.event_name == 'pull_request'
+ uses: ./.github/workflows/check-org-membership.yml
+ with:
+ commenter: ${{ github.event.pull_request.user.login }}
+ secrets:
+ access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
+
codex-review:
+ needs: check-membership
runs-on: ubicloud-standard-2
timeout-minutes: 30
- if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false
+ if: |
+ always() &&
+ (
+ needs.check-membership.result == 'skipped' ||
+ (needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
+ ) &&
+ (
+ github.event_name == 'workflow_call' ||
+ (github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
+ )
permissions:
contents: read
issues: write
+ pull-requests: write
steps:
- name: Check Codex configuration
id: codex_config
@@ -29,25 +69,104 @@ jobs:
echo "CODEX_AUTH_JSON is not configured; skipping Codex review."
fi
- - name: Checkout repository
+ - name: Resolve PR metadata
if: steps.codex_config.outputs.enabled == 'true'
+ id: pr
+ env:
+ GH_TOKEN: ${{ github.token }}
+ INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+ EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
+ EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ EVENT_TITLE: ${{ github.event.pull_request.title }}
+ EVENT_BODY: ${{ github.event.pull_request.body }}
+ EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
+ run: |
+ if [ -n "$INPUT_PR_NUMBER" ]; then
+ PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
+ --json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository)
+ PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
+ BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
+ BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
+ HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid')
+ PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
+ PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
+ IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
+ else
+ PR_NUMBER="$EVENT_PR_NUMBER"
+ BASE_REF="$EVENT_BASE_REF"
+ BASE_SHA="$EVENT_BASE_SHA"
+ HEAD_SHA="$EVENT_HEAD_SHA"
+ PR_TITLE="$EVENT_TITLE"
+ PR_BODY="$EVENT_BODY"
+ IS_FORK="$EVENT_FORK"
+ fi
+ if [ "$IS_FORK" = "true" ]; then
+ echo "Skipping Codex review for fork PR."
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ {
+ echo "skip=false"
+ echo "pr_number=$PR_NUMBER"
+ echo "base_ref=$BASE_REF"
+ echo "base_sha=$BASE_SHA"
+ echo "head_sha=$HEAD_SHA"
+ echo 'title<> "$GITHUB_OUTPUT"
+
+ - name: Checkout repository
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
uses: actions/checkout@v5
with:
- ref: refs/pull/${{ github.event.pull_request.number }}/merge
+ ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
fetch-depth: 1
+ - name: Check EE access
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ id: ee
+ env:
+ EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+ run: |
+ if [ -n "$EE_TOKEN" ]; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Checkout EE repository
+ if: steps.ee.outputs.available == 'true'
+ uses: actions/checkout@v5
+ with:
+ repository: windmill-labs/windmill-ee-private
+ path: ./windmill-ee-private
+ ref: ${{ steps.ee.outputs.ee_repo_ref }}
+ token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+ fetch-depth: 1
+
+ - name: Substitute EE code
+ if: steps.ee.outputs.available == 'true'
+ run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
+
- name: Set up Node.js
- if: steps.codex_config.outputs.enabled == 'true'
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Codex CLI
- if: steps.codex_config.outputs.enabled == 'true'
- run: npm install --global @openai/codex@0.117.0
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ run: npm install --global @openai/codex@0.128.0
- name: Configure file-backed Codex auth
- if: steps.codex_config.outputs.enabled == 'true'
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
env:
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
run: |
@@ -63,24 +182,36 @@ jobs:
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
- name: Pre-fetch base and head refs for the PR
- if: steps.codex_config.outputs.enabled == 'true'
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
env:
- PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
+ PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
run: |
git fetch --no-tags origin \
"$PR_BASE_REF" \
"+refs/pull/$PR_NUMBER/head"
+ - name: Fetch prior PR discussion
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+ run: |
+ gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
+ --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
+ > prior-comments.json || echo "[]" > prior-comments.json
+
- name: Write Codex review context
- if: steps.codex_config.outputs.enabled == 'true'
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
env:
PR_REPOSITORY: ${{ github.repository }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
- PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- PR_TITLE: ${{ github.event.pull_request.title }}
- PR_BODY: ${{ github.event.pull_request.body || '' }}
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+ PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
+ PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
+ PR_TITLE: ${{ steps.pr.outputs.title }}
+ PR_BODY: ${{ steps.pr.outputs.body }}
+ EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
mkdir -p .github/codex
node <<'NODE'
@@ -106,23 +237,46 @@ jobs:
'Full review diff command:',
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
];
+ if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
+ lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
+ }
+ if (fs.existsSync('prior-comments.json')) {
+ try {
+ const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
+ if (Array.isArray(comments) && comments.length > 0) {
+ lines.push(
+ '',
+ 'Prior PR discussion (most recent up to 20 comments):',
+ '',
+ 'If you have already reviewed this PR (look for your own earlier "## Codex Review" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.',
+ ''
+ );
+ for (const c of comments) {
+ lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', '');
+ }
+ }
+ } catch (_) {}
+ }
fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`);
NODE
- name: Run Codex review
- if: steps.codex_config.outputs.enabled == 'true'
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
run: |
+ cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md
codex exec \
-C "$GITHUB_WORKSPACE" \
- -m gpt-5.4 \
+ -m gpt-5.5 \
-c 'model_reasoning_effort="xhigh"' \
- -s read-only \
+ -s danger-full-access \
-o codex-final-message.md \
- - < .github/codex/pr-review.prompt.md
+ - < /tmp/codex-prompt.md
- name: Post Codex review comment
- if: steps.codex_config.outputs.enabled == 'true'
+ if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
uses: actions/github-script@v7
+ env:
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
with:
github-token: ${{ github.token }}
script: |
@@ -140,6 +294,6 @@ jobs:
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: context.payload.pull_request.number,
+ issue_number: Number(process.env.PR_NUMBER),
body,
});
diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml
new file mode 100644
index 0000000000..9c0edc9cb5
--- /dev/null
+++ b/.github/workflows/pi-pr-review.yml
@@ -0,0 +1,315 @@
+name: Pi Auto Review
+
+on:
+ pull_request:
+ types: [ready_for_review, opened, synchronize]
+ workflow_call:
+ inputs:
+ pr_number:
+ description: 'PR number to review'
+ required: true
+ type: number
+ extra_prompt:
+ description: 'Additional reviewer instructions appended to the standard review prompt'
+ required: false
+ type: string
+ default: ''
+ triggered_by:
+ description: 'GitHub username that triggered this review (for audit only)'
+ required: false
+ type: string
+ default: ''
+ secrets:
+ DEEPSEEK_API_KEY:
+ required: false
+ WINDMILL_EE_PRIVATE_ACCESS:
+ required: false
+
+concurrency:
+ group: pi-review-${{ inputs.pr_number || github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ check-membership:
+ if: github.event_name == 'pull_request'
+ uses: ./.github/workflows/check-org-membership.yml
+ with:
+ commenter: ${{ github.event.pull_request.user.login }}
+ secrets:
+ access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
+
+ pi-review:
+ needs: check-membership
+ runs-on: ubicloud-standard-2
+ timeout-minutes: 30
+ if: |
+ always() &&
+ (
+ needs.check-membership.result == 'skipped' ||
+ (needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
+ ) &&
+ (
+ github.event_name == 'workflow_call' ||
+ (github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
+ )
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Check Pi configuration
+ id: pi_config
+ env:
+ DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+ run: |
+ if [ -n "$DEEPSEEK_API_KEY" ]; then
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ echo "DEEPSEEK_API_KEY is not configured; skipping Pi review."
+ fi
+
+ - name: Resolve PR metadata
+ if: steps.pi_config.outputs.enabled == 'true'
+ id: pr
+ env:
+ GH_TOKEN: ${{ github.token }}
+ INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+ EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
+ EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ EVENT_TITLE: ${{ github.event.pull_request.title }}
+ EVENT_BODY: ${{ github.event.pull_request.body }}
+ EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
+ run: |
+ if [ -n "$INPUT_PR_NUMBER" ]; then
+ PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
+ --json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository)
+ PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
+ BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
+ BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
+ HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid')
+ PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
+ PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
+ IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
+ else
+ PR_NUMBER="$EVENT_PR_NUMBER"
+ BASE_REF="$EVENT_BASE_REF"
+ BASE_SHA="$EVENT_BASE_SHA"
+ HEAD_SHA="$EVENT_HEAD_SHA"
+ PR_TITLE="$EVENT_TITLE"
+ PR_BODY="$EVENT_BODY"
+ IS_FORK="$EVENT_FORK"
+ fi
+ if [ "$IS_FORK" = "true" ]; then
+ echo "Skipping Pi review for fork PR."
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ {
+ echo "skip=false"
+ echo "pr_number=$PR_NUMBER"
+ echo "base_ref=$BASE_REF"
+ echo "base_sha=$BASE_SHA"
+ echo "head_sha=$HEAD_SHA"
+ echo 'title<> "$GITHUB_OUTPUT"
+
+ - name: Checkout repository
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ uses: actions/checkout@v5
+ with:
+ ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
+ fetch-depth: 1
+
+ - name: Check EE access
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ id: ee
+ env:
+ EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+ run: |
+ if [ -n "$EE_TOKEN" ]; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Checkout EE repository
+ if: steps.ee.outputs.available == 'true'
+ uses: actions/checkout@v5
+ with:
+ repository: windmill-labs/windmill-ee-private
+ path: ./windmill-ee-private
+ ref: ${{ steps.ee.outputs.ee_repo_ref }}
+ token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+ fetch-depth: 1
+
+ - name: Substitute EE code
+ if: steps.ee.outputs.available == 'true'
+ run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
+
+ - name: Set up Node.js
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install Pi CLI
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ run: npm install --global @mariozechner/pi-coding-agent
+
+ - name: Pre-fetch base and head refs for the PR
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ env:
+ PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+ run: |
+ git fetch --no-tags origin \
+ "$PR_BASE_REF" \
+ "+refs/pull/$PR_NUMBER/head"
+
+ - name: Fetch prior PR discussion
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+ run: |
+ gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
+ --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
+ > prior-comments.json || echo "[]" > prior-comments.json
+
+ - name: Write Pi review context
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ env:
+ PR_REPOSITORY: ${{ github.repository }}
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+ PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
+ PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
+ PR_TITLE: ${{ steps.pr.outputs.title }}
+ PR_BODY: ${{ steps.pr.outputs.body }}
+ EXTRA_PROMPT: ${{ inputs.extra_prompt }}
+ run: |
+ mkdir -p .github/pi
+ node <<'NODE'
+ const fs = require('fs');
+ const lines = [
+ `Repository: ${process.env.PR_REPOSITORY}`,
+ `PR number: ${process.env.PR_NUMBER}`,
+ `Base SHA: ${process.env.PR_BASE_SHA}`,
+ `Head SHA: ${process.env.PR_HEAD_SHA}`,
+ '',
+ 'PR title:',
+ process.env.PR_TITLE || '(empty)',
+ '',
+ 'PR body:',
+ process.env.PR_BODY || '(empty)',
+ '',
+ 'Changed commits command:',
+ `git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
+ '',
+ 'Changed files command:',
+ `git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
+ '',
+ 'Full review diff command:',
+ `git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
+ ];
+ if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
+ lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
+ }
+ if (fs.existsSync('prior-comments.json')) {
+ try {
+ const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
+ if (Array.isArray(comments) && comments.length > 0) {
+ lines.push(
+ '',
+ 'Prior PR discussion (most recent up to 20 comments):',
+ '',
+ 'If you have already reviewed this PR (look for your own earlier "## Pi Review (DeepSeek V4)" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.',
+ ''
+ );
+ for (const c of comments) {
+ lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', '');
+ }
+ }
+ } catch (_) {}
+ }
+ fs.writeFileSync('.github/pi/pr-review-context.md', `${lines.join('\n')}\n`);
+ NODE
+
+ - name: Run Pi review
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ env:
+ DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+ PI_SKIP_VERSION_CHECK: '1'
+ run: |
+ set -o pipefail
+ cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
+ pi -p \
+ --provider deepseek \
+ --model deepseek-v4-pro \
+ --tools read,grep,find,ls,bash \
+ --mode json \
+ < /tmp/pi-prompt.md \
+ | tee pi-events.jsonl \
+ | jq -rc --unbuffered '
+ if .type == "agent_start" then "🤖 pi agent started"
+ elif .type == "turn_start" then "── turn ──"
+ elif .type == "message_end" then
+ "[\(.message.role)] " + (
+ (.message.content // [])
+ | map(
+ if .type == "text" then "text(\(.text | length)c)"
+ elif .type == "tool_use" then "🔧 \(.name) \(.input | @json | .[:160])"
+ elif .type == "tool_result" then "✅ result"
+ else .type
+ end
+ )
+ | join(" | ")
+ )
+ elif .type == "turn_end" then "── turn done (\((.toolResults // []) | length) tool result(s)) ──"
+ elif .type == "agent_end" then "🏁 pi agent done"
+ else empty
+ end
+ '
+
+ jq -r '
+ select(.type == "agent_end")
+ | .messages
+ | map(select(.role == "assistant"))
+ | last
+ | (.content[]? | select(.type == "text") | .text)
+ ' pi-events.jsonl > pi-final-message.md
+
+ - name: Post Pi review comment
+ if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+ uses: actions/github-script@v7
+ env:
+ PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+ with:
+ github-token: ${{ github.token }}
+ script: |
+ const fs = require('fs');
+ const path = `${process.env.GITHUB_WORKSPACE}/pi-final-message.md`;
+ if (!fs.existsSync(path)) {
+ core.info('Pi did not produce a final message; skipping PR comment.');
+ return;
+ }
+ const body = fs.readFileSync(path, 'utf8').trim();
+ if (!body) {
+ core.info('Pi final message was empty; skipping PR comment.');
+ return;
+ }
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: Number(process.env.PR_NUMBER),
+ body,
+ });
diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml
index 78c0c3e045..be2d13477a 100644
--- a/.github/workflows/pr-ready-review.yml
+++ b/.github/workflows/pr-ready-review.yml
@@ -3,31 +3,140 @@ name: Claude Auto Review
on:
pull_request:
types: [ready_for_review, opened]
+ workflow_call:
+ inputs:
+ pr_number:
+ description: 'PR number to review'
+ required: true
+ type: number
+ extra_prompt:
+ description: 'Additional reviewer instructions appended to the standard review prompt'
+ required: false
+ type: string
+ default: ''
+ triggered_by:
+ description: 'GitHub username that triggered this review (for audit only)'
+ required: false
+ type: string
+ default: ''
+ secrets:
+ CLAUDE_CODE_OAUTH_TOKEN:
+ required: true
+ WINDMILL_EE_PRIVATE_ACCESS:
+ required: false
concurrency:
- group: claude-review-${{ github.event.pull_request.number }}
+ group: claude-review-${{ inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
+ check-membership:
+ if: github.event_name == 'pull_request'
+ uses: ./.github/workflows/check-org-membership.yml
+ with:
+ commenter: ${{ github.event.pull_request.user.login }}
+ secrets:
+ access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
+
auto-review:
+ needs: check-membership
runs-on: ubuntu-latest
- if: github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true
+ if: |
+ always() &&
+ (
+ needs.check-membership.result == 'skipped' ||
+ (needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
+ ) &&
+ (
+ github.event_name == 'workflow_call' ||
+ (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true)
+ )
permissions:
contents: read
pull-requests: read
id-token: write
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 1
+ - name: Check EE access
+ id: ee
+ env:
+ EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+ run: |
+ if [ -n "$EE_TOKEN" ]; then
+ echo "available=true" >> "$GITHUB_OUTPUT"
+ echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
+ else
+ echo "available=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Checkout EE repository
+ if: steps.ee.outputs.available == 'true'
+ uses: actions/checkout@v5
+ with:
+ repository: windmill-labs/windmill-ee-private
+ path: ./windmill-ee-private
+ ref: ${{ steps.ee.outputs.ee_repo_ref }}
+ token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+ fetch-depth: 1
+
+ - name: Substitute EE code
+ if: steps.ee.outputs.available == 'true'
+ run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
+
+ - name: Resolve PR number
+ id: resolve
+ env:
+ INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+ EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ if [ -n "$INPUT_PR_NUMBER" ]; then
+ echo "pr_number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT"
+ else
+ echo "pr_number=$EVENT_PR_NUMBER" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Fetch prior PR discussion
+ id: prior
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ steps.resolve.outputs.pr_number }}
+ run: |
+ gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
+ --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
+ > prior-comments.json || echo "[]" > prior-comments.json
+ jq -r '
+ if length == 0 then ""
+ else
+ "## Prior PR discussion (most recent up to 20 comments)\n\nIf you have already reviewed this PR (look for your own earlier comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.\n\n" +
+ (map("### @\(.user) (\(.created_at))\n\n\(.body)") | join("\n\n---\n\n"))
+ end
+ ' prior-comments.json > prior-comments.md
+
- name: Read review prompt
id: review-prompt
+ env:
+ EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
{
echo 'REVIEW_PROMPT<> "$GITHUB_ENV"
@@ -38,7 +147,7 @@ jobs:
track_progress: true
prompt: |
REPO: ${{ github.repository }}
- PR NUMBER: ${{ github.event.pull_request.number }}
+ PR NUMBER: ${{ steps.resolve.outputs.pr_number }}
${{ env.REVIEW_PROMPT }}
claude_args: |
diff --git a/.github/workflows/pr-review-commands.yml b/.github/workflows/pr-review-commands.yml
new file mode 100644
index 0000000000..21d5e1decd
--- /dev/null
+++ b/.github/workflows/pr-review-commands.yml
@@ -0,0 +1,122 @@
+name: PR Review Commands
+
+on:
+ issue_comment:
+ types: [created]
+
+jobs:
+ parse:
+ if: github.event.issue.pull_request != null
+ runs-on: ubuntu-latest
+ outputs:
+ command: ${{ steps.parse.outputs.command }}
+ extra_prompt: ${{ steps.parse.outputs.extra_prompt }}
+ steps:
+ - name: Parse command from comment
+ id: parse
+ env:
+ BODY: ${{ github.event.comment.body }}
+ run: |
+ FIRST_LINE=$(printf '%s' "$BODY" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
+ FIRST_WORD=${FIRST_LINE%% *}
+ case "$FIRST_WORD" in
+ /review|/codex|/pi|/claude)
+ COMMAND="${FIRST_WORD#/}"
+ REMAINDER_FIRST_LINE=${FIRST_LINE#"$FIRST_WORD"}
+ REMAINDER_FIRST_LINE=${REMAINDER_FIRST_LINE# }
+ REST=$(printf '%s' "$BODY" | tail -n +2)
+ {
+ echo "command=$COMMAND"
+ echo 'extra_prompt<> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "command=" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+
+ check-membership:
+ needs: parse
+ if: needs.parse.outputs.command != ''
+ uses: ./.github/workflows/check-org-membership.yml
+ secrets:
+ access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
+
+ acknowledge:
+ needs: [parse, check-membership]
+ if: needs.parse.outputs.command != '' && needs.check-membership.outputs.is_member == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - name: React to comment with eyes
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ run: |
+ gh api -X POST \
+ "/repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
+ -f content=eyes >/dev/null
+
+ claude:
+ needs: [parse, check-membership]
+ if: |
+ needs.check-membership.outputs.is_member == 'true' &&
+ (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'claude')
+ permissions:
+ contents: read
+ pull-requests: read
+ id-token: write
+ uses: ./.github/workflows/pr-ready-review.yml
+ with:
+ pr_number: ${{ github.event.issue.number }}
+ extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
+ triggered_by: ${{ github.event.comment.user.login }}
+ secrets:
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+
+ codex:
+ needs: [parse, check-membership]
+ if: |
+ needs.check-membership.outputs.is_member == 'true' &&
+ (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'codex')
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+ uses: ./.github/workflows/codex-pr-review.yml
+ with:
+ pr_number: ${{ github.event.issue.number }}
+ extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
+ triggered_by: ${{ github.event.comment.user.login }}
+ secrets:
+ CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
+ WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+
+ pi:
+ needs: [parse, check-membership]
+ if: |
+ needs.check-membership.outputs.is_member == 'true' &&
+ (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'pi')
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+ uses: ./.github/workflows/pi-pr-review.yml
+ with:
+ pr_number: ${{ github.event.issue.number }}
+ extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
+ triggered_by: ${{ github.event.comment.user.login }}
+ secrets:
+ DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+ WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
diff --git a/.gitignore b/.gitignore
index b2741131a5..10889080ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ backend/chrome_profiler.json
.fast-check/
__pycache__/
.playwright-mcp/
+.codex
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 825a033f94..c4818577e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,9 +15,10 @@ 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.
-- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance
+- **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`
+- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
## Dev Environment
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 427eba2a5f..a7f2f43e3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,77 @@
# Changelog
+## [1.697.0](https://github.com/windmill-labs/windmill/compare/v1.696.2...v1.697.0) (2026-05-07)
+
+
+### Features
+
+* add workspace-specific flag for resources and variables ([#8836](https://github.com/windmill-labs/windmill/issues/8836)) ([4427a3d](https://github.com/windmill-labs/windmill/commit/4427a3d37f6e1edef93082322ee346077e0c1ff6))
+* **forks:** handle triggers and schedules in wmill workspace merge ([#9023](https://github.com/windmill-labs/windmill/issues/9023)) ([9de38f9](https://github.com/windmill-labs/windmill/commit/9de38f9a09d2a5759de98849c78f5470bffef94b))
+* **kafka-trigger:** OAUTHBEARER + SASL_SSL support ([#9054](https://github.com/windmill-labs/windmill/issues/9054)) ([80475f0](https://github.com/windmill-labs/windmill/commit/80475f011bf8f7ffbd5afa50fb5df37cdac0825d))
+* **secret-backend:** add Workload Identity Federation for Azure Key Vault ([#9061](https://github.com/windmill-labs/windmill/issues/9061)) ([0c203e8](https://github.com/windmill-labs/windmill/commit/0c203e8cf1cd23fa61675c5691c2649bf0664de0))
+
+
+### Bug Fixes
+
+* **cli:** stable auto-numbered inline-script names in app pull ([#9071](https://github.com/windmill-labs/windmill/issues/9071)) ([8c67e5f](https://github.com/windmill-labs/windmill/commit/8c67e5fdb78b4f17b8866e97bc04168519a65021))
+* **concurrency:** two-phase admit to skip FOR UPDATE on over-limit pulls ([#9064](https://github.com/windmill-labs/windmill/issues/9064)) ([153c4e6](https://github.com/windmill-labs/windmill/commit/153c4e6aff9344a9d6059737c8614c25c9b4443a))
+* handle singlestepflow zombies and stop filtering them from runs page ([#9055](https://github.com/windmill-labs/windmill/issues/9055)) ([e74f06c](https://github.com/windmill-labs/windmill/commit/e74f06cb561d2a2652477e2b6d2ea645e9c77964))
+* Log viewer fixed top bar ([#9070](https://github.com/windmill-labs/windmill/issues/9070)) ([23e081b](https://github.com/windmill-labs/windmill/commit/23e081b078df961d9c42ca12472e3daa7479e290))
+* scope dev server CSS reset to a layer so Tailwind utilities win ([#9069](https://github.com/windmill-labs/windmill/issues/9069)) ([6df79a4](https://github.com/windmill-labs/windmill/commit/6df79a457269a02747d8ec37ba5e97deec7a3e42))
+
+## [1.696.2](https://github.com/windmill-labs/windmill/compare/v1.696.1...v1.696.2) (2026-05-06)
+
+
+### Bug Fixes
+
+* bubble handle_flow chaining errors to parent flow ([#9058](https://github.com/windmill-labs/windmill/issues/9058)) ([5bca03e](https://github.com/windmill-labs/windmill/commit/5bca03eacdbf147268d8ad9079e9ec45b2819af0))
+* **bun:** make hub script cache resilient to malformed lockfiles ([#9063](https://github.com/windmill-labs/windmill/issues/9063)) ([c6f1c5e](https://github.com/windmill-labs/windmill/commit/c6f1c5e623716df703dd6b37be9981dbe5742550))
+* **cli:** detect upstream auth-gateway HTML responses and add poll heartbeat ([#9065](https://github.com/windmill-labs/windmill/issues/9065)) ([628ab56](https://github.com/windmill-labs/windmill/commit/628ab5692e1825001eac0aaa92638c0067a508ea))
+* **queue:** cap worker pull loop at 10 to avoid DB storm ([#9062](https://github.com/windmill-labs/windmill/issues/9062)) ([e3cc258](https://github.com/windmill-labs/windmill/commit/e3cc2584555d532d91aa888cc52af34a16277036))
+
+## [1.696.1](https://github.com/windmill-labs/windmill/compare/v1.696.0...v1.696.1) (2026-05-06)
+
+
+### Bug Fixes
+
+* **bun:** propagate non-zero exit from generate_bun_bundle on no-DB path ([#9051](https://github.com/windmill-labs/windmill/issues/9051)) ([eebaab9](https://github.com/windmill-labs/windmill/commit/eebaab9c87f975b70049e118a08665fc21653b13))
+* **workspaces:** validate fork id as a git branch name component ([#9049](https://github.com/windmill-labs/windmill/issues/9049)) ([f4553e8](https://github.com/windmill-labs/windmill/commit/f4553e8e7919b115a4239a62ee4587347cc82bb8))
+
+## [1.696.0](https://github.com/windmill-labs/windmill/compare/v1.695.0...v1.696.0) (2026-05-05)
+
+
+### Features
+
+* add ai chat resource action buttons ([#9016](https://github.com/windmill-labs/windmill/issues/9016)) ([502a029](https://github.com/windmill-labs/windmill/commit/502a02998685308e82fab95bae7d3c14efd77d6a))
+* add wac ai context for frontend chat ([#9021](https://github.com/windmill-labs/windmill/issues/9021)) ([0d0557f](https://github.com/windmill-labs/windmill/commit/0d0557fc9dc5addee887911ddfc0fd08a09bc92e))
+* **cli:** add --as-superadmin flag to workspace list-remote ([#9043](https://github.com/windmill-labs/windmill/issues/9043)) ([66c9063](https://github.com/windmill-labs/windmill/commit/66c90639191a77eb4f19da092167384565edb9b3))
+
+
+### Bug Fixes
+
+* **cli:** resolve cross-folder relative imports during lockgen on fresh DB ([#9048](https://github.com/windmill-labs/windmill/issues/9048)) ([40dbab5](https://github.com/windmill-labs/windmill/commit/40dbab531e5166b894f3f94b0d72b2ac456c0097))
+* **flows:** inherit flow_env in sub-flow predicates ([#9042](https://github.com/windmill-labs/windmill/issues/9042)) ([6e5a21a](https://github.com/windmill-labs/windmill/commit/6e5a21a9c7b5db77d325b5916a9ab8799a2eb6e7))
+* navigate home arrows ([#9024](https://github.com/windmill-labs/windmill/issues/9024)) ([c1e52ea](https://github.com/windmill-labs/windmill/commit/c1e52eab09794746bea7dc9adb94552641f87d5b))
+* open job detail header path links in a new tab ([#9039](https://github.com/windmill-labs/windmill/issues/9039)) ([fe68c06](https://github.com/windmill-labs/windmill/commit/fe68c066004d860088e09be32e7ff2e7438f78c4))
+* **rust-client:** re-export models module from wmill crate ([#9038](https://github.com/windmill-labs/windmill/issues/9038)) ([ca6efbf](https://github.com/windmill-labs/windmill/commit/ca6efbff74e7d7e85174b1d8394af79eda7d6535))
+* **windmill-utils-internal:** move config to subpath export ([#9045](https://github.com/windmill-labs/windmill/issues/9045)) ([b86f896](https://github.com/windmill-labs/windmill/commit/b86f8960fcd8a66bc6849638178ef45ac49e06f1))
+
+## [1.695.0](https://github.com/windmill-labs/windmill/compare/v1.694.0...v1.695.0) (2026-05-04)
+
+
+### Features
+
+* add separate filter searchbar for resource types tab ([#9019](https://github.com/windmill-labs/windmill/issues/9019)) ([f1fd245](https://github.com/windmill-labs/windmill/commit/f1fd245073d6bf97a6a6c64e64d545618cccc432))
+
+
+### Bug Fixes
+
+* **autoscaling:** consider dedicated workers in scale decisions ([#9020](https://github.com/windmill-labs/windmill/issues/9020)) ([42be1d4](https://github.com/windmill-labs/windmill/commit/42be1d46a632c23830f97995e9ab1b52a1ed5d3d))
+* bind MySQL table listing to configured database name ([#9007](https://github.com/windmill-labs/windmill/issues/9007)) ([44fad13](https://github.com/windmill-labs/windmill/commit/44fad139fe2076f5faa62de31aaeeb217466b25e))
+* **flows:** don't bubble error when continue_on_error is on the last step ([#9029](https://github.com/windmill-labs/windmill/issues/9029)) ([192866d](https://github.com/windmill-labs/windmill/commit/192866d5197c74ec930d6fe7bf9234fac76763f4))
+* **forks:** strip mode/enabled from merge-UI deploy payload ([#9008](https://github.com/windmill-labs/windmill/issues/9008)) ([da95588](https://github.com/windmill-labs/windmill/commit/da95588b253e8bb2a06b9792674b4d691384f55a))
+* stop sequential whileloop on iteration failure ([#9028](https://github.com/windmill-labs/windmill/issues/9028)) ([1be62ea](https://github.com/windmill-labs/windmill/commit/1be62ea926872882ddbd4c8ce81502d6e341b8c1))
+
## [1.694.0](https://github.com/windmill-labs/windmill/compare/v1.693.4...v1.694.0) (2026-05-01)
diff --git a/REVIEW.md b/REVIEW.md
new file mode 100644
index 0000000000..de311cccc6
--- /dev/null
+++ b/REVIEW.md
@@ -0,0 +1,65 @@
+# Pull request review — shared policy
+
+You are reviewing a GitHub pull request for this repository. Apply this policy alongside your tool's output requirements.
+
+## Read the project rules first
+
+- Read `AGENTS.md` (repo root) and any `AGENTS.md` in directories touched by the diff before reviewing — they are the canonical contributor guide.
+- Quote the exact rule from `AGENTS.md` when flagging a violation.
+
+## Verdict (first line of the review)
+
+Start every review with a single verdict line, before any other section. Pick exactly one:
+
+- **Good to merge** — no blocking issues and no nits worth surfacing.
+- **Mergeable, but should ideally address nits: ** — no blockers, but P2 findings that are worth a look. The list must name each nit briefly (e.g. "doc/code mismatch in `foo.rs`, half-finished `pub fn bar`").
+- **Should address issues before merging: ** — at least one P0 or P1 finding. The list must name each blocking issue briefly (e.g. "missing auth check on new `/api/x` handler, SQL injection in `build_query`").
+
+The names in the list must match findings detailed later in the review. If you list a nit or issue here, it must appear with full context in the body. Do not invent items that aren't in the body, and do not bury blockers in the body without surfacing them in the verdict.
+
+## Review policy
+
+- Only report issues you are confident are real and introduced by this pull request.
+- Focus on bugs, security problems, performance, and clear `AGENTS.md` violations.
+- Do not report style nits, speculative concerns, pre-existing issues, or anything a normal linter / typechecker would obviously catch.
+- Self-validate each finding before posting: "is this definitely a real issue?" If uncertain, discard it.
+- Read additional files only when the diff is not enough to validate a finding.
+- Do not modify any files.
+
+## Severity triage
+
+Tag each finding with a severity. Always report P0 and P1. Report P2 only when the diff invites it (a new `pub fn`, a new module, a new exported component, a meaningful refactor).
+
+- **P0** — RCE, auth bypass, data loss, secrets in code, SQL injection, path traversal, broken auth on a public surface.
+- **P1** — significant bug, missing auth/authorization check on a new public surface, blocking I/O on a likely async path, race condition, missing input validation on caller-controlled parameters, observable performance regression.
+- **P2** — wrong module placement, doc/code mismatch, half-finished public abstractions (`pub fn` + `#[allow(dead_code)]` + `TODO`), `AGENTS.md` style violations, naming that contradicts the function's behavior.
+
+## Checklist for new public surfaces
+
+For any new `pub fn` / `pub async fn` / exported Svelte component / exported prop introduced by this PR, verify:
+
+- (a) auth/authorization expectations are documented in the doc comment OR enforced in the function body. A new `pub fn` that touches workspace data, secrets, files, or processes without an auth check or documented "caller MUST verify" contract is a P1.
+- (b) the function is placed in a module whose stated purpose matches what it does. Check the module-level doc comment (`//!`) — a config-file reader inside `external_ip.rs` is a P2.
+- (c) it is not half-finished. `pub fn` + `#[allow(dead_code)]` + a `TODO` is a smell that says the function should land together with its caller, not separately. Cite the relevant `AGENTS.md` rule.
+- (d) input validation defends against injection / traversal / overflow / NUL bytes at every parameter that may be caller-controlled.
+
+## Test coverage assessment
+
+End your review with a short "Test coverage" section calibrated to the layers actually changed by the diff. Skip categories the PR does not touch.
+
+- **Backend** (Rust under `backend/`) — expect Rust unit tests for new logic. For new or modified API handlers, worker steps, queue/cron behavior, or DB access, also expect or note the absence of integration tests. Pure-refactor backend PRs don't need new tests if existing tests cover the surface.
+- **Frontend** (Svelte / TS under `frontend/`) — the codebase does not generally test Svelte components, so do not ask for component tests. Only flag missing tests for new pure-logic utilities (the kind of file that already has a sibling `*.test.ts`, e.g. `flowDiff`, `previousResults`, copilot logic).
+- **CI / workflows / docs / config-only** — no automated tests expected; say so explicitly so the reader knows you considered it.
+
+Then state what manual verification, if any, is still needed before merge:
+
+- Describe each manual scenario as a short paragraph (not a numbered list): what page / action / input, and what observable outcome confirms correctness.
+- If the diff has no in-app surface to exercise (purely backend internals, CI, docs, or refactor), say that plainly.
+
+## Additional reviewer instructions
+
+If the prompt or context includes an "Additional reviewer instructions" section, treat it as extra guidance from the human who triggered this review and follow it.
+
+## Prior PR discussion
+
+If the prompt or context includes a "Prior PR discussion" section, this PR has already received review activity. Look for your own previous comment, take it into account, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed.
diff --git a/backend/.sqlx/query-04e584128fc74dd57ca7e6c3b59c446bc8610101e92d99c4733b79a1141444e8.json b/backend/.sqlx/query-04e584128fc74dd57ca7e6c3b59c446bc8610101e92d99c4733b79a1141444e8.json
new file mode 100644
index 0000000000..84919cc20e
--- /dev/null
+++ b/backend/.sqlx/query-04e584128fc74dd57ca7e6c3b59c446bc8610101e92d99c4733b79a1141444e8.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled,\n script_path, args, is_flow, email, timezone, summary, permissioned_as)\n VALUES\n ('test-workspace', 'f/sch/runtime_only', 'test-user', NOW(), '0 * * * * *', true,\n 'f/scripts/x', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user'),\n ('wm-fork-test-workspace', 'f/sch/runtime_only', 'test-user', NOW(), '0 * * * * *', false,\n 'f/scripts/x', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "04e584128fc74dd57ca7e6c3b59c446bc8610101e92d99c4733b79a1141444e8"
+}
diff --git a/backend/.sqlx/query-bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f.json b/backend/.sqlx/query-074dc85ffb596585eb99336ffa34fa29fc0a1aff4d10d6fe4aba4b5357afb0f3.json
similarity index 92%
rename from backend/.sqlx/query-bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f.json
rename to backend/.sqlx/query-074dc85ffb596585eb99336ffa34fa29fc0a1aff4d10d6fe4aba4b5357afb0f3.json
index 42b90ef229..a932976a2f 100644
--- a/backend/.sqlx/query-bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f.json
+++ b/backend/.sqlx/query-074dc85ffb596585eb99336ffa34fa29fc0a1aff4d10d6fe4aba4b5357afb0f3.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status) AS \"flow_status: Box\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ",
+ "query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status) AS \"flow_status: Box\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ",
"describe": {
"columns": [
{
@@ -108,5 +108,5 @@
false
]
},
- "hash": "bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f"
+ "hash": "074dc85ffb596585eb99336ffa34fa29fc0a1aff4d10d6fe4aba4b5357afb0f3"
}
diff --git a/backend/.sqlx/query-081e56844a17e37145b018eda8c9f2b3927a8d46c90959af4c38ff46b8f62e2d.json b/backend/.sqlx/query-081e56844a17e37145b018eda8c9f2b3927a8d46c90959af4c38ff46b8f62e2d.json
new file mode 100644
index 0000000000..73881e1590
--- /dev/null
+++ b/backend/.sqlx/query-081e56844a17e37145b018eda8c9f2b3927a8d46c90959af4c38ff46b8f62e2d.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled,\n script_path, args, is_flow, email, timezone, summary, permissioned_as)\n VALUES\n ('test-workspace', 'f/sch/config_change', 'test-user', NOW(), '0 * * * * *', false,\n 'f/scripts/parent_path', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user'),\n ('wm-fork-test-workspace', 'f/sch/config_change', 'test-user', NOW(), '0 * * * * *', false,\n 'f/scripts/fork_path', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "081e56844a17e37145b018eda8c9f2b3927a8d46c90959af4c38ff46b8f62e2d"
+}
diff --git a/backend/.sqlx/query-09713562019ede9622378e9d23f9bb36ad10832a79678723d9e6550b89383217.json b/backend/.sqlx/query-09713562019ede9622378e9d23f9bb36ad10832a79678723d9e6550b89383217.json
new file mode 100644
index 0000000000..4371654b99
--- /dev/null
+++ b/backend/.sqlx/query-09713562019ede9622378e9d23f9bb36ad10832a79678723d9e6550b89383217.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO http_trigger (workspace_id, path, edited_by, edited_at, route_path,\n route_path_key, script_path, is_flow, http_method, request_type,\n authentication_method, mode, permissioned_as)\n VALUES\n ('test-workspace', 'f/rt/config_change', 'test-user', NOW(), 'parent', 'parent',\n 'f/scripts/y', false, 'get', 'sync',\n 'none', 'disabled', 'u/test-user'),\n ('wm-fork-test-workspace', 'f/rt/config_change', 'test-user', NOW(), 'fork', 'fork',\n 'f/scripts/y', false, 'get', 'sync',\n 'none', 'disabled', 'u/test-user')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "09713562019ede9622378e9d23f9bb36ad10832a79678723d9e6550b89383217"
+}
diff --git a/backend/.sqlx/query-54b7d6614bdfae5d9113c1baf9eae6e1f600c0593ba855138b435561687905c5.json b/backend/.sqlx/query-097cdca7a20e174d4db1604c087d5d31e454eb6889c4a9bd6e857bd71d91d2bd.json
similarity index 50%
rename from backend/.sqlx/query-54b7d6614bdfae5d9113c1baf9eae6e1f600c0593ba855138b435561687905c5.json
rename to backend/.sqlx/query-097cdca7a20e174d4db1604c087d5d31e454eb6889c4a9bd6e857bd71d91d2bd.json
index 7657331ba1..e9d117caff 100644
--- a/backend/.sqlx/query-54b7d6614bdfae5d9113c1baf9eae6e1f600c0593ba855138b435561687905c5.json
+++ b/backend/.sqlx/query-097cdca7a20e174d4db1604c087d5d31e454eb6889c4a9bd6e857bd71d91d2bd.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\nSELECT COALESCE(\n (SELECT COUNT(*) \n FROM jsonb_object_keys(job_uuids) AS keys(key) \n WHERE key <> $2),\n 0\n)\nFROM concurrency_counter\nWHERE concurrency_id = $1\nFOR UPDATE",
+ "query": "\nSELECT COALESCE(\n (SELECT COUNT(*)\n FROM jsonb_object_keys(job_uuids) AS keys(key)\n WHERE key <> $2),\n 0\n)\nFROM concurrency_counter\nWHERE concurrency_id = $1\nFOR UPDATE",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
null
]
},
- "hash": "54b7d6614bdfae5d9113c1baf9eae6e1f600c0593ba855138b435561687905c5"
+ "hash": "097cdca7a20e174d4db1604c087d5d31e454eb6889c4a9bd6e857bd71d91d2bd"
}
diff --git a/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json b/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json
new file mode 100644
index 0000000000..7de0416a12
--- /dev/null
+++ b/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "item_kind",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "path",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87"
+}
diff --git a/backend/.sqlx/query-0c89ef278782f5a72b0b07ab3ba0edc487f03edd61936fcf77dee93fb22839ea.json b/backend/.sqlx/query-0c89ef278782f5a72b0b07ab3ba0edc487f03edd61936fcf77dee93fb22839ea.json
deleted file mode 100644
index e74e12f501..0000000000
--- a/backend/.sqlx/query-0c89ef278782f5a72b0b07ab3ba0edc487f03edd61936fcf77dee93fb22839ea.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT jsonb_build_object(\n 'kind', jb.kind,\n 'script_path', jb.runnable_path,\n 'latest_schema', COALESCE(\n (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.workspace_id = $1 AND s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),\n (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = jb.runnable_path AND jb.kind = 'flow')\n ),\n 'schemas', ARRAY(\n SELECT jsonb_build_object(\n 'script_hash', LPAD(TO_HEX(COALESCE(s.hash, f.id)), 16, '0'),\n 'job_ids', ARRAY_AGG(DISTINCT j.id),\n 'schema', (ARRAY_AGG(COALESCE(s.schema, f.schema)))[1]\n ) FROM v2_job j\n LEFT JOIN script s ON s.hash = j.runnable_id AND j.kind = 'script'\n LEFT JOIN flow_version f ON f.id = j.runnable_id AND j.kind = 'flow'\n WHERE j.id = ANY(ARRAY_AGG(jb.id))\n GROUP BY COALESCE(s.hash, f.id)\n )\n ) FROM v2_job jb\n WHERE (jb.kind = 'flow' OR jb.kind = 'script')\n AND jb.workspace_id = $1 AND jb.id = ANY($2)\n GROUP BY jb.kind, jb.runnable_path",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "jsonb_build_object",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "UuidArray"
- ]
- },
- "nullable": [
- null
- ]
- },
- "hash": "0c89ef278782f5a72b0b07ab3ba0edc487f03edd61936fcf77dee93fb22839ea"
-}
diff --git a/backend/.sqlx/query-14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d.json b/backend/.sqlx/query-14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d.json
new file mode 100644
index 0000000000..d7ef4e04e0
--- /dev/null
+++ b/backend/.sqlx/query-14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d.json
@@ -0,0 +1,24 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS (\n SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job, j.flow_innermost_root_job, j.runnable_id, j.runnable_path, j.raw_flow, c.depth + 1\n FROM v2_job j\n JOIN chain c\n ON j.id = COALESCE(c.flow_innermost_root_job, c.parent_job)\n WHERE j.workspace_id = $2 AND c.depth < $3\n )\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env'\n ELSE\n chain.raw_flow -> 'flow_env'\n END AS \"flow_env: Json>>\"\n FROM chain\n LEFT JOIN flow_version\n ON flow_version.id = chain.runnable_id\n AND flow_version.path = chain.runnable_path\n AND flow_version.workspace_id = $2\n WHERE (CASE\n WHEN flow_version.id IS NOT NULL THEN flow_version.value -> 'flow_env'\n ELSE chain.raw_flow -> 'flow_env'\n END) IS NOT NULL\n ORDER BY chain.depth ASC\n LIMIT 1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "flow_env: Json>>",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text",
+ "Int4"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "14c2784a68f06e7349941671abe5a9108b6f32490cb61b14de71102bb305f71d"
+}
diff --git a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json
deleted file mode 100644
index 41520bfc88..0000000000
--- a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n args as \"args: _\",\n created_at\n FROM v2_job\n WHERE workspace_id = $1\n AND (\n kind = 'unassigned_script'::JOB_KIND OR\n kind = 'unassigned_flow'::JOB_KIND OR\n kind = 'unassigned_singlestepflow'::JOB_KIND\n )\n AND trigger_kind = $2\n AND trigger = $3\n AND id = ANY($4)\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "args: _",
- "type_info": "Jsonb"
- },
- {
- "ordinal": 2,
- "name": "created_at",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- {
- "Custom": {
- "name": "job_trigger_kind",
- "kind": {
- "Enum": [
- "webhook",
- "http",
- "websocket",
- "kafka",
- "email",
- "nats",
- "schedule",
- "app",
- "ui",
- "postgres",
- "sqs",
- "gcp",
- "mqtt",
- "nextcloud",
- "google",
- "ci_test",
- "github",
- "azure"
- ]
- }
- }
- },
- "Text",
- "UuidArray"
- ]
- },
- "nullable": [
- false,
- true,
- false
- ]
- },
- "hash": "19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2"
-}
diff --git a/backend/.sqlx/query-ba9ab074f466bc2c581f018e2592f5a453e8a766c35dbf919d29c96966d63c75.json b/backend/.sqlx/query-25784f87ccf0bc13b93739d34d416908b75d0d17392c2565e70b4738039b56a1.json
similarity index 75%
rename from backend/.sqlx/query-ba9ab074f466bc2c581f018e2592f5a453e8a766c35dbf919d29c96966d63c75.json
rename to backend/.sqlx/query-25784f87ccf0bc13b93739d34d416908b75d0d17392c2565e70b4738039b56a1.json
index c7114f6591..8cae07c761 100644
--- a/backend/.sqlx/query-ba9ab074f466bc2c581f018e2592f5a453e8a766c35dbf919d29c96966d63c75.json
+++ b/backend/.sqlx/query-25784f87ccf0bc13b93739d34d416908b75d0d17392c2565e70b4738039b56a1.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM group_ WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
+ "query": "SELECT workspace_id, name, summary, extra_perms FROM group_ WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -38,5 +38,5 @@
false
]
},
- "hash": "ba9ab074f466bc2c581f018e2592f5a453e8a766c35dbf919d29c96966d63c75"
+ "hash": "25784f87ccf0bc13b93739d34d416908b75d0d17392c2565e70b4738039b56a1"
}
diff --git a/backend/.sqlx/query-45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7.json b/backend/.sqlx/query-27b243e7ff9838a8fc0a4a8e51ac5d33d8617fc75c0d9d08e428db488e0be409.json
similarity index 79%
rename from backend/.sqlx/query-45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7.json
rename to backend/.sqlx/query-27b243e7ff9838a8fc0a4a8e51ac5d33d8617fc75c0d9d08e428db488e0be409.json
index 12b8201ebb..ba1ffe0ac6 100644
--- a/backend/.sqlx/query-45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7.json
+++ b/backend/.sqlx/query-27b243e7ff9838a8fc0a4a8e51ac5d33d8617fc75c0d9d08e428db488e0be409.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'",
+ "query": "SELECT workspace_id, path, value, description, resource_type, extra_perms, created_by, edited_at, labels FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'",
"describe": {
"columns": [
{
@@ -35,13 +35,13 @@
},
{
"ordinal": 6,
- "name": "edited_at",
- "type_info": "Timestamptz"
+ "name": "created_by",
+ "type_info": "Varchar"
},
{
"ordinal": 7,
- "name": "created_by",
- "type_info": "Varchar"
+ "name": "edited_at",
+ "type_info": "Timestamptz"
},
{
"ordinal": 8,
@@ -66,5 +66,5 @@
true
]
},
- "hash": "45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7"
+ "hash": "27b243e7ff9838a8fc0a4a8e51ac5d33d8617fc75c0d9d08e428db488e0be409"
}
diff --git a/backend/.sqlx/query-d9c8f6ec7bd10e533876526255c15e376ccb4f898b9c0ab8840b2930bda24fdc.json b/backend/.sqlx/query-2c9a56fd46767c15dabc7813cd2b167a2f73a06d661d31c375697a68f7066aa9.json
similarity index 76%
rename from backend/.sqlx/query-d9c8f6ec7bd10e533876526255c15e376ccb4f898b9c0ab8840b2930bda24fdc.json
rename to backend/.sqlx/query-2c9a56fd46767c15dabc7813cd2b167a2f73a06d661d31c375697a68f7066aa9.json
index 463a3f32f0..7ca04a8311 100644
--- a/backend/.sqlx/query-d9c8f6ec7bd10e533876526255c15e376ccb4f898b9c0ab8840b2930bda24fdc.json
+++ b/backend/.sqlx/query-2c9a56fd46767c15dabc7813cd2b167a2f73a06d661d31c375697a68f7066aa9.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM group_ WHERE name = $1 AND workspace_id = $2",
+ "query": "SELECT workspace_id, name, summary, extra_perms FROM group_ WHERE name = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -37,5 +37,5 @@
false
]
},
- "hash": "d9c8f6ec7bd10e533876526255c15e376ccb4f898b9c0ab8840b2930bda24fdc"
+ "hash": "2c9a56fd46767c15dabc7813cd2b167a2f73a06d661d31c375697a68f7066aa9"
}
diff --git a/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json b/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json
deleted file mode 100644
index 8c5f43ab07..0000000000
--- a/backend/.sqlx/query-2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env' -> $3\n ELSE\n root_job.raw_flow -> 'flow_env' -> $3\n END AS \"flow_env: sqlx::types::Json>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "flow_env: sqlx::types::Json>",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid",
- "Text",
- "Text"
- ]
- },
- "nullable": [
- null
- ]
- },
- "hash": "2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943"
-}
diff --git a/backend/.sqlx/query-dbc7e74e259b502e700491ee0248e0c9c8c61e1bf609be60ac5dc5d438189353.json b/backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json
similarity index 68%
rename from backend/.sqlx/query-dbc7e74e259b502e700491ee0248e0c9c8c61e1bf609be60ac5dc5d438189353.json
rename to backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json
index 5791090fc1..a40cc12148 100644
--- a/backend/.sqlx/query-dbc7e74e259b502e700491ee0248e0c9c8c61e1bf609be60ac5dc5d438189353.json
+++ b/backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
+ "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
"describe": {
"columns": [
{
@@ -54,5 +54,5 @@
null
]
},
- "hash": "dbc7e74e259b502e700491ee0248e0c9c8c61e1bf609be60ac5dc5d438189353"
+ "hash": "3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb"
}
diff --git a/backend/.sqlx/query-3fa3d1fa1add8e187fcbaf7351b721ad0f3e2888af207e8830ccf5e921c5fd60.json b/backend/.sqlx/query-3fa3d1fa1add8e187fcbaf7351b721ad0f3e2888af207e8830ccf5e921c5fd60.json
deleted file mode 100644
index 3bb4151982..0000000000
--- a/backend/.sqlx/query-3fa3d1fa1add8e187fcbaf7351b721ad0f3e2888af207e8830ccf5e921c5fd60.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO concurrency_counter(concurrency_id, job_uuids) \n VALUES ($1, $2)\n ON CONFLICT (concurrency_id)\n DO UPDATE SET job_uuids = jsonb_set(concurrency_counter.job_uuids, array[$3], '{}')",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Varchar",
- "Jsonb",
- "Text"
- ]
- },
- "nullable": []
- },
- "hash": "3fa3d1fa1add8e187fcbaf7351b721ad0f3e2888af207e8830ccf5e921c5fd60"
-}
diff --git a/backend/.sqlx/query-447fb87252e831db13199b311a3aa0100b2a996115108316bbb2f5a16b270ea6.json b/backend/.sqlx/query-447fb87252e831db13199b311a3aa0100b2a996115108316bbb2f5a16b270ea6.json
new file mode 100644
index 0000000000..dc71dd37bd
--- /dev/null
+++ b/backend/.sqlx/query-447fb87252e831db13199b311a3aa0100b2a996115108316bbb2f5a16b270ea6.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO concurrency_key (job_id, key, ended_at) VALUES ($1, $2, $3)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Varchar",
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "447fb87252e831db13199b311a3aa0100b2a996115108316bbb2f5a16b270ea6"
+}
diff --git a/backend/.sqlx/query-44dc4a9fe7b51a184ed4599be8342d730a45b431b057cee66688256e455b62e3.json b/backend/.sqlx/query-44dc4a9fe7b51a184ed4599be8342d730a45b431b057cee66688256e455b62e3.json
new file mode 100644
index 0000000000..0aa56e6a3a
--- /dev/null
+++ b/backend/.sqlx/query-44dc4a9fe7b51a184ed4599be8342d730a45b431b057cee66688256e455b62e3.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM variable WHERE workspace_id = $1 AND path = ANY($2)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "TextArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "44dc4a9fe7b51a184ed4599be8342d730a45b431b057cee66688256e455b62e3"
+}
diff --git a/backend/.sqlx/query-03d63d2e64b012f624d2731b5bcb8849c74a9474777be61edf0ed43ddda07ef3.json b/backend/.sqlx/query-45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545.json
similarity index 81%
rename from backend/.sqlx/query-03d63d2e64b012f624d2731b5bcb8849c74a9474777be61edf0ed43ddda07ef3.json
rename to backend/.sqlx/query-45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545.json
index 31ec682852..e4db87ec7d 100644
--- a/backend/.sqlx/query-03d63d2e64b012f624d2731b5bcb8849c74a9474777be61edf0ed43ddda07ef3.json
+++ b/backend/.sqlx/query-45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
+ "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -25,13 +25,13 @@
},
{
"ordinal": 4,
- "name": "edited_at",
- "type_info": "Timestamptz"
+ "name": "created_by",
+ "type_info": "Varchar"
},
{
"ordinal": 5,
- "name": "created_by",
- "type_info": "Varchar"
+ "name": "edited_at",
+ "type_info": "Timestamptz"
},
{
"ordinal": 6,
@@ -46,7 +46,6 @@
],
"parameters": {
"Left": [
- "Text",
"Text"
]
},
@@ -61,5 +60,5 @@
false
]
},
- "hash": "03d63d2e64b012f624d2731b5bcb8849c74a9474777be61edf0ed43ddda07ef3"
+ "hash": "45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545"
}
diff --git a/backend/.sqlx/query-4a43d4df6c5b2e8dda4308dcb88c23caf312ec377dd91e5307f00d3fb8ec325d.json b/backend/.sqlx/query-4a43d4df6c5b2e8dda4308dcb88c23caf312ec377dd91e5307f00d3fb8ec325d.json
new file mode 100644
index 0000000000..2a6930755a
--- /dev/null
+++ b/backend/.sqlx/query-4a43d4df6c5b2e8dda4308dcb88c23caf312ec377dd91e5307f00d3fb8ec325d.json
@@ -0,0 +1,99 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n id,\n args as \"args: _\",\n created_at,\n kind AS \"kind: _\"\n FROM v2_job\n WHERE workspace_id = $1\n AND (\n kind = 'unassigned_script'::JOB_KIND OR\n kind = 'unassigned_flow'::JOB_KIND OR\n kind = 'unassigned_singlestepflow'::JOB_KIND\n )\n AND trigger_kind = $2\n AND trigger = $3\n AND id = ANY($4)\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "args: _",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 2,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 3,
+ "name": "kind: _",
+ "type_info": {
+ "Custom": {
+ "name": "job_kind",
+ "kind": {
+ "Enum": [
+ "script",
+ "preview",
+ "flow",
+ "dependencies",
+ "flowpreview",
+ "script_hub",
+ "identity",
+ "flowdependencies",
+ "http",
+ "graphql",
+ "postgresql",
+ "noop",
+ "appdependencies",
+ "deploymentcallback",
+ "singlestepflow",
+ "flowscript",
+ "flownode",
+ "appscript",
+ "aiagent",
+ "unassigned_script",
+ "unassigned_flow",
+ "unassigned_singlestepflow"
+ ]
+ }
+ }
+ }
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ {
+ "Custom": {
+ "name": "job_trigger_kind",
+ "kind": {
+ "Enum": [
+ "webhook",
+ "http",
+ "websocket",
+ "kafka",
+ "email",
+ "nats",
+ "schedule",
+ "app",
+ "ui",
+ "postgres",
+ "sqs",
+ "gcp",
+ "mqtt",
+ "nextcloud",
+ "google",
+ "ci_test",
+ "github",
+ "azure"
+ ]
+ }
+ }
+ },
+ "Text",
+ "UuidArray"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ false
+ ]
+ },
+ "hash": "4a43d4df6c5b2e8dda4308dcb88c23caf312ec377dd91e5307f00d3fb8ec325d"
+}
diff --git a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json b/backend/.sqlx/query-500b68d23314cb0f9edd570e576d5b9822bebe88f90ae007d943f2c761858190.json
similarity index 78%
rename from backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json
rename to backend/.sqlx/query-500b68d23314cb0f9edd570e576d5b9822bebe88f90ae007d943f2c761858190.json
index 79625b6baf..bb8731d445 100644
--- a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json
+++ b/backend/.sqlx/query-500b68d23314cb0f9edd570e576d5b9822bebe88f90ae007d943f2c761858190.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT *\n FROM usr\n WHERE workspace_id = $1\n ",
+ "query": "SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via FROM usr\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -47,11 +47,6 @@
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
- },
- {
- "ordinal": 9,
- "name": "is_service_account",
- "type_info": "Bool"
}
],
"parameters": {
@@ -68,9 +63,8 @@
false,
false,
true,
- true,
- false
+ true
]
},
- "hash": "5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c"
+ "hash": "500b68d23314cb0f9edd570e576d5b9822bebe88f90ae007d943f2c761858190"
}
diff --git a/backend/.sqlx/query-54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00.json b/backend/.sqlx/query-54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00.json
new file mode 100644
index 0000000000..962446787e
--- /dev/null
+++ b/backend/.sqlx/query-54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Varchar",
+ "Int8",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "54d3dd91f3348c03b8b39ecb59c85242186449ff592ed457daac59b37b94aa00"
+}
diff --git a/backend/.sqlx/query-5a96c213dbfb0106fe28c9c397ffdb5caf75062248e2f29cd9e4ac3180ee4b31.json b/backend/.sqlx/query-5a96c213dbfb0106fe28c9c397ffdb5caf75062248e2f29cd9e4ac3180ee4b31.json
new file mode 100644
index 0000000000..c2a617f3c8
--- /dev/null
+++ b/backend/.sqlx/query-5a96c213dbfb0106fe28c9c397ffdb5caf75062248e2f29cd9e4ac3180ee4b31.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO usr(workspace_id, email, username, is_admin, role)\n VALUES ('wm-fork-test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "5a96c213dbfb0106fe28c9c397ffdb5caf75062248e2f29cd9e4ac3180ee4b31"
+}
diff --git a/backend/.sqlx/query-5c705ea49ceda8b281155e38ebdb91e3896009a047286747a9c9f4e8e0192e4a.json b/backend/.sqlx/query-5c705ea49ceda8b281155e38ebdb91e3896009a047286747a9c9f4e8e0192e4a.json
new file mode 100644
index 0000000000..85077b883b
--- /dev/null
+++ b/backend/.sqlx/query-5c705ea49ceda8b281155e38ebdb91e3896009a047286747a9c9f4e8e0192e4a.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO ws_specific (workspace_id, item_kind, path) VALUES ($1, 'variable', $2) ON CONFLICT DO NOTHING",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Varchar"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "5c705ea49ceda8b281155e38ebdb91e3896009a047286747a9c9f4e8e0192e4a"
+}
diff --git a/backend/.sqlx/query-7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234.json b/backend/.sqlx/query-623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4.json
similarity index 78%
rename from backend/.sqlx/query-7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234.json
rename to backend/.sqlx/query-623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4.json
index 29f4baacc2..d2084d76d8 100644
--- a/backend/.sqlx/query-7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234.json
+++ b/backend/.sqlx/query-623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM resource_type WHERE workspace_id = $1",
+ "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
"describe": {
"columns": [
{
@@ -25,13 +25,13 @@
},
{
"ordinal": 4,
- "name": "edited_at",
- "type_info": "Timestamptz"
+ "name": "created_by",
+ "type_info": "Varchar"
},
{
"ordinal": 5,
- "name": "created_by",
- "type_info": "Varchar"
+ "name": "edited_at",
+ "type_info": "Timestamptz"
},
{
"ordinal": 6,
@@ -46,6 +46,7 @@
],
"parameters": {
"Left": [
+ "Text",
"Text"
]
},
@@ -60,5 +61,5 @@
false
]
},
- "hash": "7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234"
+ "hash": "623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4"
}
diff --git a/backend/.sqlx/query-69378789e2d14778812c8d6f1a6cb2cb3345869afea8a82b8416f762aacf5dd9.json b/backend/.sqlx/query-69378789e2d14778812c8d6f1a6cb2cb3345869afea8a82b8416f762aacf5dd9.json
new file mode 100644
index 0000000000..dcb44dbb90
--- /dev/null
+++ b/backend/.sqlx/query-69378789e2d14778812c8d6f1a6cb2cb3345869afea8a82b8416f762aacf5dd9.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "TextArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "69378789e2d14778812c8d6f1a6cb2cb3345869afea8a82b8416f762aacf5dd9"
+}
diff --git a/backend/.sqlx/query-6a7e0a5ef270d7effeccbb8f9edba6adcb5170d2825644fb02f6a1a57447f4fb.json b/backend/.sqlx/query-6a7e0a5ef270d7effeccbb8f9edba6adcb5170d2825644fb02f6a1a57447f4fb.json
new file mode 100644
index 0000000000..9c35d02b73
--- /dev/null
+++ b/backend/.sqlx/query-6a7e0a5ef270d7effeccbb8f9edba6adcb5170d2825644fb02f6a1a57447f4fb.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "6a7e0a5ef270d7effeccbb8f9edba6adcb5170d2825644fb02f6a1a57447f4fb"
+}
diff --git a/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json
new file mode 100644
index 0000000000..0a39db6822
--- /dev/null
+++ b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "exists",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4"
+}
diff --git a/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json b/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json
new file mode 100644
index 0000000000..e8f746ded0
--- /dev/null
+++ b/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json
@@ -0,0 +1,89 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "WITH norm AS (\n SELECT\n j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args,\n -- Project effective kind for dispatch: pass script/flow through;\n -- for singlestepflow, read the wrapped runnable's type from\n -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').\n CASE\n WHEN j.kind IN ('script', 'flow') THEN j.kind::text\n WHEN j.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n LIMIT 1),\n 'script'\n )\n END AS norm_kind,\n -- Pinned script hash for script-wrapped singlestepflow lives in\n -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a\n -- version, so this is NULL there (Flow rerun pushes by path).\n (CASE WHEN j.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END) AS ssf_hash\n FROM v2_job j\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND j.kind IN ('script', 'flow', 'singlestepflow')\n )\n SELECT\n n.id,\n n.norm_kind::JOB_KIND AS \"kind!: _\",\n COALESCE(s.path, f.path, n.runnable_path) AS \"script_path!\",\n -- script_hash is unused on the Flow rerun path (path-based push), so\n -- 0 is a safe placeholder when no version is pinned.\n COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n n.args AS input,\n -- Pinned schema for script/flow; latest-by-path fallback for\n -- singlestepflow so input_transforms still resolve at rerun time.\n COALESCE(\n s.schema,\n f.schema,\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN\n (SELECT s2.schema FROM script s2\n WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path\n ORDER BY s2.created_at DESC LIMIT 1)\n END),\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow\n LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)\n END)\n ) AS \"schema: _\"\n FROM norm n\n LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'\n LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = n.id\n LEFT JOIN v2_job_queue jq ON jq.id = n.id\n WHERE n.norm_kind IS NOT NULL\n AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL\n AND (\n n.kind = 'singlestepflow'\n OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)\n )",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "kind!: _",
+ "type_info": {
+ "Custom": {
+ "name": "job_kind",
+ "kind": {
+ "Enum": [
+ "script",
+ "preview",
+ "flow",
+ "dependencies",
+ "flowpreview",
+ "script_hub",
+ "identity",
+ "flowdependencies",
+ "http",
+ "graphql",
+ "postgresql",
+ "noop",
+ "appdependencies",
+ "deploymentcallback",
+ "singlestepflow",
+ "flowscript",
+ "flownode",
+ "appscript",
+ "aiagent",
+ "unassigned_script",
+ "unassigned_flow",
+ "unassigned_singlestepflow"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "script_path!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "script_hash!: _",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 4,
+ "name": "scheduled_for!: _",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "input",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 6,
+ "name": "schema: _",
+ "type_info": "Json"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "UuidArray",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ null,
+ null,
+ null,
+ null,
+ true,
+ null
+ ]
+ },
+ "hash": "6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada"
+}
diff --git a/backend/.sqlx/query-6d144520b894d21e4cede9370393926fbb892cbe730112c645f11455a2702de1.json b/backend/.sqlx/query-6d144520b894d21e4cede9370393926fbb892cbe730112c645f11455a2702de1.json
new file mode 100644
index 0000000000..379c026f5a
--- /dev/null
+++ b/backend/.sqlx/query-6d144520b894d21e4cede9370393926fbb892cbe730112c645f11455a2702de1.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO workspace_key(workspace_id, kind, key)\n VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "6d144520b894d21e4cede9370393926fbb892cbe730112c645f11455a2702de1"
+}
diff --git a/backend/.sqlx/query-ce9e56ff451bae10af2c396352f5f93f78658e57b79dc5295553cacc328eb2b7.json b/backend/.sqlx/query-6f95d6927a9be75f6c371c799ebe3755142647d0257b76e9befd0c4f31c332b1.json
similarity index 70%
rename from backend/.sqlx/query-ce9e56ff451bae10af2c396352f5f93f78658e57b79dc5295553cacc328eb2b7.json
rename to backend/.sqlx/query-6f95d6927a9be75f6c371c799ebe3755142647d0257b76e9befd0c4f31c332b1.json
index b64a1eb468..c97cc9ff8d 100644
--- a/backend/.sqlx/query-ce9e56ff451bae10af2c396352f5f93f78658e57b79dc5295553cacc328eb2b7.json
+++ b/backend/.sqlx/query-6f95d6927a9be75f6c371c799ebe3755142647d0257b76e9befd0c4f31c332b1.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM config WHERE name LIKE 'worker__%'",
+ "query": "SELECT name, config FROM config WHERE name LIKE 'worker__%'",
"describe": {
"columns": [
{
@@ -22,5 +22,5 @@
true
]
},
- "hash": "ce9e56ff451bae10af2c396352f5f93f78658e57b79dc5295553cacc328eb2b7"
+ "hash": "6f95d6927a9be75f6c371c799ebe3755142647d0257b76e9befd0c4f31c332b1"
}
diff --git a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json b/backend/.sqlx/query-704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84.json
similarity index 81%
rename from backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json
rename to backend/.sqlx/query-704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84.json
index 7be961c050..9f80167752 100644
--- a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json
+++ b/backend/.sqlx/query-704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM usr\n WHERE workspace_id = $1",
+ "query": "\n SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account\n FROM usr\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -72,5 +72,5 @@
false
]
},
- "hash": "e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2"
+ "hash": "704d873d4585e00aa0a88cc9949d3a6cc806b33a844b8e0e600cb8bfae428e84"
}
diff --git a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json
deleted file mode 100644
index b705130ffe..0000000000
--- a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n args as \"args: _\",\n created_at\n FROM v2_job\n WHERE workspace_id = $1\n AND (\n kind = 'unassigned_script'::JOB_KIND OR\n kind = 'unassigned_flow'::JOB_KIND OR\n kind = 'unassigned_singlestepflow'::JOB_KIND\n )\n AND trigger_kind = $2\n AND trigger = $3\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "args: _",
- "type_info": "Jsonb"
- },
- {
- "ordinal": 2,
- "name": "created_at",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- {
- "Custom": {
- "name": "job_trigger_kind",
- "kind": {
- "Enum": [
- "webhook",
- "http",
- "websocket",
- "kafka",
- "email",
- "nats",
- "schedule",
- "app",
- "ui",
- "postgres",
- "sqs",
- "gcp",
- "mqtt",
- "nextcloud",
- "google",
- "ci_test",
- "github",
- "azure"
- ]
- }
- }
- },
- "Text"
- ]
- },
- "nullable": [
- false,
- true,
- false
- ]
- },
- "hash": "757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c"
-}
diff --git a/backend/.sqlx/query-79e56c3cf21dbc57193358ec79393fc669d73448552f68f83caf32cfb0bf4be0.json b/backend/.sqlx/query-79e56c3cf21dbc57193358ec79393fc669d73448552f68f83caf32cfb0bf4be0.json
new file mode 100644
index 0000000000..b07fe6faf7
--- /dev/null
+++ b/backend/.sqlx/query-79e56c3cf21dbc57193358ec79393fc669d73448552f68f83caf32cfb0bf4be0.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO concurrency_counter(concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT DO NOTHING",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "79e56c3cf21dbc57193358ec79393fc669d73448552f68f83caf32cfb0bf4be0"
+}
diff --git a/backend/.sqlx/query-7e11b36055ed15a8f3a68511bc15b52aaf1f79874b3369e7a01171491f6d9083.json b/backend/.sqlx/query-7e11b36055ed15a8f3a68511bc15b52aaf1f79874b3369e7a01171491f6d9083.json
new file mode 100644
index 0000000000..253dde7a8e
--- /dev/null
+++ b/backend/.sqlx/query-7e11b36055ed15a8f3a68511bc15b52aaf1f79874b3369e7a01171491f6d9083.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM ws_specific\n WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "TextArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "7e11b36055ed15a8f3a68511bc15b52aaf1f79874b3369e7a01171491f6d9083"
+}
diff --git a/backend/.sqlx/query-864f73293d110474bd5544afc92f9060c7677542b0ce3240df214935dccbf509.json b/backend/.sqlx/query-864f73293d110474bd5544afc92f9060c7677542b0ce3240df214935dccbf509.json
new file mode 100644
index 0000000000..c53aac2abb
--- /dev/null
+++ b/backend/.sqlx/query-864f73293d110474bd5544afc92f9060c7677542b0ce3240df214935dccbf509.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO concurrency_counter(concurrency_id, job_uuids)\n VALUES ($1, $2)\n ON CONFLICT (concurrency_id)\n DO UPDATE SET job_uuids = jsonb_set(concurrency_counter.job_uuids, array[$3], '{}')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Jsonb",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "864f73293d110474bd5544afc92f9060c7677542b0ce3240df214935dccbf509"
+}
diff --git a/backend/.sqlx/query-8aab14a811b7a3f69107530c17564be4bcc58bcd262e8bb0637624a4ba455761.json b/backend/.sqlx/query-8aab14a811b7a3f69107530c17564be4bcc58bcd262e8bb0637624a4ba455761.json
new file mode 100644
index 0000000000..af15115a3c
--- /dev/null
+++ b/backend/.sqlx/query-8aab14a811b7a3f69107530c17564be4bcc58bcd262e8bb0637624a4ba455761.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO concurrency_counter(concurrency_id, job_uuids) VALUES ($1, $2)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "8aab14a811b7a3f69107530c17564be4bcc58bcd262e8bb0637624a4ba455761"
+}
diff --git a/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json b/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json
new file mode 100644
index 0000000000..415544ece9
--- /dev/null
+++ b/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "item_kind",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "path",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac"
+}
diff --git a/backend/.sqlx/query-8dbe7d38df3493958456bb9454681a20b92779d6c56b116c7189f3055ad15d35.json b/backend/.sqlx/query-8dbe7d38df3493958456bb9454681a20b92779d6c56b116c7189f3055ad15d35.json
new file mode 100644
index 0000000000..4ca7a134e3
--- /dev/null
+++ b/backend/.sqlx/query-8dbe7d38df3493958456bb9454681a20b92779d6c56b116c7189f3055ad15d35.json
@@ -0,0 +1,25 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT ws AS \"ws!\" FROM list_ws_specific_versions($1, $2, $3, $4)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "ws!",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "8dbe7d38df3493958456bb9454681a20b92779d6c56b116c7189f3055ad15d35"
+}
diff --git a/backend/.sqlx/query-8ed3be54f3ff8a22e1d3e583c1019357829e39659fbc4e5ba8885aef29da6e55.json b/backend/.sqlx/query-8ed3be54f3ff8a22e1d3e583c1019357829e39659fbc4e5ba8885aef29da6e55.json
new file mode 100644
index 0000000000..1420030cd6
--- /dev/null
+++ b/backend/.sqlx/query-8ed3be54f3ff8a22e1d3e583c1019357829e39659fbc4e5ba8885aef29da6e55.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO workspace (id, name, owner, parent_workspace_id)\n VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "8ed3be54f3ff8a22e1d3e583c1019357829e39659fbc4e5ba8885aef29da6e55"
+}
diff --git a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json b/backend/.sqlx/query-8f308f841b2d60a6be2bc4ae6aeae506781295a3bcf036dbbc8600f229e61408.json
similarity index 83%
rename from backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json
rename to backend/.sqlx/query-8f308f841b2d60a6be2bc4ae6aeae506781295a3bcf036dbbc8600f229e61408.json
index ed09f2833f..920ce385f3 100644
--- a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json
+++ b/backend/.sqlx/query-8f308f841b2d60a6be2bc4ae6aeae506781295a3bcf036dbbc8600f229e61408.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM usr WHERE username = $1 AND workspace_id = $2",
+ "query": "SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account FROM usr WHERE username = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -73,5 +73,5 @@
false
]
},
- "hash": "60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b"
+ "hash": "8f308f841b2d60a6be2bc4ae6aeae506781295a3bcf036dbbc8600f229e61408"
}
diff --git a/backend/.sqlx/query-41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0.json b/backend/.sqlx/query-9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8.json
similarity index 61%
rename from backend/.sqlx/query-41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0.json
rename to backend/.sqlx/query-9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8.json
index 78efa6ccbc..7959ccdbcb 100644
--- a/backend/.sqlx/query-41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0.json
+++ b/backend/.sqlx/query-9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT resource.*, (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 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 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 (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",
"describe": {
"columns": [
{
@@ -35,13 +35,13 @@
},
{
"ordinal": 6,
- "name": "edited_at",
- "type_info": "Timestamptz"
+ "name": "created_by",
+ "type_info": "Varchar"
},
{
"ordinal": 7,
- "name": "created_by",
- "type_info": "Varchar"
+ "name": "edited_at",
+ "type_info": "Timestamptz"
},
{
"ordinal": 8,
@@ -77,6 +77,11 @@
"ordinal": 14,
"name": "account",
"type_info": "Int4"
+ },
+ {
+ "ordinal": 15,
+ "name": "ws_specific",
+ "type_info": "Bool"
}
],
"parameters": {
@@ -100,8 +105,9 @@
true,
null,
false,
- true
+ true,
+ null
]
},
- "hash": "41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0"
+ "hash": "9254e2a0e1be830fa4cab660c69a659e2eb3f3d912e1c062bb8000a2e0a653e8"
}
diff --git a/backend/.sqlx/query-9db0ee31bfae5b3c6e4ba83bd3659e138529a62ad02b22e0afddd2887d6aad72.json b/backend/.sqlx/query-9db0ee31bfae5b3c6e4ba83bd3659e138529a62ad02b22e0afddd2887d6aad72.json
new file mode 100644
index 0000000000..44a033b19c
--- /dev/null
+++ b/backend/.sqlx/query-9db0ee31bfae5b3c6e4ba83bd3659e138529a62ad02b22e0afddd2887d6aad72.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES\n ('test-workspace', 'wm-fork-test-workspace', 'f/sch/runtime_only', 'schedule', 0, 1, NULL),\n ('test-workspace', 'wm-fork-test-workspace', 'f/sch/config_change', 'schedule', 0, 1, NULL),\n ('test-workspace', 'wm-fork-test-workspace', 'f/rt/runtime_only', 'http_trigger', 0, 1, NULL),\n ('test-workspace', 'wm-fork-test-workspace', 'f/rt/config_change', 'http_trigger', 0, 1, NULL)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "9db0ee31bfae5b3c6e4ba83bd3659e138529a62ad02b22e0afddd2887d6aad72"
+}
diff --git a/backend/.sqlx/query-9f978c08b3a0150bb6f6ef71a6c4a1d8b126495a514ea4c0e791216aef55dc38.json b/backend/.sqlx/query-9f978c08b3a0150bb6f6ef71a6c4a1d8b126495a514ea4c0e791216aef55dc38.json
new file mode 100644
index 0000000000..6819848a06
--- /dev/null
+++ b/backend/.sqlx/query-9f978c08b3a0150bb6f6ef71a6c4a1d8b126495a514ea4c0e791216aef55dc38.json
@@ -0,0 +1,59 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT kind AS \"kind: JobKind\", count(*) AS \"count!\"\n FROM v2_job\n WHERE workspace_id = $1\n AND id <> ALL($2)\n AND parent_job IS NULL\n GROUP BY kind\n ORDER BY kind::text",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "kind: JobKind",
+ "type_info": {
+ "Custom": {
+ "name": "job_kind",
+ "kind": {
+ "Enum": [
+ "script",
+ "preview",
+ "flow",
+ "dependencies",
+ "flowpreview",
+ "script_hub",
+ "identity",
+ "flowdependencies",
+ "http",
+ "graphql",
+ "postgresql",
+ "noop",
+ "appdependencies",
+ "deploymentcallback",
+ "singlestepflow",
+ "flowscript",
+ "flownode",
+ "appscript",
+ "aiagent",
+ "unassigned_script",
+ "unassigned_flow",
+ "unassigned_singlestepflow"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 1,
+ "name": "count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "UuidArray"
+ ]
+ },
+ "nullable": [
+ false,
+ null
+ ]
+ },
+ "hash": "9f978c08b3a0150bb6f6ef71a6c4a1d8b126495a514ea4c0e791216aef55dc38"
+}
diff --git a/backend/.sqlx/query-a1ab1f23f49496f745d89d6c33a91c6b693afc4477a634aa84f81db91f9e03a4.json b/backend/.sqlx/query-a1ab1f23f49496f745d89d6c33a91c6b693afc4477a634aa84f81db91f9e03a4.json
deleted file mode 100644
index 0c8209f0d0..0000000000
--- a/backend/.sqlx/query-a1ab1f23f49496f745d89d6c33a91c6b693afc4477a634aa84f81db91f9e03a4.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT COALESCE(\n (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),\n (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = jb.runnable_path AND jb.kind = 'flow')\n ) FROM v2_job jb\n WHERE jb.id = $1 AND jb.workspace_id = $2\n GROUP BY jb.kind, jb.runnable_path",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "coalesce",
- "type_info": "Json"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid",
- "Text"
- ]
- },
- "nullable": [
- null
- ]
- },
- "hash": "a1ab1f23f49496f745d89d6c33a91c6b693afc4477a634aa84f81db91f9e03a4"
-}
diff --git a/backend/.sqlx/query-edd57b3d59ddc21b99212b5fabd4a8b793c4ae618a04220b3609c8c3c168f8fd.json b/backend/.sqlx/query-a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665.json
similarity index 69%
rename from backend/.sqlx/query-edd57b3d59ddc21b99212b5fabd4a8b793c4ae618a04220b3609c8c3c168f8fd.json
rename to backend/.sqlx/query-a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665.json
index f67e52ab6b..2358af807f 100644
--- a/backend/.sqlx/query-edd57b3d59ddc21b99212b5fabd4a8b793c4ae618a04220b3609c8c3c168f8fd.json
+++ b/backend/.sqlx/query-a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ",
+ "query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
null
]
},
- "hash": "edd57b3d59ddc21b99212b5fabd4a8b793c4ae618a04220b3609c8c3c168f8fd"
+ "hash": "a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665"
}
diff --git a/backend/.sqlx/query-aba492cb21cbbd514959a16ca02ab3b62efb138edd968d90f10f9043ae9a7c62.json b/backend/.sqlx/query-aba492cb21cbbd514959a16ca02ab3b62efb138edd968d90f10f9043ae9a7c62.json
new file mode 100644
index 0000000000..291e8279fa
--- /dev/null
+++ b/backend/.sqlx/query-aba492cb21cbbd514959a16ca02ab3b62efb138edd968d90f10f9043ae9a7c62.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "WITH normalized AS (\n SELECT\n jb.id,\n jb.workspace_id,\n jb.runnable_path,\n CASE\n WHEN jb.kind IN ('flow', 'script') THEN jb.kind::text\n WHEN jb.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(jb.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n )\n ELSE NULL\n END AS norm_kind,\n COALESCE(\n jb.runnable_id,\n CASE WHEN jb.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(jb.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END\n ) AS effective_hash\n FROM v2_job jb\n WHERE jb.kind IN ('flow', 'script', 'singlestepflow')\n AND jb.workspace_id = $1 AND jb.id = ANY($2)\n )\n SELECT jsonb_build_object(\n 'kind', n.norm_kind,\n 'script_path', n.runnable_path,\n 'latest_schema', COALESCE(\n (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.workspace_id = $1 AND s.path = n.runnable_path AND n.norm_kind = 'script' ORDER BY s.path, s.created_at DESC),\n (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = n.runnable_path AND n.norm_kind = 'flow')\n ),\n 'schemas', ARRAY(\n SELECT jsonb_build_object(\n 'script_hash', CASE WHEN COALESCE(s.hash, f.id) IS NULL THEN NULL ELSE LPAD(TO_HEX(COALESCE(s.hash, f.id)), 16, '0') END,\n 'job_ids', ARRAY_AGG(DISTINCT n2.id),\n 'schema', COALESCE(\n (ARRAY_AGG(COALESCE(s.schema, f.schema)))[1],\n CASE WHEN n.norm_kind = 'script' THEN\n (SELECT DISTINCT ON (s2.path) s2.schema FROM script s2 WHERE s2.workspace_id = $1 AND s2.path = n.runnable_path ORDER BY s2.path, s2.created_at DESC)\n END,\n CASE WHEN n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = n.runnable_path)\n END\n )\n ) FROM normalized n2\n LEFT JOIN script s ON s.hash = n2.effective_hash AND n2.norm_kind = 'script'\n LEFT JOIN flow_version f ON f.id = n2.effective_hash AND n2.norm_kind = 'flow'\n WHERE n2.id = ANY(ARRAY_AGG(n.id))\n GROUP BY COALESCE(s.hash, f.id)\n )\n ) FROM normalized n\n WHERE n.norm_kind IS NOT NULL\n GROUP BY n.norm_kind, n.runnable_path",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "jsonb_build_object",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "UuidArray"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "aba492cb21cbbd514959a16ca02ab3b62efb138edd968d90f10f9043ae9a7c62"
+}
diff --git a/backend/.sqlx/query-b32d8b364001f5d54c1c4e564aac6b49d514771191a004c21c2e0d042f9d6f4d.json b/backend/.sqlx/query-b32d8b364001f5d54c1c4e564aac6b49d514771191a004c21c2e0d042f9d6f4d.json
new file mode 100644
index 0000000000..9704af007f
--- /dev/null
+++ b/backend/.sqlx/query-b32d8b364001f5d54c1c4e564aac6b49d514771191a004c21c2e0d042f9d6f4d.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT COALESCE(\n (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = norm.path AND s.workspace_id = $2 AND norm.kind = 'script' ORDER BY s.path, s.created_at DESC),\n (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = norm.path AND flow.workspace_id = $2 AND norm.kind = 'flow')\n ) FROM (\n SELECT\n jb.runnable_path AS path,\n CASE\n WHEN jb.kind IN ('script', 'flow') THEN jb.kind::text\n WHEN jb.kind = 'singlestepflow' THEN COALESCE(\n (SELECT m->'value'->>'type' FROM jsonb_array_elements(jb.raw_flow->'modules') m WHERE m->>'id' IN ('a', 'main') LIMIT 1),\n 'script'\n )\n END AS kind\n FROM v2_job jb\n WHERE jb.id = $1 AND jb.workspace_id = $2\n ) norm\n GROUP BY norm.kind, norm.path",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "coalesce",
+ "type_info": "Json"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "b32d8b364001f5d54c1c4e564aac6b49d514771191a004c21c2e0d042f9d6f4d"
+}
diff --git a/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json b/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json
new file mode 100644
index 0000000000..327032afb5
--- /dev/null
+++ b/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "exists",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb"
+}
diff --git a/backend/.sqlx/query-b620c0af0725a71fc777b39b016403751741b97a8bfc620174802ad3827bba39.json b/backend/.sqlx/query-b620c0af0725a71fc777b39b016403751741b97a8bfc620174802ad3827bba39.json
new file mode 100644
index 0000000000..c369c8c8a8
--- /dev/null
+++ b/backend/.sqlx/query-b620c0af0725a71fc777b39b016403751741b97a8bfc620174802ad3827bba39.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "b620c0af0725a71fc777b39b016403751741b97a8bfc620174802ad3827bba39"
+}
diff --git a/backend/.sqlx/query-bcfa34cf80abea05f0c24883b9e77429c51e6166c414bcc5ce2e97fac25bcd77.json b/backend/.sqlx/query-bcfa34cf80abea05f0c24883b9e77429c51e6166c414bcc5ce2e97fac25bcd77.json
new file mode 100644
index 0000000000..045d470de5
--- /dev/null
+++ b/backend/.sqlx/query-bcfa34cf80abea05f0c24883b9e77429c51e6166c414bcc5ce2e97fac25bcd77.json
@@ -0,0 +1,98 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n id,\n args as \"args: _\",\n created_at,\n kind AS \"kind: _\"\n FROM v2_job\n WHERE workspace_id = $1\n AND (\n kind = 'unassigned_script'::JOB_KIND OR\n kind = 'unassigned_flow'::JOB_KIND OR\n kind = 'unassigned_singlestepflow'::JOB_KIND\n )\n AND trigger_kind = $2\n AND trigger = $3\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "args: _",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 2,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 3,
+ "name": "kind: _",
+ "type_info": {
+ "Custom": {
+ "name": "job_kind",
+ "kind": {
+ "Enum": [
+ "script",
+ "preview",
+ "flow",
+ "dependencies",
+ "flowpreview",
+ "script_hub",
+ "identity",
+ "flowdependencies",
+ "http",
+ "graphql",
+ "postgresql",
+ "noop",
+ "appdependencies",
+ "deploymentcallback",
+ "singlestepflow",
+ "flowscript",
+ "flownode",
+ "appscript",
+ "aiagent",
+ "unassigned_script",
+ "unassigned_flow",
+ "unassigned_singlestepflow"
+ ]
+ }
+ }
+ }
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ {
+ "Custom": {
+ "name": "job_trigger_kind",
+ "kind": {
+ "Enum": [
+ "webhook",
+ "http",
+ "websocket",
+ "kafka",
+ "email",
+ "nats",
+ "schedule",
+ "app",
+ "ui",
+ "postgres",
+ "sqs",
+ "gcp",
+ "mqtt",
+ "nextcloud",
+ "google",
+ "ci_test",
+ "github",
+ "azure"
+ ]
+ }
+ }
+ },
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ false
+ ]
+ },
+ "hash": "bcfa34cf80abea05f0c24883b9e77429c51e6166c414bcc5ce2e97fac25bcd77"
+}
diff --git a/backend/.sqlx/query-c340adb3118a002ab700cf3f5eae0e9cf6940081b556832142dd757468ca823b.json b/backend/.sqlx/query-c340adb3118a002ab700cf3f5eae0e9cf6940081b556832142dd757468ca823b.json
new file mode 100644
index 0000000000..9cadbcac89
--- /dev/null
+++ b/backend/.sqlx/query-c340adb3118a002ab700cf3f5eae0e9cf6940081b556832142dd757468ca823b.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO concurrency_counter(concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "c340adb3118a002ab700cf3f5eae0e9cf6940081b556832142dd757468ca823b"
+}
diff --git a/backend/.sqlx/query-d233e07d19e8e339e1378c1bfc5d78d592c00ffb6f42c3d072f56305b40e50f9.json b/backend/.sqlx/query-c9064664829d304013920e3f710c4dfab99b263e5271045c542c34216e2b47cb.json
similarity index 73%
rename from backend/.sqlx/query-d233e07d19e8e339e1378c1bfc5d78d592c00ffb6f42c3d072f56305b40e50f9.json
rename to backend/.sqlx/query-c9064664829d304013920e3f710c4dfab99b263e5271045c542c34216e2b47cb.json
index b235a60c1a..1eb8cf3595 100644
--- a/backend/.sqlx/query-d233e07d19e8e339e1378c1bfc5d78d592c00ffb6f42c3d072f56305b40e50f9.json
+++ b/backend/.sqlx/query-c9064664829d304013920e3f710c4dfab99b263e5271045c542c34216e2b47cb.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * FROM config WHERE name = $1",
+ "query": "SELECT name, config FROM config WHERE name = $1",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
- "hash": "d233e07d19e8e339e1378c1bfc5d78d592c00ffb6f42c3d072f56305b40e50f9"
+ "hash": "c9064664829d304013920e3f710c4dfab99b263e5271045c542c34216e2b47cb"
}
diff --git a/backend/.sqlx/query-b8d392ccfcccafe0c19511b3567bc11779b1052b0948c410468a8aeba1d26d33.json b/backend/.sqlx/query-d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6.json
similarity index 79%
rename from backend/.sqlx/query-b8d392ccfcccafe0c19511b3567bc11779b1052b0948c410468a8aeba1d26d33.json
rename to backend/.sqlx/query-d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6.json
index 4871744e03..ffd1670c04 100644
--- a/backend/.sqlx/query-b8d392ccfcccafe0c19511b3567bc11779b1052b0948c410468a8aeba1d26d33.json
+++ b/backend/.sqlx/query-d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name",
+ "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name",
"describe": {
"columns": [
{
@@ -25,13 +25,13 @@
},
{
"ordinal": 4,
- "name": "edited_at",
- "type_info": "Timestamptz"
+ "name": "created_by",
+ "type_info": "Varchar"
},
{
"ordinal": 5,
- "name": "created_by",
- "type_info": "Varchar"
+ "name": "edited_at",
+ "type_info": "Timestamptz"
},
{
"ordinal": 6,
@@ -60,5 +60,5 @@
false
]
},
- "hash": "b8d392ccfcccafe0c19511b3567bc11779b1052b0948c410468a8aeba1d26d33"
+ "hash": "d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6"
}
diff --git a/backend/.sqlx/query-d1dabd750d2304107a981c992b3dc52cc9f471ce8e61a00132aed2dcce8653af.json b/backend/.sqlx/query-d1dabd750d2304107a981c992b3dc52cc9f471ce8e61a00132aed2dcce8653af.json
new file mode 100644
index 0000000000..1cc8d7fa64
--- /dev/null
+++ b/backend/.sqlx/query-d1dabd750d2304107a981c992b3dc52cc9f471ce8e61a00132aed2dcce8653af.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'variable' AND path = $3",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "d1dabd750d2304107a981c992b3dc52cc9f471ce8e61a00132aed2dcce8653af"
+}
diff --git a/backend/.sqlx/query-d487c1dd1f6456e11c30ac44e3828f3316d2b994d836b1145039ee7d77391628.json b/backend/.sqlx/query-d487c1dd1f6456e11c30ac44e3828f3316d2b994d836b1145039ee7d77391628.json
new file mode 100644
index 0000000000..0a3a7084dd
--- /dev/null
+++ b/backend/.sqlx/query-d487c1dd1f6456e11c30ac44e3828f3316d2b994d836b1145039ee7d77391628.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT COALESCE(\n (SELECT COUNT(*)\n FROM jsonb_object_keys(job_uuids) AS keys(key)\n WHERE key <> $2),\n 0\n )\n FROM concurrency_counter\n WHERE concurrency_id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "coalesce",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "d487c1dd1f6456e11c30ac44e3828f3316d2b994d836b1145039ee7d77391628"
+}
diff --git a/backend/.sqlx/query-eb1f7f01461f5a7540c273b37e5d578c31cf151ab3ef813f7aada76533761e12.json b/backend/.sqlx/query-e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3.json
similarity index 82%
rename from backend/.sqlx/query-eb1f7f01461f5a7540c273b37e5d578c31cf151ab3ef813f7aada76533761e12.json
rename to backend/.sqlx/query-e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3.json
index ce532af385..c44d3d711d 100644
--- a/backend/.sqlx/query-eb1f7f01461f5a7540c273b37e5d578c31cf151ab3ef813f7aada76533761e12.json
+++ b/backend/.sqlx/query-e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT * from resource_type ORDER BY name",
+ "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name",
"describe": {
"columns": [
{
@@ -25,13 +25,13 @@
},
{
"ordinal": 4,
- "name": "edited_at",
- "type_info": "Timestamptz"
+ "name": "created_by",
+ "type_info": "Varchar"
},
{
"ordinal": 5,
- "name": "created_by",
- "type_info": "Varchar"
+ "name": "edited_at",
+ "type_info": "Timestamptz"
},
{
"ordinal": 6,
@@ -58,5 +58,5 @@
false
]
},
- "hash": "eb1f7f01461f5a7540c273b37e5d578c31cf151ab3ef813f7aada76533761e12"
+ "hash": "e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3"
}
diff --git a/backend/.sqlx/query-e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7.json b/backend/.sqlx/query-e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7.json
deleted file mode 100644
index cab697d254..0000000000
--- a/backend/.sqlx/query-e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7.json
+++ /dev/null
@@ -1,89 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT\n j.id,\n j.kind AS \"kind: _\",\n COALESCE(s.path, f.path) AS \"script_path!\",\n COALESCE(s.hash, f.id) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n args AS input,\n COALESCE(s.schema, f.schema) AS \"schema: _\"\n FROM v2_job j\n LEFT JOIN script s ON j.runnable_id = s.hash AND j.kind = 'script'\n LEFT JOIN flow_version f ON j.runnable_id = f.id AND j.runnable_path = f.path AND j.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = j.id\n LEFT JOIN v2_job_queue jq ON jq.id = j.id\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND COALESCE(s.hash, f.id) IS NOT NULL\n AND COALESCE(s.path, f.path) IS NOT NULL",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "kind: _",
- "type_info": {
- "Custom": {
- "name": "job_kind",
- "kind": {
- "Enum": [
- "script",
- "preview",
- "flow",
- "dependencies",
- "flowpreview",
- "script_hub",
- "identity",
- "flowdependencies",
- "http",
- "graphql",
- "postgresql",
- "noop",
- "appdependencies",
- "deploymentcallback",
- "singlestepflow",
- "flowscript",
- "flownode",
- "appscript",
- "aiagent",
- "unassigned_script",
- "unassigned_flow",
- "unassigned_singlestepflow"
- ]
- }
- }
- }
- },
- {
- "ordinal": 2,
- "name": "script_path!",
- "type_info": "Varchar"
- },
- {
- "ordinal": 3,
- "name": "script_hash!: _",
- "type_info": "Int8"
- },
- {
- "ordinal": 4,
- "name": "scheduled_for!: _",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 5,
- "name": "input",
- "type_info": "Jsonb"
- },
- {
- "ordinal": 6,
- "name": "schema: _",
- "type_info": "Json"
- }
- ],
- "parameters": {
- "Left": [
- "UuidArray",
- "Text"
- ]
- },
- "nullable": [
- false,
- false,
- null,
- null,
- null,
- true,
- null
- ]
- },
- "hash": "e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7"
-}
diff --git a/backend/.sqlx/query-e4d2a19d56e561e903c883d503f1864734ae5329e31a39d7af7bad4e3e2fe5b2.json b/backend/.sqlx/query-e4d2a19d56e561e903c883d503f1864734ae5329e31a39d7af7bad4e3e2fe5b2.json
new file mode 100644
index 0000000000..39e187e227
--- /dev/null
+++ b/backend/.sqlx/query-e4d2a19d56e561e903c883d503f1864734ae5329e31a39d7af7bad4e3e2fe5b2.json
@@ -0,0 +1,42 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n EXISTS(SELECT 1 FROM ws_specific\n WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $3) AS \"src_ws!\",\n EXISTS(SELECT 1 FROM ws_specific\n WHERE workspace_id = $2 AND item_kind = 'variable' AND path = $3) AS \"tgt_ws!\",\n EXISTS(SELECT 1 FROM variable\n WHERE workspace_id = $1 AND path = $3) AS \"src_var!\",\n EXISTS(SELECT 1 FROM variable\n WHERE workspace_id = $2 AND path = $3) AS \"tgt_var!\"",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "src_ws!",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 1,
+ "name": "tgt_ws!",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 2,
+ "name": "src_var!",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 3,
+ "name": "tgt_var!",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null,
+ null,
+ null,
+ null
+ ]
+ },
+ "hash": "e4d2a19d56e561e903c883d503f1864734ae5329e31a39d7af7bad4e3e2fe5b2"
+}
diff --git a/backend/.sqlx/query-e59a15166ccacc601d059f2da379fe3ecc4cf60e4a928acaaa78da3ed65949b7.json b/backend/.sqlx/query-e59a15166ccacc601d059f2da379fe3ecc4cf60e4a928acaaa78da3ed65949b7.json
new file mode 100644
index 0000000000..4b0102591d
--- /dev/null
+++ b/backend/.sqlx/query-e59a15166ccacc601d059f2da379fe3ecc4cf60e4a928acaaa78da3ed65949b7.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT COALESCE((SELECT COUNT(*) FROM jsonb_object_keys(job_uuids)), 0)\n FROM concurrency_counter WHERE concurrency_id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "coalesce",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "e59a15166ccacc601d059f2da379fe3ecc4cf60e4a928acaaa78da3ed65949b7"
+}
diff --git a/backend/.sqlx/query-e8597d72fc73446d6ac1e76dffcbc6a1c77e754825b59ca4bc79c4e675bed8e5.json b/backend/.sqlx/query-e8597d72fc73446d6ac1e76dffcbc6a1c77e754825b59ca4bc79c4e675bed8e5.json
new file mode 100644
index 0000000000..b67b639a79
--- /dev/null
+++ b/backend/.sqlx/query-e8597d72fc73446d6ac1e76dffcbc6a1c77e754825b59ca4bc79c4e675bed8e5.json
@@ -0,0 +1,58 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT kind AS \"kind: JobKind\", args::text AS \"args!\"\n FROM v2_job WHERE id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "kind: JobKind",
+ "type_info": {
+ "Custom": {
+ "name": "job_kind",
+ "kind": {
+ "Enum": [
+ "script",
+ "preview",
+ "flow",
+ "dependencies",
+ "flowpreview",
+ "script_hub",
+ "identity",
+ "flowdependencies",
+ "http",
+ "graphql",
+ "postgresql",
+ "noop",
+ "appdependencies",
+ "deploymentcallback",
+ "singlestepflow",
+ "flowscript",
+ "flownode",
+ "appscript",
+ "aiagent",
+ "unassigned_script",
+ "unassigned_flow",
+ "unassigned_singlestepflow"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 1,
+ "name": "args!",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ null
+ ]
+ },
+ "hash": "e8597d72fc73446d6ac1e76dffcbc6a1c77e754825b59ca4bc79c4e675bed8e5"
+}
diff --git a/backend/.sqlx/query-bef2776351e8489559609d390b92d688519e8af27b228202c872061cbda7e30a.json b/backend/.sqlx/query-e9cd99ea990f5aabf1278856c2bf4395c98b8858b686ea9f30e77bfb00c7bf9c.json
similarity index 74%
rename from backend/.sqlx/query-bef2776351e8489559609d390b92d688519e8af27b228202c872061cbda7e30a.json
rename to backend/.sqlx/query-e9cd99ea990f5aabf1278856c2bf4395c98b8858b686ea9f30e77bfb00c7bf9c.json
index d75ffd0339..7050274345 100644
--- a/backend/.sqlx/query-bef2776351e8489559609d390b92d688519e8af27b228202c872061cbda7e30a.json
+++ b/backend/.sqlx/query-e9cd99ea990f5aabf1278856c2bf4395c98b8858b686ea9f30e77bfb00c7bf9c.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT scheduled_for FROM v2_job_queue INNER JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id\n WHERE key = $1 AND running = false AND canceled_by IS NULL AND scheduled_for >= $2",
+ "query": "SELECT scheduled_for FROM v2_job_queue INNER JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id\n WHERE key = $1 AND running = false AND canceled_by IS NULL AND scheduled_for >= $2\n ORDER BY scheduled_for ASC\n LIMIT 1000",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
false
]
},
- "hash": "bef2776351e8489559609d390b92d688519e8af27b228202c872061cbda7e30a"
+ "hash": "e9cd99ea990f5aabf1278856c2bf4395c98b8858b686ea9f30e77bfb00c7bf9c"
}
diff --git a/backend/.sqlx/query-ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1.json b/backend/.sqlx/query-ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1.json
new file mode 100644
index 0000000000..593408b1d4
--- /dev/null
+++ b/backend/.sqlx/query-ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Varchar",
+ "Varchar",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "ea132d80fdb6525f192797fd77b3f5343e68856dfede19597813893b7e99ead1"
+}
diff --git a/backend/.sqlx/query-750f09238caf114d01f752a145fd4289f04f3961d5035d42f3fd0bd2f87eed1a.json b/backend/.sqlx/query-f6eff53e00b33310bd9626b44b72af91a2da474ddf622f15f4f618dfcc7f1c39.json
similarity index 82%
rename from backend/.sqlx/query-750f09238caf114d01f752a145fd4289f04f3961d5035d42f3fd0bd2f87eed1a.json
rename to backend/.sqlx/query-f6eff53e00b33310bd9626b44b72af91a2da474ddf622f15f4f618dfcc7f1c39.json
index c72f20178c..6829ce231d 100644
--- a/backend/.sqlx/query-750f09238caf114d01f752a145fd4289f04f3961d5035d42f3fd0bd2f87eed1a.json
+++ b/backend/.sqlx/query-f6eff53e00b33310bd9626b44b72af91a2da474ddf622f15f4f618dfcc7f1c39.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n UPDATE v2_job_status f SET flow_status = JSONB_SET(flow_status, ARRAY['user_states'], JSONB_SET(COALESCE(flow_status->'user_states', '{}'::jsonb), ARRAY[$1], $2))\n FROM v2_job j\n WHERE f.id = $3 AND f.id = j.id AND j.workspace_id = $4 AND kind IN ('flow', 'flowpreview', 'flownode') RETURNING 1\n ",
+ "query": "\n UPDATE v2_job_status f SET flow_status = JSONB_SET(flow_status, ARRAY['user_states'], JSONB_SET(COALESCE(flow_status->'user_states', '{}'::jsonb), ARRAY[$1], $2))\n FROM v2_job j\n WHERE f.id = $3 AND f.id = j.id AND j.workspace_id = $4 AND kind IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') RETURNING 1\n ",
"describe": {
"columns": [
{
@@ -21,5 +21,5 @@
null
]
},
- "hash": "750f09238caf114d01f752a145fd4289f04f3961d5035d42f3fd0bd2f87eed1a"
+ "hash": "f6eff53e00b33310bd9626b44b72af91a2da474ddf622f15f4f618dfcc7f1c39"
}
diff --git a/backend/.sqlx/query-f7e273ab42c1632ffe4d127b447813046e86b8768a276f1b724d32721424fa08.json b/backend/.sqlx/query-f7e273ab42c1632ffe4d127b447813046e86b8768a276f1b724d32721424fa08.json
new file mode 100644
index 0000000000..dee0944da5
--- /dev/null
+++ b/backend/.sqlx/query-f7e273ab42c1632ffe4d127b447813046e86b8768a276f1b724d32721424fa08.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM ws_specific\n WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "f7e273ab42c1632ffe4d127b447813046e86b8768a276f1b724d32721424fa08"
+}
diff --git a/backend/.sqlx/query-fdcfa286819f77f9e147599036ef4bd26057dc6792a9952678e5b785e4fff9a4.json b/backend/.sqlx/query-fdcfa286819f77f9e147599036ef4bd26057dc6792a9952678e5b785e4fff9a4.json
new file mode 100644
index 0000000000..b1f15f4c06
--- /dev/null
+++ b/backend/.sqlx/query-fdcfa286819f77f9e147599036ef4bd26057dc6792a9952678e5b785e4fff9a4.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO http_trigger (workspace_id, path, edited_by, edited_at, route_path,\n route_path_key, script_path, is_flow, http_method, request_type,\n authentication_method, mode, permissioned_as)\n VALUES\n ('test-workspace', 'f/rt/runtime_only', 'test-user', NOW(), 'foo', 'foo',\n 'f/scripts/y', false, 'get', 'sync',\n 'none', 'enabled', 'u/test-user'),\n ('wm-fork-test-workspace', 'f/rt/runtime_only', 'test-user', NOW(), 'foo', 'foo',\n 'f/scripts/y', false, 'get', 'sync',\n 'none', 'disabled', 'u/test-user')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "fdcfa286819f77f9e147599036ef4bd26057dc6792a9952678e5b785e4fff9a4"
+}
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index ba2f0f37b8..c6bf144004 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1221,7 +1221,7 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
- "h2 0.4.13",
+ "h2 0.4.14",
"http 0.2.12",
"http 1.4.0",
"http-body 0.4.6",
@@ -2836,6 +2836,21 @@ dependencies = [
"cmov",
]
+[[package]]
+name = "curl-sys"
+version = "0.4.88+curl-8.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "644816de6547255eff4e491a1dda1c19b7237f00b62a61e6e64859ce4f2906d0"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -4001,7 +4016,7 @@ dependencies = [
"deno_tls",
"dyn-clone",
"error_reporter",
- "h2 0.4.13",
+ "h2 0.4.14",
"hickory-resolver",
"http 1.4.0",
"http-body-util",
@@ -4282,7 +4297,7 @@ dependencies = [
"elliptic-curve",
"errno",
"faster-hex",
- "h2 0.4.13",
+ "h2 0.4.14",
"hkdf",
"http 1.4.0",
"http-body-util",
@@ -4738,7 +4753,7 @@ dependencies = [
"deno_permissions",
"deno_tls",
"fastwebsockets",
- "h2 0.4.13",
+ "h2 0.4.14",
"http 1.4.0",
"http-body-util",
"hyper 1.9.0",
@@ -5039,9 +5054,9 @@ dependencies = [
[[package]]
name = "digest"
-version = "0.11.2"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
@@ -6558,9 +6573,9 @@ dependencies = [
[[package]]
name = "h2"
-version = "0.4.13"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [
"atomic-waker",
"bytes",
@@ -6853,7 +6868,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
- "digest 0.11.2",
+ "digest 0.11.3",
]
[[package]]
@@ -7041,7 +7056,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2 0.4.13",
+ "h2 0.4.14",
"http 1.4.0",
"http-body 1.0.1",
"httparse",
@@ -7539,16 +7554,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "iri-string"
-version = "0.7.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
-dependencies = [
- "memchr",
- "serde",
-]
-
[[package]]
name = "is-macro"
version = "0.3.7"
@@ -8168,7 +8173,7 @@ dependencies = [
"bitflags 2.9.4",
"libc",
"plain",
- "redox_syscall 0.7.4",
+ "redox_syscall 0.7.5",
]
[[package]]
@@ -8542,7 +8547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
- "digest 0.11.2",
+ "digest 0.11.3",
]
[[package]]
@@ -9580,15 +9585,14 @@ dependencies = [
[[package]]
name = "openssl"
-version = "0.10.78"
+version = "0.10.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
+checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
- "once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -9627,9 +9631,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
-version = "0.9.114"
+version = "0.9.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
+checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
dependencies = [
"cc",
"libc",
@@ -10234,7 +10238,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
- "siphasher 1.0.2",
+ "siphasher 1.0.3",
]
[[package]]
@@ -10243,7 +10247,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
- "siphasher 1.0.2",
+ "siphasher 1.0.3",
]
[[package]]
@@ -10260,18 +10264,18 @@ dependencies = [
[[package]]
name = "pin-project"
-version = "1.1.11"
+version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
+checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
-version = "1.1.11"
+version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
+checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
dependencies = [
"proc-macro2",
"quote",
@@ -10633,9 +10637,9 @@ dependencies = [
[[package]]
name = "profiling"
-version = "1.0.17"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "prometheus"
@@ -11118,6 +11122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e234cf318915c1059d4921ef7f75616b5219b10b46e9f3a511a15eb4b56a3f77"
dependencies = [
"cmake",
+ "curl-sys",
"libc",
"libz-sys",
"num_enum",
@@ -11163,9 +11168,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.7.4"
+version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
+checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
dependencies = [
"bitflags 2.9.4",
]
@@ -11283,7 +11288,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "h2 0.4.13",
+ "h2 0.4.14",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
@@ -11331,7 +11336,7 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
- "h2 0.4.13",
+ "h2 0.4.14",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
@@ -11702,9 +11707,9 @@ dependencies = [
[[package]]
name = "rust_decimal"
-version = "1.41.0"
+version = "1.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
+checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
dependencies = [
"arrayvec",
"borsh",
@@ -12673,7 +12678,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
- "digest 0.11.2",
+ "digest 0.11.3",
]
[[package]]
@@ -12818,9 +12823,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "siphasher"
-version = "1.0.2"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "size"
@@ -14722,7 +14727,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"flate2",
- "h2 0.4.13",
+ "h2 0.4.14",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
@@ -14754,7 +14759,7 @@ dependencies = [
"axum 0.8.4",
"base64 0.22.1",
"bytes",
- "h2 0.4.13",
+ "h2 0.4.14",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
@@ -14832,9 +14837,9 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.8"
+version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"async-compression",
"base64 0.22.1",
@@ -14845,7 +14850,6 @@ dependencies = [
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
- "iri-string",
"mime",
"pin-project-lite",
"tokio",
@@ -14854,6 +14858,7 @@ dependencies = [
"tower-layer",
"tower-service",
"tracing",
+ "url",
]
[[package]]
@@ -16020,7 +16025,7 @@ dependencies = [
[[package]]
name = "windmill"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16101,7 +16106,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"async-trait",
"aws-config",
@@ -16125,7 +16130,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16138,7 +16143,7 @@ dependencies = [
[[package]]
name = "windmill-api"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"argon2",
@@ -16281,7 +16286,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16304,7 +16309,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16317,7 +16322,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16343,7 +16348,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16353,7 +16358,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16370,7 +16375,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"base64 0.22.1",
@@ -16392,7 +16397,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16415,7 +16420,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16431,7 +16436,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16452,7 +16457,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16473,7 +16478,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16487,7 +16492,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16519,7 +16524,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16544,7 +16549,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"flate2",
@@ -16562,7 +16567,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16584,7 +16589,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16604,7 +16609,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16634,7 +16639,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16662,7 +16667,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"lazy_static",
"serde",
@@ -16674,7 +16679,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"argon2",
"axum 0.8.4",
@@ -16699,7 +16704,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16713,7 +16718,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16746,7 +16751,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16760,7 +16765,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16779,7 +16784,7 @@ dependencies = [
[[package]]
name = "windmill-common"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -16880,7 +16885,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16899,7 +16904,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"regex",
"serde",
@@ -16914,7 +16919,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16938,7 +16943,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"futures",
@@ -16955,7 +16960,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16971,7 +16976,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16992,7 +16997,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17023,7 +17028,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -17048,7 +17053,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-stream",
@@ -17082,7 +17087,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"futures",
@@ -17100,7 +17105,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -17109,7 +17114,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -17121,7 +17126,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde_json",
@@ -17133,7 +17138,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"gosyn",
@@ -17145,7 +17150,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -17157,7 +17162,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde_json",
@@ -17169,7 +17174,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -17180,7 +17185,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -17191,7 +17196,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -17203,7 +17208,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -17214,7 +17219,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17236,7 +17241,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde_json",
@@ -17248,7 +17253,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -17262,7 +17267,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -17279,7 +17284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -17292,7 +17297,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde",
@@ -17304,7 +17309,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -17322,7 +17327,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -17338,7 +17343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -17354,7 +17359,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde",
@@ -17365,7 +17370,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17402,7 +17407,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"const_format",
@@ -17440,7 +17445,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17451,7 +17456,7 @@ dependencies = [
[[package]]
name = "windmill-store"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17481,7 +17486,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17505,7 +17510,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17538,7 +17543,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17571,7 +17576,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17591,7 +17596,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17625,7 +17630,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17661,7 +17666,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17684,7 +17689,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17708,7 +17713,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17732,7 +17737,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17767,7 +17772,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17795,7 +17800,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17818,7 +17823,7 @@ dependencies = [
[[package]]
name = "windmill-types"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17837,7 +17842,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17949,7 +17954,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"bytes",
"futures",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index e39ea3eb93..0d25909c74 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "windmill"
-version = "1.694.0"
+version = "1.697.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
-version = "1.694.0"
+version = "1.697.0"
authors = ["Ruben Fiszel "]
edition = "2021"
@@ -546,7 +546,7 @@ tokio-native-tls = "^0"
openssl = "=0.10"
mail-parser = "^0"
matchit = "=0.7.3"
-rdkafka = { version = "0.36.2", features = ["cmake-build", "ssl-vendored"] }
+rdkafka = { version = "0.36.2", features = ["cmake-build", "ssl-vendored", "curl-static"] }
rdkafka-sys = "=4.9.0"
pg_escape = "0.1.1"
async-nats = "0.38.0"
diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt
index f48c738f28..401a8213f7 100644
--- a/backend/ee-repo-ref.txt
+++ b/backend/ee-repo-ref.txt
@@ -1 +1 @@
-967f961f0a88b027d894aebd03977181129477a8
+c8d100d74b8de6bd26fc973d5edbd8853d54dd8b
diff --git a/backend/migrations/20260505125724_list_ws_specific_versions_function.down.sql b/backend/migrations/20260505125724_list_ws_specific_versions_function.down.sql
new file mode 100644
index 0000000000..894ad8e409
--- /dev/null
+++ b/backend/migrations/20260505125724_list_ws_specific_versions_function.down.sql
@@ -0,0 +1,2 @@
+DROP FUNCTION IF EXISTS list_ws_specific_versions(TEXT, TEXT, TEXT, TEXT);
+DROP INDEX IF EXISTS workspace_settings_deploy_to_idx;
diff --git a/backend/migrations/20260505125724_list_ws_specific_versions_function.up.sql b/backend/migrations/20260505125724_list_ws_specific_versions_function.up.sql
new file mode 100644
index 0000000000..84e7d5e345
--- /dev/null
+++ b/backend/migrations/20260505125724_list_ws_specific_versions_function.up.sql
@@ -0,0 +1,166 @@
+-- Index supporting the recursive CTE in list_ws_specific_versions: each
+-- iteration probes `WHERE ws.deploy_to = r.ws_id`, which without an index
+-- on workspace_settings.deploy_to seq-scans the whole table per iteration
+-- (up to depth-cap × |workspace_settings| row reads per call). deploy_to
+-- is sparse — most workspaces don't deploy anywhere — so a partial index
+-- keeps the index small while still covering every probe.
+CREATE INDEX IF NOT EXISTS workspace_settings_deploy_to_idx
+ ON workspace_settings (deploy_to)
+ WHERE deploy_to IS NOT NULL;
+
+-- Returns the list of workspace ids related to `seed_workspace` via the
+-- `workspace_settings.deploy_to` graph (in either direction) for which a
+-- `resource` or `variable` row at `item_path` exists AND is visible to
+-- `user_email` under that workspace's RLS context.
+--
+-- For each related workspace this function:
+-- 1. resolves the user's per-workspace identity (`usr` row by email,
+-- groups via `usr_to_group` + `email_to_igroup`, folders via
+-- `folder.extra_perms`),
+-- 2. switches the role + session.* settings via `set_session_context`
+-- so the next query is evaluated under that workspace's RLS,
+-- 3. runs an `EXISTS` against the requested item table.
+--
+-- This consolidates the "fan out per-workspace, set RLS, EXISTS" loop
+-- that previously lived in the Rust handler into a single round trip.
+CREATE OR REPLACE FUNCTION list_ws_specific_versions(
+ seed_workspace TEXT,
+ user_email TEXT,
+ item_kind TEXT,
+ item_path TEXT
+) RETURNS TABLE(ws VARCHAR) AS $$
+DECLARE
+ rel RECORD;
+ usr_row RECORD;
+ user_perms TEXT[];
+ groups_csv TEXT;
+ pgroups_csv TEXT;
+ folders_read_csv TEXT;
+ folders_write_csv TEXT;
+ item_exists BOOLEAN;
+ is_super BOOLEAN;
+BEGIN
+ IF item_kind NOT IN ('resource', 'variable') THEN
+ RAISE EXCEPTION 'Invalid kind: %', item_kind;
+ END IF;
+
+ SELECT COALESCE(super_admin, false) INTO is_super
+ FROM password WHERE email = user_email;
+ is_super := COALESCE(is_super, false);
+
+ BEGIN
+ FOR rel IN
+ WITH RECURSIVE related_workspaces(ws_id, depth) AS (
+ SELECT seed_workspace::VARCHAR, 0
+ UNION
+ SELECT CASE
+ WHEN ws.workspace_id = r.ws_id THEN ws.deploy_to
+ ELSE ws.workspace_id
+ END, r.depth + 1
+ FROM workspace_settings ws, related_workspaces r
+ WHERE r.depth < 32
+ AND ((ws.workspace_id = r.ws_id AND ws.deploy_to IS NOT NULL)
+ OR ws.deploy_to = r.ws_id)
+ )
+ SELECT DISTINCT r.ws_id
+ FROM related_workspaces r
+ INNER JOIN workspace w ON w.id = r.ws_id AND w.deleted = false
+ LOOP
+ SELECT u.username, u.is_admin
+ INTO usr_row
+ FROM usr u
+ WHERE u.email = user_email
+ AND u.workspace_id = rel.ws_id
+ AND u.disabled = false;
+
+ IF NOT FOUND AND NOT is_super THEN
+ CONTINUE;
+ END IF;
+
+ IF NOT FOUND THEN
+ -- super admin without a usr row in this workspace: synthesize an
+ -- admin identity so RLS is bypassed (windmill_admin role).
+ usr_row.username := user_email;
+ usr_row.is_admin := true;
+ groups_csv := '';
+ pgroups_csv := '';
+ folders_read_csv := '';
+ folders_write_csv := '';
+ ELSE
+ SELECT
+ COALESCE(string_agg(g, ','), ''),
+ COALESCE(string_agg('g/' || g, ','), '')
+ INTO groups_csv, pgroups_csv
+ FROM (
+ SELECT group_ AS g FROM usr_to_group
+ WHERE usr_to_group.usr = usr_row.username
+ AND usr_to_group.workspace_id = rel.ws_id
+ UNION ALL
+ SELECT igroup FROM email_to_igroup WHERE email = user_email
+ ) gs;
+
+ user_perms := ARRAY['u/' || usr_row.username] || ARRAY(
+ SELECT 'g/' || g FROM (
+ SELECT group_ AS g FROM usr_to_group
+ WHERE usr = usr_row.username AND workspace_id = rel.ws_id
+ UNION ALL
+ SELECT igroup FROM email_to_igroup WHERE email = user_email
+ ) gs2
+ );
+
+ -- folders_read: every folder the user can see (write implies read);
+ -- folders_write: only those granting write access.
+ WITH user_folders AS (
+ SELECT name, EXISTS (
+ SELECT 1 FROM jsonb_each_text(extra_perms) t
+ WHERE t.key = ANY(user_perms) AND t.value::boolean IS true
+ ) AS is_write
+ FROM folder
+ WHERE extra_perms ?| user_perms AND folder.workspace_id = rel.ws_id
+ )
+ SELECT
+ COALESCE(string_agg(name, ','), ''),
+ COALESCE(string_agg(name, ',') FILTER (WHERE is_write), '')
+ INTO folders_read_csv, folders_write_csv
+ FROM user_folders;
+
+ IF is_super THEN
+ usr_row.is_admin := true;
+ END IF;
+ END IF;
+
+ PERFORM set_session_context(
+ usr_row.is_admin,
+ usr_row.username,
+ groups_csv,
+ pgroups_csv,
+ folders_read_csv,
+ folders_write_csv
+ );
+
+ EXECUTE format(
+ 'SELECT EXISTS(SELECT 1 FROM %I WHERE workspace_id = $1 AND path = $2)',
+ item_kind
+ )
+ INTO item_exists
+ USING rel.ws_id, item_path;
+
+ IF item_exists THEN
+ ws := rel.ws_id;
+ RETURN NEXT;
+ END IF;
+ END LOOP;
+ EXCEPTION WHEN OTHERS THEN
+ -- Reset to a deny-default state before re-raising so a half-set
+ -- session context can't leak past the failed call.
+ PERFORM set_session_context(false, '', '', '', '', '');
+ RAISE;
+ END;
+
+ -- Reset to a deny-default state on the happy path too. SET LOCAL is
+ -- transaction-scoped so this also unwinds at transaction end, but
+ -- being explicit defends against the function being called inside a
+ -- longer outer transaction.
+ PERFORM set_session_context(false, '', '', '', '', '');
+END;
+$$ LANGUAGE plpgsql;
diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs
index e63b0ef680..1ae22879b6 100644
--- a/backend/parsers/windmill-parser-ts/src/lib.rs
+++ b/backend/parsers/windmill-parser-ts/src/lib.rs
@@ -77,32 +77,36 @@ impl Visit for ImportsFinder {
}
fn visit_export_all(&mut self, node: &swc_ecma_ast::ExportAll) {
- if !self.skip_type_only || node.type_only {
+ if self.skip_type_only && node.type_only {
return;
}
self.process_raw(node.src.raw.as_ref().map(|x| x.to_string()));
}
fn visit_named_export(&mut self, node: &swc_ecma_ast::NamedExport) {
- if node.src.is_none() || !self.skip_type_only || node.type_only {
+ if node.src.is_none() {
return;
}
- if node.specifiers.len() > 0 {
- let mut is_type_only = true;
- for specifier in node.specifiers.iter() {
- match specifier {
- swc_ecma_ast::ExportSpecifier::Named(swc_ecma_ast::ExportNamedSpecifier {
- is_type_only,
- ..
- }) if *is_type_only => (),
- _ => {
- is_type_only = false;
- break;
+ if self.skip_type_only {
+ if node.type_only {
+ return;
+ }
+ if node.specifiers.len() > 0 {
+ let mut is_type_only = true;
+ for specifier in node.specifiers.iter() {
+ match specifier {
+ swc_ecma_ast::ExportSpecifier::Named(
+ swc_ecma_ast::ExportNamedSpecifier { is_type_only, .. },
+ ) if *is_type_only => (),
+ _ => {
+ is_type_only = false;
+ break;
+ }
}
}
- }
- if is_type_only {
- return;
+ if is_type_only {
+ return;
+ }
}
}
diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs
index 0243ccf06d..4309018fb4 100644
--- a/backend/parsers/windmill-parser-ts/tests/tests.rs
+++ b/backend/parsers/windmill-parser-ts/tests/tests.rs
@@ -936,4 +936,27 @@ mod tests {
let result = parse_relative_imports(code, "f/one/two/three/script").unwrap();
assert_eq!(result, vec!["f/b", "f/one/a"]);
}
+
+ #[test]
+ fn test_relative_imports_includes_re_exports() {
+ // Barrel re-exports must be captured as relative imports — without
+ // them, importers reaching helpers via a barrel file lose the edge in
+ // the dependency tree and the dep job 404s on the sibling fetches.
+ let code = r#"
+ export * from "./types.ts";
+ export { WorkflowError } from "./WorkflowError.ts";
+ export * as factory from "./errorFactory.ts";
+ export type { ErrorKind } from "./types-only.ts";
+ "#;
+ let result = parse_relative_imports(code, "f/lib/errors/index").unwrap();
+ assert_eq!(
+ result,
+ vec![
+ "f/lib/errors/WorkflowError",
+ "f/lib/errors/errorFactory",
+ "f/lib/errors/types",
+ "f/lib/errors/types-only",
+ ]
+ );
+ }
}
diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock
index 68cb9c8580..4bb0074734 100644
--- a/backend/parsers/windmill-parser-wasm/Cargo.lock
+++ b/backend/parsers/windmill-parser-wasm/Cargo.lock
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
-version = "1.694.0"
+version = "1.697.0"
dependencies = [
"anyhow",
"bitflags",
diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml
index 86603edffd..e9dc39abc7 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.694.0"
+version = "1.697.0"
edition = "2021"
authors = ["Ruben Fiszel "]
diff --git a/backend/src/main.rs b/backend/src/main.rs
index ce09c7f25f..3049064624 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -288,42 +288,56 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> {
let job_dir = format!("{}/cache_init/{}", *WINDMILL_DIR, job_id);
create_dir_all(&job_dir)?;
if let Some(lock) = res.lockfile {
- let _ = windmill_worker::prepare_job_dir(&lock, &job_dir).await?;
- let envs = windmill_worker::get_common_bun_proc_envs(None).await;
- let _ = windmill_worker::install_bun_lockfile(
- &mut 0,
- &mut None,
- &job_id,
- "admins",
- None,
- &job_dir,
- "cache_init",
- envs.clone(),
- false,
- &mut None,
- false,
- )
- .await?;
-
- let _ = windmill_common::worker::write_file(&job_dir, "main.js", &res.content)?;
-
- if let Err(e) = windmill_worker::prebundle_bun_script(
- &res.content,
- &lock,
- &path,
- &job_id,
- "admins",
- None,
- &job_dir,
- "",
- "cache_init",
- "",
- &mut None,
- &None,
- )
- .await
- {
- panic!("Error prebundling bun script: {e:#}");
+ // The hub occasionally returns a malformed `lockfile` field — e.g. the
+ // raw script source instead of the expected `\n//bun.lockb\n`
+ // shape. A valid lockfile always starts with the package.json (`{...}`),
+ // so anything else is bogus and would make `bun install` choke trying to
+ // parse TypeScript as JSON. Skip those rather than aborting the entire cache.
+ if !lock.trim_start().starts_with('{') {
+ tracing::warn!(
+ "Hub script {path} returned a malformed lockfile (does not start with a package.json object), skipping prebundling"
+ );
+ } else {
+ let _ = windmill_worker::prepare_job_dir(&lock, &job_dir).await?;
+ let envs = windmill_worker::get_common_bun_proc_envs(None).await;
+ if let Err(e) = windmill_worker::install_bun_lockfile(
+ &mut 0,
+ &mut None,
+ &job_id,
+ "admins",
+ None,
+ &job_dir,
+ "cache_init",
+ envs.clone(),
+ false,
+ &mut None,
+ false,
+ )
+ .await
+ {
+ // A single broken hub script (malformed lockfile, missing dep, …)
+ // shouldn't abort the entire cache run — log and move on.
+ tracing::error!(
+ "Failed to install lockfile for hub script {path}, skipping: {e:#}"
+ );
+ } else if let Err(e) = windmill_worker::prebundle_bun_script(
+ &res.content,
+ &lock,
+ &path,
+ &job_id,
+ "admins",
+ None,
+ &job_dir,
+ "",
+ "cache_init",
+ "",
+ &mut None,
+ &None,
+ )
+ .await
+ {
+ tracing::error!("Failed to prebundle hub script {path}, skipping: {e:#}");
+ }
}
} else {
tracing::warn!("No lockfile found for bun script {path}, skipping...");
@@ -600,6 +614,7 @@ async fn windmill_main() -> anyhow::Result<()> {
return Ok(());
}
"cache" => {
+ tracing_subscriber::fmt::init();
#[cfg(feature = "embedding")]
{
println!("Caching embedding model...");
diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs
index ef3fb99696..59a2f47ed0 100644
--- a/backend/src/monitor.rs
+++ b/backend/src/monitor.rs
@@ -3404,7 +3404,7 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)
LEFT JOIN worker_ping wp ON wp.worker = q.worker
WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()
- AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode')
+ AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')
AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval
AND q.canceled_by IS NULL
diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs
new file mode 100644
index 0000000000..b750379834
--- /dev/null
+++ b/backend/tests/batch_rerun.rs
@@ -0,0 +1,615 @@
+//! Tests for `/jobs/run/batch_rerun_jobs` and `/jobs/list_selected_job_groups`.
+//!
+//! These cover both the long-existing baseline behavior (regular Script and Flow
+//! jobs reran with default args, `use_latest_version=true`, and `input_transforms`)
+//! and the SingleStepFlow projection paths added in the PR that introduced this
+//! file. The endpoint had zero coverage before — every change here exists because
+//! a code reviewer or reader caught it; the tests exist so the next reader doesn't
+//! have to.
+
+use serde_json::json;
+use sqlx::{Pool, Postgres};
+use uuid::Uuid;
+
+use windmill_common::{
+ flows::Retry,
+ jobs::{JobKind, JobPayload},
+ runnable_settings::{ConcurrencySettings, DebouncingSettings},
+ scripts::{ScriptHash, ScriptLang},
+};
+
+use windmill_test_utils::*;
+
+const WORKSPACE: &str = "test-workspace";
+const SCRIPT_PATH: &str = "u/test-user/rerun_script";
+const SCRIPT_HASH: i64 = 1111111111;
+const FLOW_PATH: &str = "u/test-user/rerun_flow";
+const FLOW_VERSION: i64 = 2222222222;
+
+/// Mark a queued job as completed with success — needed so it's eligible for
+/// `list_selected_job_groups` / `batch_rerun_jobs` (both join `v2_job_completed`).
+async fn complete_job(db: &Pool, job_id: Uuid) -> anyhow::Result<()> {
+ sqlx::query(
+ "INSERT INTO v2_job_completed (workspace_id, id, result, status, duration_ms, started_at, completed_at)
+ VALUES ($1, $2, '{}'::jsonb, 'success', 0, now(), now())",
+ )
+ .bind(WORKSPACE)
+ .bind(job_id)
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+fn script_payload() -> JobPayload {
+ JobPayload::ScriptHash {
+ hash: ScriptHash(SCRIPT_HASH),
+ path: SCRIPT_PATH.to_string(),
+ cache_ttl: None,
+ cache_ignore_s3_path: None,
+ dedicated_worker: None,
+ language: ScriptLang::Deno,
+ priority: None,
+ apply_preprocessor: false,
+ concurrency_settings: ConcurrencySettings::default().into(),
+ debouncing_settings: DebouncingSettings::default(),
+ labels: None,
+ }
+}
+
+fn flow_payload() -> JobPayload {
+ JobPayload::Flow {
+ path: FLOW_PATH.to_string(),
+ dedicated_worker: None,
+ apply_preprocessor: false,
+ version: FLOW_VERSION,
+ labels: None,
+ }
+}
+
+fn ssf_script_payload(hash: Option, retry: Option) -> JobPayload {
+ JobPayload::SingleStepFlow {
+ path: SCRIPT_PATH.to_string(),
+ hash,
+ flow_version: None,
+ args: Default::default(),
+ retry,
+ error_handler_path: None,
+ error_handler_args: None,
+ skip_handler: None,
+ cache_ttl: None,
+ cache_ignore_s3_path: None,
+ priority: None,
+ tag_override: None,
+ trigger_path: None,
+ apply_preprocessor: false,
+ concurrency_settings: ConcurrencySettings::default(),
+ debouncing_settings: DebouncingSettings::default(),
+ }
+}
+
+fn ssf_flow_payload() -> JobPayload {
+ JobPayload::SingleStepFlow {
+ path: FLOW_PATH.to_string(),
+ hash: None,
+ flow_version: Some(FLOW_VERSION),
+ args: Default::default(),
+ retry: None,
+ error_handler_path: None,
+ error_handler_args: None,
+ skip_handler: None,
+ cache_ttl: None,
+ cache_ignore_s3_path: None,
+ priority: None,
+ tag_override: None,
+ trigger_path: None,
+ apply_preprocessor: false,
+ concurrency_settings: ConcurrencySettings::default(),
+ debouncing_settings: DebouncingSettings::default(),
+ }
+}
+
+/// Push + complete a job so it's eligible for batch rerun.
+async fn push_completed(
+ db: &Pool,
+ payload: JobPayload,
+ args: Vec<(&str, serde_json::Value)>,
+) -> anyhow::Result {
+ let mut runner = RunJob::from(payload);
+ for (k, v) in args {
+ runner = runner.arg(k.to_string(), v);
+ }
+ let id = runner.push(db).await;
+ complete_job(db, id).await?;
+ Ok(id)
+}
+
+/// POST `/jobs/run/batch_rerun_jobs` and parse the SSE-style line-per-result
+/// stream into a list of (uuid, error?) pairs.
+async fn batch_rerun(
+ client: &windmill_api_client::Client,
+ body: serde_json::Value,
+) -> anyhow::Result>> {
+ let response = client
+ .client()
+ .post(format!(
+ "{}/w/{}/jobs/run/batch_rerun_jobs",
+ client.baseurl(),
+ WORKSPACE
+ ))
+ .json(&body)
+ .send()
+ .await?;
+ assert!(
+ response.status().is_success(),
+ "batch_rerun_jobs returned {}",
+ response.status()
+ );
+ let body = response.text().await?;
+ Ok(body
+ .lines()
+ .filter(|l| !l.is_empty())
+ .map(|l| {
+ if let Some(err) = l.strip_prefix("Error: ") {
+ Err(err.to_string())
+ } else {
+ Ok(Uuid::parse_str(l.trim()).expect("rerun line should be a UUID"))
+ }
+ })
+ .collect())
+}
+
+/// Read kind + args of a freshly-rerun job.
+async fn rerun_job(db: &Pool, id: Uuid) -> anyhow::Result<(JobKind, serde_json::Value)> {
+ let row = sqlx::query!(
+ r#"SELECT kind AS "kind: JobKind", args::text AS "args!"
+ FROM v2_job WHERE id = $1"#,
+ id
+ )
+ .fetch_one(db)
+ .await?;
+ Ok((row.kind, serde_json::from_str(&row.args)?))
+}
+
+// ---------------------------------------------------------------------------
+// Baseline regression — regular Script and Flow rerun.
+
+/// Default-mode rerun on a regular Script: new Script job inherits original args.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_script_default(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(&db, script_payload(), vec![("name", json!("orig"))]).await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {},
+ "flow_options_by_path": {},
+ }),
+ )
+ .await?;
+ assert_eq!(results.len(), 1, "expected one rerun");
+ let new_id = results[0].as_ref().expect("rerun should succeed").clone();
+ let (kind, args) = rerun_job(&db, new_id).await?;
+ assert_eq!(kind, JobKind::Script);
+ assert_eq!(args, json!({"name": "orig"}));
+ Ok(())
+}
+
+/// `use_latest_version=true` reruns regular Script via path-based push.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_script_latest_version(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(&db, script_payload(), vec![("name", json!("orig"))]).await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {
+ SCRIPT_PATH: { "use_latest_version": true }
+ },
+ "flow_options_by_path": {},
+ }),
+ )
+ .await?;
+ let new_id = results[0].as_ref().expect("rerun should succeed").clone();
+ let (kind, args) = rerun_job(&db, new_id).await?;
+ assert_eq!(kind, JobKind::Script);
+ assert_eq!(args, json!({"name": "orig"}));
+ Ok(())
+}
+
+/// `input_transforms` static value overrides original args on Script rerun.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_script_static_transform(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(&db, script_payload(), vec![("name", json!("orig"))]).await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {
+ SCRIPT_PATH: {
+ "input_transforms": { "name": { "type": "static", "value": "\"X\"" } }
+ }
+ },
+ "flow_options_by_path": {},
+ }),
+ )
+ .await?;
+ let new_id = results[0].as_ref().expect("rerun should succeed").clone();
+ let (_, args) = rerun_job(&db, new_id).await?;
+ assert_eq!(
+ args,
+ json!({"name": "\"X\""}),
+ "static transform should override original args"
+ );
+ Ok(())
+}
+
+/// Regular Flow rerun: new Flow job inherits original args.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_flow_default(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(&db, flow_payload(), vec![("name", json!("orig-flow"))]).await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {},
+ "flow_options_by_path": {},
+ }),
+ )
+ .await?;
+ let new_id = results[0].as_ref().expect("rerun should succeed").clone();
+ let (kind, args) = rerun_job(&db, new_id).await?;
+ assert_eq!(kind, JobKind::Flow);
+ assert_eq!(args, json!({"name": "orig-flow"}));
+ Ok(())
+}
+
+/// `input_transforms` override original args on Flow rerun (path-based,
+/// frontend forces use_latest_version=true for flow).
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_flow_static_transform(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(&db, flow_payload(), vec![("name", json!("orig"))]).await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {},
+ "flow_options_by_path": {
+ FLOW_PATH: {
+ "use_latest_version": true,
+ "input_transforms": { "name": { "type": "static", "value": "\"FX\"" } }
+ }
+ },
+ }),
+ )
+ .await?;
+ let new_id = results[0].as_ref().expect("rerun should succeed").clone();
+ let (_, args) = rerun_job(&db, new_id).await?;
+ assert_eq!(args, json!({"name": "\"FX\""}));
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// SingleStepFlow projection — the bug class this PR fixes.
+
+/// Default rerun of a script-wrapped SingleStepFlow lands as a plain Script.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_ssf_script_default(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(
+ &db,
+ ssf_script_payload(Some(ScriptHash(SCRIPT_HASH)), None),
+ vec![("name", json!("orig-ssf"))],
+ )
+ .await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {},
+ "flow_options_by_path": {},
+ }),
+ )
+ .await?;
+ let new_id = results[0].as_ref().expect("SSF should be reruable").clone();
+ let (kind, args) = rerun_job(&db, new_id).await?;
+ // Wrapper unwraps to plain Script (retry policy belongs to the trigger, not to "rerun").
+ assert_eq!(kind, JobKind::Script);
+ assert_eq!(args, json!({"name": "orig-ssf"}));
+ Ok(())
+}
+
+/// `use_latest_version=true` + `input_transforms` on SSF — exercises the
+/// `latest_schema` projection in `batch_rerun_handle_job`. Regression for the
+/// fix in this PR's third commit; without it the transform silently no-ops.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_ssf_script_latest_with_transform(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(
+ &db,
+ ssf_script_payload(Some(ScriptHash(SCRIPT_HASH)), None),
+ vec![("name", json!("orig"))],
+ )
+ .await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {
+ SCRIPT_PATH: {
+ "use_latest_version": true,
+ "input_transforms": { "name": { "type": "static", "value": "\"S\"" } }
+ }
+ },
+ "flow_options_by_path": {},
+ }),
+ )
+ .await?;
+ let new_id = results[0]
+ .as_ref()
+ .expect("SSF rerun should succeed")
+ .clone();
+ let (_, args) = rerun_job(&db, new_id).await?;
+ assert_eq!(args, json!({"name": "\"S\""}));
+ Ok(())
+}
+
+/// Flow-wrapped SingleStepFlow projects to Flow and reruns by path.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_ssf_flow(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let original = push_completed(
+ &db,
+ ssf_flow_payload(),
+ vec![("name", json!("orig-ssf-flow"))],
+ )
+ .await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [original],
+ "script_options_by_path": {},
+ "flow_options_by_path": {
+ FLOW_PATH: {
+ "use_latest_version": true,
+ "input_transforms": { "name": { "type": "static", "value": "\"FF\"" } }
+ }
+ },
+ }),
+ )
+ .await?;
+ let new_id = results[0]
+ .as_ref()
+ .expect("SSF flow should be reruable")
+ .clone();
+ let (kind, args) = rerun_job(&db, new_id).await?;
+ assert_eq!(kind, JobKind::Flow);
+ assert_eq!(args, json!({"name": "\"FF\""}));
+ Ok(())
+}
+
+/// Mixed batch (script + flow + SSF-script + SSF-flow) — exercises the
+/// dispatch arms across all four kinds in a single request.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn batch_rerun_mixed_kinds(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let s = push_completed(&db, script_payload(), vec![("name", json!("S"))]).await?;
+ let f = push_completed(&db, flow_payload(), vec![("name", json!("F"))]).await?;
+ let ss = push_completed(
+ &db,
+ ssf_script_payload(Some(ScriptHash(SCRIPT_HASH)), None),
+ vec![("name", json!("SS"))],
+ )
+ .await?;
+ let sf = push_completed(&db, ssf_flow_payload(), vec![("name", json!("SF"))]).await?;
+
+ let results = batch_rerun(
+ &client,
+ json!({
+ "job_ids": [s, f, ss, sf],
+ "script_options_by_path": {},
+ "flow_options_by_path": {},
+ }),
+ )
+ .await?;
+ assert_eq!(
+ results.len(),
+ 4,
+ "all 4 kinds should rerun, got: {results:?}"
+ );
+ for r in &results {
+ assert!(r.is_ok(), "expected all reruns to succeed, got {r:?}");
+ }
+
+ // Two new Scripts (regular + SSF-script projected) and two new Flows
+ // (regular + SSF-flow projected).
+ let counts = sqlx::query!(
+ r#"SELECT kind AS "kind: JobKind", count(*) AS "count!"
+ FROM v2_job
+ WHERE workspace_id = $1
+ AND id <> ALL($2)
+ AND parent_job IS NULL
+ GROUP BY kind
+ ORDER BY kind::text"#,
+ WORKSPACE,
+ &[s, f, ss, sf][..]
+ )
+ .fetch_all(&db)
+ .await?;
+ let mut script = 0;
+ let mut flow = 0;
+ for c in counts {
+ match c.kind {
+ JobKind::Script => script = c.count,
+ JobKind::Flow => flow = c.count,
+ _ => {}
+ }
+ }
+ assert_eq!(
+ script, 2,
+ "expected 2 new Script jobs (script + SSF-script)"
+ );
+ assert_eq!(flow, 2, "expected 2 new Flow jobs (flow + SSF-flow)");
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// list_selected_job_groups — the front door for the BatchReRun pane. Crashes
+// here would stop the user before they could even click Re-run.
+
+#[derive(serde::Deserialize)]
+struct GroupResp {
+ kind: String,
+ script_path: String,
+ schemas: Vec,
+ latest_schema: Option,
+}
+
+#[derive(serde::Deserialize)]
+struct SchemaEntry {
+ script_hash: Option,
+ schema: Option,
+ job_ids: Vec,
+}
+
+async fn list_groups(
+ client: &windmill_api_client::Client,
+ job_ids: &[Uuid],
+) -> anyhow::Result> {
+ let response = client
+ .client()
+ .post(format!(
+ "{}/w/{}/jobs/list_selected_job_groups",
+ client.baseurl(),
+ WORKSPACE
+ ))
+ .json(&job_ids)
+ .send()
+ .await?;
+ assert!(response.status().is_success());
+ Ok(response.json().await?)
+}
+
+/// SSF script-wrapped: pinned hash projected, schema non-null. Without this,
+/// the BatchReRun pane crashes on `mergeSchemasForBatchReruns`.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn list_groups_ssf_script_has_schema(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let id = push_completed(
+ &db,
+ ssf_script_payload(Some(ScriptHash(SCRIPT_HASH)), None),
+ vec![("name", json!("x"))],
+ )
+ .await?;
+
+ let groups = list_groups(&client, &[id]).await?;
+ assert_eq!(groups.len(), 1);
+ let g = &groups[0];
+ assert_eq!(g.kind, "script", "SSF wrapping a script projects to script");
+ assert_eq!(g.script_path, SCRIPT_PATH);
+ assert!(g.latest_schema.is_some(), "latest_schema must resolve");
+ assert_eq!(g.schemas.len(), 1, "expected one schema entry");
+ let s = &g.schemas[0];
+ assert!(s.schema.is_some(), "per-version schema must be non-null");
+ let expected_hash = format!("{:0>16x}", SCRIPT_HASH as u64);
+ assert_eq!(s.script_hash.as_deref(), Some(expected_hash.as_str()));
+ assert_eq!(s.job_ids, vec![id]);
+ Ok(())
+}
+
+/// SSF flow-wrapped: no hash to pin, but path-based fallback fills in schema.
+#[sqlx::test(fixtures("base", "batch_rerun"))]
+async fn list_groups_ssf_flow_has_schema_via_path(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{}", server.addr.port()),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ let id = push_completed(&db, ssf_flow_payload(), vec![("name", json!("x"))]).await?;
+
+ let groups = list_groups(&client, &[id]).await?;
+ let g = groups.first().expect("expected one group");
+ assert_eq!(g.kind, "flow", "SSF wrapping a flow projects to flow");
+ assert!(g.latest_schema.is_some());
+ assert_eq!(g.schemas.len(), 1);
+ let s = &g.schemas[0];
+ assert!(
+ s.schema.is_some(),
+ "flow-wrapped SSF schema falls back to latest by path"
+ );
+ Ok(())
+}
diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs
index a791060a2c..fa15866bca 100644
--- a/backend/tests/bun_jobs.rs
+++ b/backend/tests/bun_jobs.rs
@@ -956,6 +956,221 @@ export function main() {
Ok(())
}
+// ============================================================================
+// Bundle Wrapper Safety Tests
+// ============================================================================
+
+/// Regression test for the "TS source ends up in the bun bundle cache" bug.
+///
+/// The wrapper-side hardening: `node_builder.ts` discarded `Bun.build`'s
+/// return value, so any silent-failure mode (`success: false` without
+/// throwing — `throw: false`, or a future Bun where defaults change) made
+/// the wrapper exit 0 even though no `main.js` was written. Pair that with
+/// a pre-existing `main.js` containing raw TypeScript and `save_cache`
+/// happily copied that TS into the bundle cache; the worker later choked
+/// on `type GpgKey = {`.
+///
+/// This test patches `node_builder.ts` to force the silent-failure shape
+/// and asserts that our wrapper now refuses to silently succeed — bun must
+/// exit non-zero so prebundling fails loudly instead of writing TypeScript
+/// into the bundle cache.
+#[test]
+fn test_bun_bundle_wrapper_catches_silent_failure() {
+ use std::process::Command;
+ use windmill_worker::{build_loader, LoaderMode, BUN_PATH};
+
+ let temp_dir = tempfile::tempdir().unwrap();
+ let dir = temp_dir.path();
+ let dir_str = dir.to_str().unwrap();
+
+ // Script imports a package that won't exist in node_modules.
+ std::fs::write(
+ dir.join("main.ts"),
+ r#"
+import x from "definitely-not-a-real-pkg-windmill-test";
+export function main() { return x; }
+"#,
+ )
+ .unwrap();
+
+ // Generate the real node_builder.ts via the production code path.
+ tokio::runtime::Runtime::new()
+ .unwrap()
+ .block_on(build_loader(
+ dir_str,
+ "http://localhost:8000",
+ "test_token",
+ "test-workspace",
+ "f/test/script",
+ LoaderMode::BunBundle,
+ &None,
+ ))
+ .expect("build_loader failed");
+
+ // Force the silent-failure shape by injecting `throw: false`. The
+ // wrapper's pre-fix `try/catch` would have swallowed this; the fixed
+ // wrapper inspects `result.success` and `result.outputs` and exits 1.
+ let path = dir.join("node_builder.ts");
+ let original = std::fs::read_to_string(&path).unwrap();
+ let patched = original.replace(
+ "external: [\"electron\"],",
+ "external: [\"electron\"], throw: false,",
+ );
+ assert_ne!(
+ original, patched,
+ "expected to find Bun.build options block to patch; node_builder.ts template changed?"
+ );
+ std::fs::write(&path, patched).unwrap();
+
+ // Pre-seed main.js with raw TypeScript (mimics the historical
+ // pre-write that originally seeded the bug).
+ std::fs::write(
+ dir.join("main.js"),
+ "type GpgKey = { email: string };\nexport const main = (): GpgKey => ({ email: \"\" });\n",
+ )
+ .unwrap();
+
+ let output = Command::new(BUN_PATH.as_str())
+ .args(["run", path.to_str().unwrap()])
+ .current_dir(dir)
+ .output()
+ .expect("Failed to run bun");
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ !output.status.success(),
+ "node_builder.ts must exit non-zero when Bun.build silently fails to write a bundle.\nstdout:\n{stdout}\nstderr:\n{stderr}"
+ );
+ assert!(
+ stdout.contains("Failed to build node bundle"),
+ "expected diagnostic in stdout, got:\n{stdout}"
+ );
+}
+
+/// Regression test for the actual root cause of the "TS source in bundle
+/// cache" bug: `generate_bun_bundle` was awaiting `child_process.wait()`
+/// without checking the exit code on the no-DB path (used by Docker-build
+/// `windmill cache hubPaths.json`). bun would exit 1 after Bun.build threw,
+/// `wait().await?` propagated only IO errors, and `generate_bun_bundle`
+/// returned `Ok(())`. `save_cache` then copied a stale `main.js` (raw TS
+/// source) straight into the bundle cache.
+///
+/// This test runs `generate_bun_bundle` with `db: None` against a `node_builder.ts`
+/// that calls `process.exit(1)`, and asserts the function now returns an error.
+#[test]
+fn test_generate_bun_bundle_propagates_exit_status() {
+ use windmill_worker::{generate_bun_bundle, get_common_bun_proc_envs};
+
+ let temp_dir = tempfile::tempdir().unwrap();
+ let dir = temp_dir.path();
+ let dir_str = dir.to_str().unwrap();
+
+ // node_builder.ts that exits 1, mimicking what bun does when Bun.build throws.
+ std::fs::write(
+ dir.join("node_builder.ts"),
+ "console.log('simulated bun build failure');\nprocess.exit(1);\n",
+ )
+ .unwrap();
+
+ let runtime = tokio::runtime::Runtime::new().unwrap();
+ let envs = runtime.block_on(get_common_bun_proc_envs(None));
+
+ let result = runtime.block_on(generate_bun_bundle(
+ dir_str,
+ "test-workspace",
+ &uuid::Uuid::new_v4(),
+ "test-worker",
+ None, // db: None — this is the cache_hub_scripts path that had the bug
+ None,
+ &mut 0,
+ &mut None,
+ &envs,
+ &mut None,
+ ));
+
+ assert!(
+ result.is_err(),
+ "generate_bun_bundle must surface bun's non-zero exit on the no-DB path. \
+ If it returns Ok(()) when bun exited 1, save_cache will silently cache stale main.js content."
+ );
+ let err_msg = format!("{:?}", result.unwrap_err());
+ assert!(
+ err_msg.contains("non-zero status"),
+ "expected exit-status error, got: {err_msg}"
+ );
+}
+
+/// Regression test for the install_bun_lockfile no-DB path: same code shape as
+/// `generate_bun_bundle` (site 3 of the original bug) — `wait().await?` ignored
+/// non-zero bun exits. A `bun install` failure (e.g. malformed package.json)
+/// must now surface as an error so callers don't proceed with a half-installed
+/// node_modules.
+#[test]
+fn test_install_bun_lockfile_propagates_exit_status() {
+ use windmill_worker::{get_common_bun_proc_envs, install_bun_lockfile};
+ let temp_dir = tempfile::tempdir().unwrap();
+ let dir = temp_dir.path();
+ let dir_str = dir.to_str().unwrap();
+ // Malformed package.json -> bun install fails with exit 1.
+ std::fs::write(dir.join("package.json"), "this is not valid json").unwrap();
+ let runtime = tokio::runtime::Runtime::new().unwrap();
+ let envs = runtime.block_on(get_common_bun_proc_envs(None));
+ let result = runtime.block_on(install_bun_lockfile(
+ &mut 0,
+ &mut None,
+ &uuid::Uuid::new_v4(),
+ "test-workspace",
+ None, // db: None — no-DB path that had the bug
+ dir_str,
+ "test-worker",
+ envs,
+ false, // npm_mode
+ &mut None,
+ true, // quiet
+ ));
+ assert!(
+ result.is_err(),
+ "install_bun_lockfile must surface bun's non-zero exit on the no-DB path"
+ );
+ let err_msg = format!("{:?}", result.unwrap_err());
+ assert!(
+ err_msg.contains("non-zero status"),
+ "expected exit-status error, got: {err_msg}"
+ );
+}
+
+/// Regression test for the post-bundle existence check in `prebundle_bun_script`
+/// and `handle_bun_job`. Both call sites guard against the case where
+/// `generate_bun_bundle` returns `Ok(())` but `main.js` was never written —
+/// the upstream wait-status fix is the primary defense, this is the catch-all
+/// for any other silent-failure mode (Bun output-naming change, custom plugin
+/// swallowing the build, etc.). Without this check, `save_cache` would
+/// happily copy whatever's at the bundle path (often raw TypeScript that some
+/// other code path left there).
+#[test]
+fn test_ensure_bundle_output_exists_rejects_missing_file() {
+ use windmill_worker::ensure_bundle_output_exists;
+ let temp_dir = tempfile::tempdir().unwrap();
+ let dir = temp_dir.path();
+ let missing = dir.join("main.js").to_str().unwrap().to_string();
+
+ let result = ensure_bundle_output_exists(&missing);
+ assert!(
+ result.is_err(),
+ "ensure_bundle_output_exists must reject when the bundle file is missing"
+ );
+ let err_msg = format!("{:?}", result.unwrap_err());
+ assert!(
+ err_msg.contains("bun bundle output missing"),
+ "expected 'bun bundle output missing' in error, got: {err_msg}"
+ );
+
+ // Sanity: when the file does exist, it returns Ok.
+ std::fs::write(&missing, "// @bun\n").unwrap();
+ assert!(ensure_bundle_output_exists(&missing).is_ok());
+}
+
// ============================================================================
// Dedicated Worker Protocol Tests
// ============================================================================
diff --git a/backend/tests/fixtures/batch_rerun.sql b/backend/tests/fixtures/batch_rerun.sql
new file mode 100644
index 0000000000..85ba377f5d
--- /dev/null
+++ b/backend/tests/fixtures/batch_rerun.sql
@@ -0,0 +1,32 @@
+-- Add-on fixture for batch_rerun.rs (combine with `base.sql` via
+-- `#[sqlx::test(fixtures("base", "batch_rerun"))]`). Provides a deployed
+-- script and a deployed flow so SingleStepFlow wrappers and rerun queries
+-- find real runnable rows to join against.
+
+INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
+VALUES (
+ 'test-workspace', 1111111111, 'u/test-user/rerun_script',
+ 'export function main(name = "world") { return "hi " + name; }',
+ 'deno', 'script', 'test-user',
+ '{"type":"object","properties":{"name":{"type":"string"}},"order":["name"],"required":[]}',
+ '', '', ''
+);
+
+INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms, versions)
+VALUES (
+ 'test-workspace', 'u/test-user/rerun_flow',
+ '', '',
+ '{"modules":[{"id":"a","value":{"type":"rawscript","language":"deno","content":"export function main(name = \"world\"){ return \"flow:\" + name; }","input_transforms":{"name":{"type":"javascript","expr":"flow_input.name"}}}}]}',
+ 'test-user', NOW(),
+ '{"type":"object","properties":{"name":{"type":"string"}},"order":["name"],"required":[]}',
+ '{}',
+ ARRAY[2222222222::bigint]
+);
+
+INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at)
+VALUES (
+ 2222222222, 'test-workspace', 'u/test-user/rerun_flow',
+ '{"modules":[{"id":"a","value":{"type":"rawscript","language":"deno","content":"export function main(name = \"world\"){ return \"flow:\" + name; }","input_transforms":{"name":{"type":"javascript","expr":"flow_input.name"}}}}]}',
+ '{"type":"object","properties":{"name":{"type":"string"}},"order":["name"],"required":[]}',
+ 'test-user', NOW()
+);
diff --git a/backend/tests/fixtures/ws_specific.sql b/backend/tests/fixtures/ws_specific.sql
new file mode 100644
index 0000000000..fcad6ce55c
--- /dev/null
+++ b/backend/tests/fixtures/ws_specific.sql
@@ -0,0 +1,45 @@
+-- Fixture for ws_specific integration tests.
+-- Adds a non-admin user (test-user-2) as a regular workspace member with
+-- access to user-owned items at u/test-user-2/* but not u/test-user/*.
+
+INSERT INTO workspace
+ (id, name, owner)
+ VALUES ('test-workspace', 'test-workspace', 'test-user')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
+ ('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
+ ('test-workspace', 'test2@windmill.dev', 'test-user-2', false, 'User')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO workspace_key(workspace_id, kind, key) VALUES
+ ('test-workspace', 'cloud', 'test-key')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO workspace_settings (workspace_id) VALUES
+ ('test-workspace')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
+ ('test-workspace', 'all', 'All users', '{}')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
+ VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
+ VALUES ('test2@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Test User 2')
+ON CONFLICT DO NOTHING;
+
+-- Tokens
+insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES
+ (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'admin token', true)
+ON CONFLICT DO NOTHING;
+
+insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES
+ (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'user2 token', false)
+ON CONFLICT DO NOTHING;
diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs
index 363ab4fb16..bae776e7f1 100644
--- a/backend/tests/flow_engine_parity.rs
+++ b/backend/tests/flow_engine_parity.rs
@@ -2097,3 +2097,1049 @@ export function main(
Ok(())
}
+
+// =============================================================================
+// flow_env inside predicates of nested sub-flows (BranchOne / loops).
+//
+// Sub-flows spawned by `payload_from_modules` for branches/loops don't carry
+// the parent's `flow_env` in their own FlowValue, so without explicit lookup
+// the predicate evaluators receive `None` and `flow_env.X` resolves to
+// `undefined` inside QuickJS. Verify `handle_flow` walks up to the nearest
+// enclosing scope so predicates see the inherited env.
+// =============================================================================
+
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_skip_if_inside_branchone(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ let mut flow_env = std::collections::HashMap::new();
+ flow_env.insert(
+ "SKIP".to_string(),
+ windmill_common::worker::to_raw_value(&json!(true)),
+ );
+
+ let inner_step = {
+ let mut m = flow_module(
+ "inner",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {ran: true};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+ // skip_if uses `Boolean(...)`-wrapped expression, so it falls through
+ // to QuickJS and exercises the local flow_env propagation path.
+ m.skip_if =
+ Some(windmill_common::flows::SkipIf { expr: "flow_env.SKIP === true".to_string() });
+ m
+ };
+
+ // Branch with two modules: a marker that runs first, then `inner` which
+ // should be skipped via `flow_env.SKIP === true`. When skipped, `inner`
+ // becomes an identity job and the branch's terminal result is whatever
+ // `previous_result` was at that point — i.e. the branch_marker output.
+ let branch_marker = flow_module(
+ "branch_marker",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {marker: "branch-marker"};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ let flow = FlowValue {
+ modules: vec![
+ flow_module(
+ "router",
+ FlowModuleValue::BranchOne {
+ branches: vec![Branch {
+ summary: None,
+ expr: "true".to_string(),
+ modules: vec![branch_marker, inner_step],
+ modules_node: None,
+ skip_failure: false,
+ parallel: false,
+ }],
+ default: vec![],
+ default_node: None,
+ },
+ ),
+ flow_module(
+ "after",
+ FlowModuleValue::RawScript {
+ input_transforms: [js_input("prev", "previous_result")].into(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main(prev: any) {
+ return {prev};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ ),
+ ],
+ flow_env: Some(flow_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // With the fix, skip_if sees `flow_env.SKIP === true`, `inner` becomes an
+ // identity step and passes through `previous_result` (branch_marker).
+ // Without the fix, flow_env was None inside the sub-flow, the predicate
+ // returned false, and `inner` ran, leaving `{ran: true}` in `prev`.
+ assert_eq!(
+ result["prev"]["marker"], "branch-marker",
+ "skip_if with flow_env should skip `inner`; expected branch_marker passed through (got {result:?})"
+ );
+ assert!(
+ result["prev"].get("ran").is_none(),
+ "`inner` ran when it should have been skipped (got {result:?})"
+ );
+
+ Ok(())
+}
+
+// Nested sub-flows: branch inside branch. The recursive CTE in
+// `fetch_root_flow_env` must walk past more than one layer of
+// `payload_from_modules`-constructed FlowValue (each of which has
+// `flow_env = None`) to reach the root's flow_env.
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_skip_if_nested_branchone(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ let mut flow_env = std::collections::HashMap::new();
+ flow_env.insert(
+ "SKIP".to_string(),
+ windmill_common::worker::to_raw_value(&json!(true)),
+ );
+
+ let leaf = {
+ let mut m = flow_module(
+ "leaf",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {ran: true};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+ m.skip_if =
+ Some(windmill_common::flows::SkipIf { expr: "flow_env.SKIP === true".to_string() });
+ m
+ };
+
+ let inner_marker = flow_module(
+ "inner_marker",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {marker: "inner"};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ // Inner BranchOne: contains the marker + the leaf with skip_if.
+ let inner_branch = flow_module(
+ "inner_router",
+ FlowModuleValue::BranchOne {
+ branches: vec![Branch {
+ summary: None,
+ expr: "true".to_string(),
+ modules: vec![inner_marker, leaf],
+ modules_node: None,
+ skip_failure: false,
+ parallel: false,
+ }],
+ default: vec![],
+ default_node: None,
+ },
+ );
+
+ // Outer BranchOne: contains the inner BranchOne. So the leaf is two
+ // levels deep in payload_from_modules-constructed sub-flows.
+ let outer = flow_module(
+ "outer_router",
+ FlowModuleValue::BranchOne {
+ branches: vec![Branch {
+ summary: None,
+ expr: "true".to_string(),
+ modules: vec![inner_branch],
+ modules_node: None,
+ skip_failure: false,
+ parallel: false,
+ }],
+ default: vec![],
+ default_node: None,
+ },
+ );
+
+ let after = flow_module(
+ "after",
+ FlowModuleValue::RawScript {
+ input_transforms: [js_input("prev", "previous_result")].into(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main(prev: any) {
+ return {prev};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ let flow = FlowValue {
+ modules: vec![outer, after],
+ flow_env: Some(flow_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // Leaf's skip_if should see flow_env.SKIP=true → leaf becomes identity →
+ // previous_result inside the inner branch is `inner_marker`. That bubbles
+ // up to the outer branch and into `after`.
+ assert_eq!(
+ result["prev"]["marker"], "inner",
+ "skip_if with flow_env should skip `leaf` even nested two layers deep (got {result:?})"
+ );
+ assert!(
+ result["prev"].get("ran").is_none(),
+ "`leaf` ran when it should have been skipped two layers deep (got {result:?})"
+ );
+
+ Ok(())
+}
+
+// Complex input-transform expression inside a sub-flow exercises the
+// QuickJS evaluation path (it doesn't match the `flow_env.X` /
+// `flow_env.X.Y` regex that hits the API fast path). Without flow_env
+// inheritance, QuickJS would see an empty `flow_env` and the expression
+// would NaN/undefined out.
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_complex_input_transform_in_branch(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ let mut flow_env = std::collections::HashMap::new();
+ flow_env.insert(
+ "LIMIT".to_string(),
+ windmill_common::worker::to_raw_value(&json!(7)),
+ );
+ flow_env.insert(
+ "OFFSET".to_string(),
+ windmill_common::worker::to_raw_value(&json!(3)),
+ );
+
+ let inner = flow_module(
+ "compute",
+ FlowModuleValue::RawScript {
+ // Expression doesn't match the regex fast path (uses arithmetic
+ // and Math.min), so the worker falls through to QuickJS using
+ // the local flow_env. Without inheritance, this is empty.
+ input_transforms: [js_input(
+ "value",
+ "Math.min(flow_env.LIMIT, 10) + flow_env.OFFSET",
+ )]
+ .into(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main(value: number) {
+ return {value};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ let flow = FlowValue {
+ modules: vec![flow_module(
+ "router",
+ FlowModuleValue::BranchOne {
+ branches: vec![Branch {
+ summary: None,
+ expr: "true".to_string(),
+ modules: vec![inner],
+ modules_node: None,
+ skip_failure: false,
+ parallel: false,
+ }],
+ default: vec![],
+ default_node: None,
+ },
+ )],
+ flow_env: Some(flow_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // min(7, 10) + 3 = 10
+ assert_eq!(
+ result["value"], 10,
+ "complex input transform `Math.min(flow_env.LIMIT, 10) + flow_env.OFFSET` should resolve via QuickJS with inherited flow_env (got {result:?})"
+ );
+
+ Ok(())
+}
+
+// Parallel for-loop iterations are pushed with `flow_innermost_root_job =
+// None` (worker_flow.rs:3941), so the recursive CTE in `fetch_root_flow_env`
+// must use `parent_job` to walk up. Verify a skip_if inside an iteration
+// sub-flow sees the parent's flow_env.
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_skip_if_in_parallel_forloop(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ let mut flow_env = std::collections::HashMap::new();
+ flow_env.insert(
+ "SKIP".to_string(),
+ windmill_common::worker::to_raw_value(&json!(true)),
+ );
+
+ let inner_marker = flow_module(
+ "marker",
+ FlowModuleValue::RawScript {
+ input_transforms: [js_input("i", "flow_input.iter.value")].into(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main(i: number) {
+ return {marker: i};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ let leaf = {
+ let mut m = flow_module(
+ "leaf",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {ran: true};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+ m.skip_if =
+ Some(windmill_common::flows::SkipIf { expr: "flow_env.SKIP === true".to_string() });
+ m
+ };
+
+ let flow = FlowValue {
+ modules: vec![flow_module(
+ "loop",
+ FlowModuleValue::ForloopFlow {
+ iterator: InputTransform::Javascript { expr: "[1, 2]".to_string() },
+ modules: vec![inner_marker, leaf],
+ modules_node: None,
+ skip_failures: false,
+ parallel: true,
+ parallelism: None,
+ squash: None,
+ },
+ )],
+ flow_env: Some(flow_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // Each iteration's `leaf` is skipped (skip_if reads flow_env.SKIP=true via
+ // parent_job lookup since parallel iterations have flow_innermost_root_job
+ // = None). The skipped step passes through previous_result = marker's
+ // output. So iteration result = `{marker: i}`, not `{ran: true}`.
+ let arr = result.as_array().expect("parallel loop result is an array");
+ assert_eq!(arr.len(), 2, "expected 2 iterations, got {result:?}");
+ for (i, iter_result) in arr.iter().enumerate() {
+ assert_eq!(
+ iter_result["marker"],
+ json!(i + 1),
+ "iteration {i} marker mismatch (got {result:?})"
+ );
+ assert!(
+ iter_result.get("ran").is_none(),
+ "leaf ran in iteration {i} when it should have been skipped (got {result:?})"
+ );
+ }
+
+ Ok(())
+}
+
+// Imported flows (`FlowModuleValue::Flow { path }`) load their value from
+// `flow_version`. Two cases:
+// (a) the imported flow defines its own flow_env → that wins, parent's is
+// NOT merged (current behavior; option (i) per design discussion).
+// (b) the imported flow defines no flow_env → it inherits from the parent
+// via the recursive CTE.
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_imported_flow_uses_own_env(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ // Imported flow with its own flow_env: a single step that returns
+ // `flow_env.KEY`. Saved at f/system/imported_with_env.
+ let imported_path = "f/system/imported_with_env";
+ let imported_value = json!({
+ "modules": [{
+ "id": "leaf",
+ "value": {
+ "type": "rawscript",
+ "language": "deno",
+ "content": "export function main(key: string) { return {key}; }",
+ "input_transforms": {
+ "key": { "type": "javascript", "expr": "flow_env.KEY" }
+ }
+ }
+ }],
+ "flow_env": { "KEY": "imported" }
+ });
+ let imported_version_id: i64 = 9991001;
+ sqlx::query!(
+ "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')",
+ "test-workspace",
+ imported_path,
+ imported_version_id,
+ imported_value.clone(),
+ )
+ .execute(&db)
+ .await?;
+ sqlx::query!(
+ "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')",
+ imported_version_id,
+ "test-workspace",
+ imported_path,
+ imported_value.clone(),
+ )
+ .execute(&db)
+ .await?;
+
+ let mut parent_env = std::collections::HashMap::new();
+ parent_env.insert(
+ "KEY".to_string(),
+ windmill_common::worker::to_raw_value(&json!("parent")),
+ );
+
+ let parent = FlowValue {
+ modules: vec![flow_module(
+ "import",
+ FlowModuleValue::Flow {
+ input_transforms: Default::default(),
+ path: imported_path.to_string(),
+ pass_flow_input_directly: None,
+ },
+ )],
+ flow_env: Some(parent_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: parent, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // The imported flow's `leaf` reads flow_env.KEY. The imported flow has its
+ // own flow_env so it wins — result should be "imported", not "parent".
+ assert_eq!(
+ result["key"], "imported",
+ "imported flow's own flow_env should win over parent's (got {result:?})"
+ );
+
+ Ok(())
+}
+
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_imported_flow_inherits_when_unset(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ // Imported flow without its own flow_env. The leaf step's `skip_if` uses
+ // a Boolean()-wrapped expression — that always falls through to QuickJS
+ // (no regex/API fast path) and reads the LOCAL flow_env. Without the fix,
+ // local flow_env inside the imported sub-flow is None and the predicate
+ // returns false; with the fix, the imported flow inherits the parent's
+ // env via the recursive CTE.
+ let imported_path = "f/system/imported_no_env";
+ let imported_value = json!({
+ "modules": [
+ {
+ "id": "marker",
+ "value": {
+ "type": "rawscript",
+ "language": "deno",
+ "content": "export function main() { return {marker: \"from-imported\"}; }",
+ "input_transforms": {}
+ }
+ },
+ {
+ "id": "leaf",
+ "value": {
+ "type": "rawscript",
+ "language": "deno",
+ "content": "export function main() { return {ran: true}; }",
+ "input_transforms": {}
+ },
+ "skip_if": { "expr": "flow_env.KEY === 'parent'" }
+ }
+ ]
+ });
+ let imported_version_id: i64 = 9991002;
+ sqlx::query!(
+ "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')",
+ "test-workspace",
+ imported_path,
+ imported_version_id,
+ imported_value.clone(),
+ )
+ .execute(&db)
+ .await?;
+ sqlx::query!(
+ "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')",
+ imported_version_id,
+ "test-workspace",
+ imported_path,
+ imported_value.clone(),
+ )
+ .execute(&db)
+ .await?;
+
+ let mut parent_env = std::collections::HashMap::new();
+ parent_env.insert(
+ "KEY".to_string(),
+ windmill_common::worker::to_raw_value(&json!("parent")),
+ );
+
+ let parent = FlowValue {
+ modules: vec![flow_module(
+ "import",
+ FlowModuleValue::Flow {
+ input_transforms: Default::default(),
+ path: imported_path.to_string(),
+ pass_flow_input_directly: None,
+ },
+ )],
+ flow_env: Some(parent_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: parent, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // Imported flow has no flow_env → inherits parent's via lookup. The
+ // leaf's skip_if (`flow_env.KEY === 'parent'`) evaluates to true → leaf
+ // becomes identity, passes through `previous_result` (marker's output).
+ // Without the fix, skip_if's QuickJS context has flow_env=None inside
+ // the imported sub-flow, the predicate is false, and `leaf` runs.
+ assert_eq!(
+ result["marker"], "from-imported",
+ "imported flow's leaf should be skipped via inherited flow_env (got {result:?})"
+ );
+ assert!(
+ result.get("ran").is_none(),
+ "leaf ran when it should have been skipped (got {result:?})"
+ );
+
+ Ok(())
+}
+
+// Imported flow with its own flow_env contains a nested BranchOne whose
+// inner step has a skip_if predicate. The branch sub-flow inside the
+// imported flow has `root_job` pointing to the **top parent**, but its
+// `flow_innermost_root_job` points to the imported flow — so the lookup
+// must walk via flow_innermost_root_job to find the imported flow's scope,
+// not jump straight to root_job (which would surface the parent's env and
+// give the wrong answer).
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ // Imported flow: own flow_env={KEY: "imported"}, contains a BranchOne
+ // whose inner step has skip_if = "flow_env.KEY === 'imported'". The
+ // predicate must see the IMPORTED flow's env, not the parent's
+ // ({KEY: "parent"}).
+ let imported_path = "f/system/imported_with_nested_branch";
+ let imported_value = json!({
+ "modules": [{
+ "id": "router",
+ "value": {
+ "type": "branchone",
+ "branches": [{
+ "summary": null,
+ "expr": "true",
+ "modules": [
+ {
+ "id": "marker",
+ "value": {
+ "type": "rawscript",
+ "language": "deno",
+ "content": "export function main() { return {marker: \"from-imported-branch\"}; }",
+ "input_transforms": {}
+ }
+ },
+ {
+ "id": "leaf",
+ "value": {
+ "type": "rawscript",
+ "language": "deno",
+ "content": "export function main() { return {ran: true}; }",
+ "input_transforms": {}
+ },
+ "skip_if": { "expr": "flow_env.KEY === 'imported'" }
+ }
+ ],
+ "skip_failure": false,
+ "parallel": false,
+ }],
+ "default": [],
+ }
+ }],
+ "flow_env": { "KEY": "imported" }
+ });
+ let imported_version_id: i64 = 9991003;
+ sqlx::query!(
+ "INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ($1, '', '', $2, ARRAY[$3]::bigint[], '{}'::jsonb, $4, 'system')",
+ "test-workspace",
+ imported_path,
+ imported_version_id,
+ imported_value.clone(),
+ )
+ .execute(&db)
+ .await?;
+ sqlx::query!(
+ "INSERT INTO public.flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, $2, $3, '{}'::jsonb, $4, 'system')",
+ imported_version_id,
+ "test-workspace",
+ imported_path,
+ imported_value.clone(),
+ )
+ .execute(&db)
+ .await?;
+
+ let mut parent_env = std::collections::HashMap::new();
+ parent_env.insert(
+ "KEY".to_string(),
+ windmill_common::worker::to_raw_value(&json!("parent")),
+ );
+
+ let parent = FlowValue {
+ modules: vec![flow_module(
+ "import",
+ FlowModuleValue::Flow {
+ input_transforms: Default::default(),
+ path: imported_path.to_string(),
+ pass_flow_input_directly: None,
+ },
+ )],
+ flow_env: Some(parent_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: parent, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // skip_if must see imported's env (KEY="imported") → predicate is true →
+ // leaf is skipped → branch returns marker's output. If the lookup
+ // shortcuts via root_job to the top parent, KEY would be "parent",
+ // skip_if would be false, leaf would run and return {ran: true}.
+ assert_eq!(
+ result["marker"], "from-imported-branch",
+ "skip_if inside imported flow's branch must see imported's flow_env, not parent's (got {result:?})"
+ );
+ assert!(
+ result.get("ran").is_none(),
+ "leaf ran — predicate didn't see imported flow's flow_env scope (got {result:?})"
+ );
+
+ Ok(())
+}
+
+// stop_after_if predicate sees flow_env. Regression for the eval at line 614
+// of `update_flow_status_after_job_completion_internal` which used to pass
+// `None` for flow_env unconditionally.
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_in_stop_after_if(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ let mut flow_env = std::collections::HashMap::new();
+ flow_env.insert(
+ "STOP".to_string(),
+ windmill_common::worker::to_raw_value(&json!(true)),
+ );
+
+ let first = {
+ let mut m = flow_module(
+ "first",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {stage: "first"};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+ m.stop_after_if = Some(windmill_common::flows::StopAfterIf {
+ expr: "flow_env.STOP === true".to_string(),
+ skip_if_stopped: true,
+ error_message: None,
+ });
+ m
+ };
+
+ let second = flow_module(
+ "second",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {stage: "second"};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ let flow = FlowValue {
+ modules: vec![first, second],
+ flow_env: Some(flow_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // With fix: stop_after_if reads flow_env.STOP=true → flow stops early
+ // after `first`, result is first's output.
+ // Without fix: stop_after_if sees flow_env=None, predicate is false, the
+ // flow continues to `second` whose output overrides the result.
+ assert_eq!(
+ result["stage"], "first",
+ "stop_after_if with flow_env should stop after `first`; got {result:?}"
+ );
+
+ Ok(())
+}
+
+// retry_if predicate sees flow_env. Regression for the two evaluate_retry
+// call sites in `update_flow_status_after_job_completion_internal` (lines
+// 1194 and 1576) which used to pass `None` for flow_env.
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_in_retry_if(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ let mut flow_env = std::collections::HashMap::new();
+ flow_env.insert(
+ "SHOULD_RETRY".to_string(),
+ windmill_common::worker::to_raw_value(&json!(true)),
+ );
+
+ let fails = {
+ let mut m = flow_module(
+ "fails",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ throw new Error("nope");
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+ m.retry = Some(windmill_common::flows::Retry {
+ constant: windmill_common::flows::ConstantDelay { attempts: 2, seconds: 0 },
+ exponential: Default::default(),
+ retry_if: Some(windmill_common::flows::RetryIf {
+ expr: "flow_env.SHOULD_RETRY === true".to_string(),
+ }),
+ });
+ m
+ };
+
+ let flow = FlowValue {
+ modules: vec![fails],
+ flow_env: Some(flow_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let completed =
+ RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await;
+
+ // The flow always fails (script throws every attempt), but retry_if
+ // controls whether retries happen at all. With the fix, retry_if sees
+ // flow_env.SHOULD_RETRY=true and retries fire (fail_count > 0). Without
+ // the fix, the predicate gets `None` for flow_env, evaluates to false,
+ // and the flow fails on the first attempt with fail_count = 0.
+ let flow_status = completed
+ .flow_status
+ .as_ref()
+ .expect("flow should have a flow_status");
+ let module_status = &flow_status["modules"][0];
+ let failed_retries = module_status["failed_retries"].as_array();
+ assert!(
+ failed_retries.is_some_and(|v| !v.is_empty()),
+ "retry_if with flow_env should have triggered retries; module status: {module_status:?}"
+ );
+
+ Ok(())
+}
+
+// stop_after_all_iters_if predicate sees flow_env. Regression for the
+// signature change to `evaluate_stop_after_all_iters_if`.
+#[cfg(feature = "deno_core")]
+#[sqlx::test(fixtures("base"))]
+async fn test_flow_env_in_stop_after_all_iters_if(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+
+ let mut flow_env = std::collections::HashMap::new();
+ flow_env.insert(
+ "STOP".to_string(),
+ windmill_common::worker::to_raw_value(&json!(true)),
+ );
+
+ let inner = flow_module(
+ "iter_step",
+ FlowModuleValue::RawScript {
+ input_transforms: [js_input("i", "flow_input.iter.value")].into(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main(i: number) {
+ return {iter: i};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ let loop_module = {
+ let mut m = flow_module(
+ "loop",
+ FlowModuleValue::ForloopFlow {
+ iterator: InputTransform::Javascript { expr: "[1, 2, 3]".to_string() },
+ modules: vec![inner],
+ modules_node: None,
+ skip_failures: false,
+ parallel: false,
+ parallelism: None,
+ squash: None,
+ },
+ );
+ m.stop_after_all_iters_if = Some(windmill_common::flows::StopAfterIf {
+ expr: "flow_env.STOP === true".to_string(),
+ skip_if_stopped: true,
+ error_message: None,
+ });
+ m
+ };
+
+ let after = flow_module(
+ "after",
+ FlowModuleValue::RawScript {
+ input_transforms: Default::default(),
+ language: ScriptLang::Deno,
+ content: r#"
+export function main() {
+ return {stage: "after-loop"};
+}
+"#
+ .to_string(),
+ path: None,
+ lock: None,
+ tag: None,
+ concurrency_settings: Default::default(),
+ is_trigger: None,
+ assets: None,
+ },
+ );
+
+ let flow = FlowValue {
+ modules: vec![loop_module, after],
+ flow_env: Some(flow_env),
+ same_worker: false,
+ ..Default::default()
+ };
+
+ let result =
+ RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
+ .run_until_complete(&db, false, server.addr.port())
+ .await
+ .json_result()
+ .unwrap();
+
+ // With fix: stop_after_all_iters_if reads flow_env.STOP=true after the
+ // loop completes → flow stops, `after` does not run, final result is
+ // the loop's output.
+ // Without fix: predicate sees flow_env=None, returns false, `after` runs
+ // and overrides the result.
+ assert!(
+ result.get("stage").is_none() || result["stage"] != "after-loop",
+ "stop_after_all_iters_if with flow_env should stop after the loop; got {result:?}"
+ );
+
+ Ok(())
+}
diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs
index 5cb9d6c2ec..5ffba7a818 100644
--- a/backend/tests/worker.rs
+++ b/backend/tests/worker.rs
@@ -5060,6 +5060,88 @@ async fn test_flow_substep_tag_availability_check(db: Pool) -> anyhow:
Ok(())
}
+#[cfg(all(feature = "quickjs", feature = "python"))]
+#[sqlx::test(fixtures("base"))]
+async fn test_whileloop_propagates_inner_iterator_eval_failure(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+
+ // Regression test: while-loop with skip_failures=false whose body has
+ // a successful step followed by a for-loop whose iterator expression
+ // throws. Previously the iterator-eval failure was silently swallowed
+ // because `handle_flow` returning `Err` left the local `success` set to
+ // the prior step's outcome (true). The parent while-loop received
+ // success=true and kept iterating despite the inner failure.
+ let port = 123;
+ let flow: FlowValue = serde_json::from_value(serde_json::json!({
+ "modules": [
+ {
+ "id": "outer",
+ "value": {
+ "type": "whileloopflow",
+ "skip_failures": false,
+ "modules": [
+ {
+ "id": "prev",
+ "value": {
+ "input_transforms": {
+ "i": {
+ "type": "javascript",
+ "expr": "flow_input.iter.index",
+ },
+ },
+ "type": "rawscript",
+ "language": "python3",
+ "content": "def main(i): return i",
+ },
+ },
+ {
+ "id": "inner",
+ "value": {
+ "type": "forloopflow",
+ // results.prev is null, so `.does_not_exist` throws.
+ "iterator": {
+ "type": "javascript",
+ "expr": "results.prev.does_not_exist",
+ },
+ "skip_failures": false,
+ "parallel": false,
+ "modules": [{
+ "id": "leaf",
+ "value": {
+ "type": "rawscript",
+ "language": "python3",
+ "content": "def main(): return 1",
+ },
+ }],
+ },
+ },
+ ],
+ },
+ // bound iteration count so without the fix the test fails fast
+ // with success=true (rather than running forever); with the fix
+ // the loop stops at iter 0 with success=false.
+ "stop_after_if": {
+ "expr": "result >= 2",
+ "skip_if_stopped": false,
+ },
+ },
+ ],
+ }))
+ .unwrap();
+ let job = JobPayload::RawFlow { value: flow, path: None, restarted_from: None };
+
+ let cjob = RunJob::from(job).run_until_complete(&db, false, port).await;
+
+ assert!(
+ !cjob.success,
+ "flow should fail when inner forloop iterator throws inside a while-loop with skip_failures=false"
+ );
+
+ Ok(())
+}
+
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_stop_after_all_iters_if_bad_expr_parallel_branchall(
diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs
new file mode 100644
index 0000000000..823ff22a43
--- /dev/null
+++ b/backend/tests/workspace_export.rs
@@ -0,0 +1,198 @@
+use sqlx::postgres::Postgres;
+use sqlx::Pool;
+use windmill_test_utils::{initialize_tracing, ApiServer};
+
+/// Integration test: exercises every explicit-column query in `tarball_workspace`.
+///
+/// Creates one entity of each type (folder, script, resource, resource_type,
+/// variable, schedule, group) in the test workspace, then calls the tarball
+/// export endpoint with all include_* flags enabled. Success means every
+/// `SELECT col1, col2, ...` list matches the database schema.
+///
+/// Tables exercised (one explicit-column query each):
+/// folder, script, resource, resource_type, variable, schedule, usr, group_
+#[sqlx::test(fixtures("base"))]
+async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let base_url = format!("http://localhost:{port}");
+
+ let client = windmill_api_client::create_client(&base_url, "SECRET_TOKEN".to_string());
+ let http = client.client();
+
+ // ---- folder ----
+ sqlx::query(
+ r#"INSERT INTO folder
+ (workspace_id, name, display_name, owners, extra_perms, summary)
+ VALUES ($1, $2, $3, $4, '{}'::jsonb, $5)"#,
+ )
+ .bind("test-workspace")
+ .bind("test_folder")
+ .bind("Test Folder")
+ .bind(vec!["u/test-user"])
+ .bind("a test folder")
+ .execute(&db)
+ .await?;
+
+ // ---- script (exercises the 30-column Script query) ----
+ client
+ .create_script(
+ "test-workspace",
+ &windmill_api_client::types::NewScript {
+ content: "export function main() { return 42; }".to_string(),
+ language: windmill_api_client::types::ScriptLang::Bun,
+ path: "f/test_folder/test_script".to_string(),
+ summary: "test script".to_string(),
+ description: "script for export test".to_string(),
+ kind: Some("script".to_string()),
+ tag: Some("test".to_string()),
+ lock: None,
+ parent_hash: None,
+ schema: Default::default(),
+ is_template: None,
+ draft_only: None,
+ dedicated_worker: None,
+ ws_error_handler_muted: None,
+ priority: None,
+ cache_ttl: None,
+ concurrent_limit: None,
+ concurrency_time_window_s: None,
+ timeout: None,
+ delete_after_secs: None,
+ restart_unless_cancelled: None,
+ visible_to_runner_only: None,
+ auto_kind: None,
+ on_behalf_of_email: None,
+ has_preprocessor: None,
+ codebase: None,
+ envs: vec![],
+ deployment_message: None,
+ assets: vec![],
+ modules: None,
+ concurrency_key: None,
+ },
+ )
+ .await?;
+
+ // ---- resource ----
+ sqlx::query(
+ r#"INSERT INTO resource
+ (workspace_id, path, value, description, resource_type, created_by)
+ VALUES ($1, $2, $3, $4, $5, $6)"#,
+ )
+ .bind("test-workspace")
+ .bind("f/test_folder/test_res")
+ .bind(serde_json::json!({"url": "http://example.com"}))
+ .bind("test resource")
+ .bind("http")
+ .bind("test-user")
+ .execute(&db)
+ .await?;
+
+ // ---- resource_type ----
+ sqlx::query(
+ r#"INSERT INTO resource_type
+ (workspace_id, name, schema, description, created_by)
+ VALUES ($1, $2, $3, $4, $5)"#,
+ )
+ .bind("test-workspace")
+ .bind("http")
+ .bind(serde_json::json!({"type": "object"}))
+ .bind("HTTP resource type")
+ .bind("system")
+ .execute(&db)
+ .await?;
+
+ // ---- variable ----
+ sqlx::query(
+ r#"INSERT INTO variable
+ (workspace_id, path, value, is_secret, description, account)
+ VALUES ($1, $2, $3, $4, $5, $6)"#,
+ )
+ .bind("test-workspace")
+ .bind("f/test_folder/test_var")
+ .bind("test_value")
+ .bind(false)
+ .bind("test variable")
+ .bind(None::)
+ .execute(&db)
+ .await?;
+
+ // ---- schedule ----
+ client
+ .create_schedule(
+ "test-workspace",
+ &windmill_api_client::types::NewSchedule {
+ schedule: "0 0 0 * * *".to_string(),
+ script_path: "f/test_folder/test_script".to_string(),
+ path: "f/test_folder/test_schedule".to_string(),
+ is_flow: false,
+ timezone: "UTC".to_string(),
+ args: Default::default(),
+ enabled: Some(false),
+ description: Some("test schedule".to_string()),
+ summary: Some("test schedule".to_string()),
+ tag: None,
+ cron_version: Some("v2".to_string()),
+ on_failure: None,
+ on_failure_times: None,
+ on_failure_exact: None,
+ on_failure_extra_args: None,
+ on_recovery: None,
+ on_recovery_times: None,
+ on_recovery_extra_args: None,
+ on_success: None,
+ on_success_extra_args: None,
+ ws_error_handler_muted: None,
+ retry: None,
+ no_flow_overlap: None,
+ },
+ )
+ .await?;
+
+ // ---- group_ (base fixture already has "all", create one more for include_groups) ----
+ sqlx::query(
+ "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, '{}'::jsonb)",
+ )
+ .bind("test-workspace")
+ .bind("testgroup")
+ .bind("test group")
+ .execute(&db)
+ .await?;
+
+ // ---- tarball export: hits ALL explicit-column queries at once ----
+ let params = [
+ "archive_type=tar",
+ "include_schedules=true",
+ "include_users=true",
+ "include_groups=true",
+ "include_settings=true",
+ "include_workspace_dependencies=true",
+ "settings_version=v1",
+ ];
+
+ let resp = http
+ .get(format!(
+ "{}/api/w/test-workspace/workspaces/tarball?{}",
+ base_url,
+ params.join("&")
+ ))
+ .bearer_auth("SECRET_TOKEN")
+ .send()
+ .await?;
+
+ assert_eq!(
+ resp.status(),
+ 200,
+ "tarball export failed: {}",
+ resp.text().await.unwrap_or_default()
+ );
+
+ // Verify we got actual bytes back
+ let body = resp.bytes().await?;
+ assert!(!body.is_empty(), "tarball export returned empty body");
+
+ Ok(())
+}
+
diff --git a/backend/tests/ws_specific.rs b/backend/tests/ws_specific.rs
new file mode 100644
index 0000000000..51b3590678
--- /dev/null
+++ b/backend/tests/ws_specific.rs
@@ -0,0 +1,357 @@
+//! Integration tests for the workspace-specific (ws_specific) feature.
+//!
+//! Covers three regression-prone areas:
+//!
+//! 1. **Linked-delete cleanup** — deleting a resource (or variable) must also
+//! drop the cross-kind ws_specific row that was auto-inserted by
+//! `mark_linked_variables_ws_specific` so a later item recreated at the
+//! same path doesn't inherit a stale flag.
+//! 2. **list_ws_specific authorization filtering** — the endpoint must hide
+//! paths the caller cannot see via the underlying resource/variable RLS.
+//! 3. **Resource upsert with `ws_specific: false`** — `create_resource` with
+//! `update_if_exists=true` and `ws_specific: false` must clear an
+//! existing flag (was previously a silent no-op).
+
+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))
+}
+
+/// Helper: count rows in ws_specific for (workspace, kind, path).
+async fn ws_specific_row_count(
+ db: &Pool,
+ workspace: &str,
+ kind: &str,
+ path: &str,
+) -> anyhow::Result {
+ let n: Option = sqlx::query_scalar(
+ "SELECT COUNT(*) FROM ws_specific
+ WHERE workspace_id = $1 AND item_kind = $2 AND path = $3",
+ )
+ .bind(workspace)
+ .bind(kind)
+ .bind(path)
+ .fetch_one(db)
+ .await?;
+ Ok(n.unwrap_or(0))
+}
+
+#[sqlx::test(fixtures("ws_specific"))]
+async fn test_linked_delete_cleanup(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");
+
+ // Create a referenced variable.
+ let resp = authed(
+ client().post(format!("{base}/variables/create")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/db_pwd",
+ "value": "hunter2",
+ "is_secret": false,
+ "description": ""
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "create var: {}", resp.text().await?);
+
+ // Create a ws_specific resource that references the variable via $var:.
+ let resp = authed(
+ client().post(format!("{base}/resources/create")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/db",
+ "value": { "user": "admin", "password": "$var:u/test-user/db_pwd" },
+ "description": "",
+ "resource_type": "object",
+ "ws_specific": true
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "create res: {}", resp.text().await?);
+
+ // The auto-mark on save inserts a ws_specific 'variable' row for the
+ // linked variable.
+ assert_eq!(
+ ws_specific_row_count(&db, "test-workspace", "variable", "u/test-user/db_pwd").await?,
+ 1,
+ "linked variable should be auto-marked ws_specific"
+ );
+ assert_eq!(
+ ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/db").await?,
+ 1
+ );
+
+ // Delete the resource — it should cascade to the linked variable AND
+ // the ws_specific row for that variable.
+ let resp = authed(
+ client().delete(format!("{base}/resources/delete/u/test-user/db")),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "delete res: {}", resp.text().await?);
+
+ assert_eq!(
+ ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/db").await?,
+ 0,
+ "resource ws_specific row should be gone"
+ );
+ assert_eq!(
+ ws_specific_row_count(&db, "test-workspace", "variable", "u/test-user/db_pwd").await?,
+ 0,
+ "orphaned linked-variable ws_specific row should also be gone"
+ );
+
+ // The same fix applies in reverse: delete_variable must clean the
+ // ws_specific 'resource' row at the same path.
+ let resp = authed(
+ client().post(format!("{base}/variables/create")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/twin",
+ "value": "v",
+ "is_secret": false,
+ "description": ""
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201);
+
+ let resp = authed(
+ client().post(format!("{base}/resources/create")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/twin",
+ "value": { "x": 1 },
+ "resource_type": "object",
+ "ws_specific": true
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201);
+
+ // ws_specific row exists for resource at u/test-user/twin
+ assert_eq!(
+ ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/twin").await?,
+ 1
+ );
+
+ // Delete the variable at the same path: cascades to the resource AND
+ // its ws_specific row.
+ let resp = authed(
+ client().delete(format!("{base}/variables/delete/u/test-user/twin")),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "delete var: {}", resp.text().await?);
+
+ assert_eq!(
+ ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/twin").await?,
+ 0,
+ "ws_specific resource row should be cleaned by variable delete"
+ );
+
+ Ok(())
+}
+
+#[sqlx::test(fixtures("ws_specific"))]
+async fn test_list_ws_specific_filters_by_rls(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");
+
+ // Admin creates two ws_specific items: one in u/test-user/* (private to
+ // test-user) and one in u/test-user-2/* (private to test-user-2).
+ for path in ["u/test-user/admin_only", "u/test-user-2/user2_only"] {
+ let resp = authed(
+ client().post(format!("{base}/variables/create")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": path,
+ "value": "v",
+ "is_secret": false,
+ "description": "",
+ "ws_specific": true
+ }))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 201,
+ "create var {path}: {}",
+ resp.text().await?
+ );
+ }
+
+ // Admin sees both via list_ws_specific.
+ let resp = authed(
+ client().get(format!("{base}/workspaces/list_ws_specific")),
+ "SECRET_TOKEN",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200);
+ let admin_items: Vec = resp.json().await?;
+ let admin_paths: Vec<&str> = admin_items
+ .iter()
+ .filter_map(|i| i.get("path").and_then(|p| p.as_str()))
+ .collect();
+ assert!(admin_paths.contains(&"u/test-user/admin_only"));
+ assert!(admin_paths.contains(&"u/test-user-2/user2_only"));
+
+ // Non-admin (test-user-2) only sees their own u/test-user-2/* path —
+ // u/test-user/admin_only is filtered by RLS see_own (path requires
+ // SPLIT_PART(path,'/',2) = session.user).
+ let resp = authed(
+ client().get(format!("{base}/workspaces/list_ws_specific")),
+ "SECRET_TOKEN_2",
+ )
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200);
+ let user2_items: Vec = resp.json().await?;
+ let user2_paths: Vec<&str> = user2_items
+ .iter()
+ .filter_map(|i| i.get("path").and_then(|p| p.as_str()))
+ .collect();
+ assert!(
+ user2_paths.contains(&"u/test-user-2/user2_only"),
+ "user2 should see their own ws_specific item, got: {user2_paths:?}"
+ );
+ assert!(
+ !user2_paths.contains(&"u/test-user/admin_only"),
+ "user2 should NOT see admin's ws_specific item, got: {user2_paths:?}"
+ );
+
+ Ok(())
+}
+
+#[sqlx::test(fixtures("ws_specific"))]
+async fn test_create_resource_upsert_clears_ws_specific(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");
+
+ // Step 1: create resource with ws_specific=true.
+ let resp = authed(
+ client().post(format!("{base}/resources/create")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/upsert_target",
+ "value": { "host": "h" },
+ "resource_type": "object",
+ "ws_specific": true
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "{}", resp.text().await?);
+ assert_eq!(
+ ws_specific_row_count(
+ &db,
+ "test-workspace",
+ "resource",
+ "u/test-user/upsert_target"
+ )
+ .await?,
+ 1
+ );
+
+ // Step 2: upsert (update_if_exists=true) with ws_specific=false — must
+ // CLEAR the existing row.
+ let resp = authed(
+ client().post(format!("{base}/resources/create?update_if_exists=true")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/upsert_target",
+ "value": { "host": "h2" },
+ "resource_type": "object",
+ "ws_specific": false
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "{}", resp.text().await?);
+ assert_eq!(
+ ws_specific_row_count(
+ &db,
+ "test-workspace",
+ "resource",
+ "u/test-user/upsert_target"
+ )
+ .await?,
+ 0,
+ "ws_specific=false on upsert must clear the existing row"
+ );
+
+ // Step 3: upsert without ws_specific (None) leaves whatever's there
+ // alone — re-flag it true, then upsert with no field, expect row stays.
+ let resp = authed(
+ client().post(format!("{base}/resources/create?update_if_exists=true")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/upsert_target",
+ "value": { "host": "h3" },
+ "resource_type": "object",
+ "ws_specific": true
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201);
+ assert_eq!(
+ ws_specific_row_count(
+ &db,
+ "test-workspace",
+ "resource",
+ "u/test-user/upsert_target"
+ )
+ .await?,
+ 1
+ );
+
+ let resp = authed(
+ client().post(format!("{base}/resources/create?update_if_exists=true")),
+ "SECRET_TOKEN",
+ )
+ .json(&json!({
+ "path": "u/test-user/upsert_target",
+ "value": { "host": "h4" },
+ "resource_type": "object"
+ // no ws_specific field
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201);
+ assert_eq!(
+ ws_specific_row_count(
+ &db,
+ "test-workspace",
+ "resource",
+ "u/test-user/upsert_target"
+ )
+ .await?,
+ 1,
+ "absent ws_specific field must leave the existing flag alone"
+ );
+
+ Ok(())
+}
diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs
index 0901e73e67..e18afc35c8 100644
--- a/backend/windmill-api-configs/src/lib.rs
+++ b/backend/windmill-api-configs/src/lib.rs
@@ -64,7 +64,7 @@ async fn list_worker_groups(
Extension(db): Extension,
) -> error::JsonResult> {
let mut configs_raw =
- sqlx::query_as!(Config, "SELECT * FROM config WHERE name LIKE 'worker__%'")
+ sqlx::query_as!(Config, "SELECT name, config FROM config WHERE name LIKE 'worker__%'")
.fetch_all(&db)
.await?;
// Remove the 'worker__' prefix from all config names
@@ -119,7 +119,7 @@ async fn get_config(
) -> error::JsonResult> {
require_devops_role(&db, &authed.email).await?;
- let config = sqlx::query_as!(Config, "SELECT * FROM config WHERE name = $1", name)
+ let config = sqlx::query_as!(Config, "SELECT name, config FROM config WHERE name = $1", name)
.fetch_optional(&db)
.await?
.map(|c| c.config);
diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs
index ae8eca67cf..f0c87633d2 100644
--- a/backend/windmill-api-embeddings/src/lib.rs
+++ b/backend/windmill-api-embeddings/src/lib.rs
@@ -365,7 +365,7 @@ impl EmbeddingsDb {
let hub_resource_types = response.json::>().await?;
let resource_types: Vec =
- sqlx::query_as!(ResourceType, "SELECT * from resource_type ORDER BY name",)
+ sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name",)
.fetch_all(pg_db)
.await?;
diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs
index eac532dc78..95243b6626 100644
--- a/backend/windmill-api-groups/src/granular_acls.rs
+++ b/backend/windmill-api-groups/src/granular_acls.rs
@@ -131,6 +131,7 @@ async fn add_granular_acl(
}
}
+ // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms"
@@ -294,6 +295,7 @@ async fn remove_granular_acl(
require_owner_of_path(&authed, path)?;
}
+ // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
"WITH old AS (
SELECT extra_perms->$1 as old_write FROM {kind}
@@ -419,6 +421,7 @@ async fn get_granular_acls(
} else {
"path"
};
+ // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
))
diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs
index af67f884c9..263daad624 100644
--- a/backend/windmill-api-groups/src/groups.rs
+++ b/backend/windmill-api-groups/src/groups.rs
@@ -109,7 +109,7 @@ async fn list_groups(
let rows = sqlx::query_as!(
Group,
- "SELECT * FROM group_ WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
+ "SELECT workspace_id, name, summary, extra_perms FROM group_ WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
offset as i64
@@ -574,7 +574,7 @@ pub async fn get_group_opt<'c>(
) -> Result> {
let group_opt = sqlx::query_as!(
Group,
- "SELECT * FROM group_ WHERE name = $1 AND workspace_id = $2",
+ "SELECT workspace_id, name, summary, extra_perms FROM group_ WHERE name = $1 AND workspace_id = $2",
name,
w_id
)
diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs
index f9cb6037c5..dc4341cc26 100644
--- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs
+++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs
@@ -119,7 +119,9 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
let fork_response = client
.client()
- .post(&format!("{base_url}/w/test-workspace/workspaces/create_fork"))
+ .post(&format!(
+ "{base_url}/w/test-workspace/workspaces/create_fork"
+ ))
.json(&json!({
"id": "wm-fork-test-workspace",
"name": "Test Fork",
@@ -129,7 +131,11 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
.await?;
let status = fork_response.status();
- assert!(status.is_success(), "Fork creation should succeed: {}", status);
+ assert!(
+ status.is_success(),
+ "Fork creation should succeed: {}",
+ status
+ );
// Verify fork was created
let fork_exists = sqlx::query_scalar!(
@@ -346,16 +352,24 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
let comparison: serde_json::Value = client
.client()
- .get(&format!("{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"))
+ .get(&format!(
+ "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
+ ))
.send()
.await?
.json()
.await?;
// Verify basic structure
- assert!(!comparison["skipped_comparison"].as_bool().unwrap_or(true), "Should not skip comparison");
+ assert!(
+ !comparison["skipped_comparison"].as_bool().unwrap_or(true),
+ "Should not skip comparison"
+ );
assert!(comparison["diffs"].is_array(), "Should have diffs array");
- assert!(comparison["summary"].is_object(), "Should have summary object");
+ assert!(
+ comparison["summary"].is_object(),
+ "Should have summary object"
+ );
let diffs = comparison["diffs"].as_array().unwrap();
let summary = &comparison["summary"];
@@ -379,12 +393,30 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
assert!(conflicts >= 1, "Should have at least 1 conflict (flow)");
// Verify per-item-type counts
- assert!(summary["scripts_changed"].as_u64().unwrap() > 0, "Should have script changes");
- assert!(summary["flows_changed"].as_u64().unwrap() > 0, "Should have flow changes");
- assert!(summary["apps_changed"].as_u64().unwrap() > 0, "Should have app changes");
- assert!(summary["resources_changed"].as_u64().unwrap() > 0, "Should have resource changes");
- assert!(summary["variables_changed"].as_u64().unwrap() > 0, "Should have variable changes");
- assert!(summary["resource_types_changed"].as_u64().unwrap() > 0, "Should have resource_type changes");
+ assert!(
+ summary["scripts_changed"].as_u64().unwrap() > 0,
+ "Should have script changes"
+ );
+ assert!(
+ summary["flows_changed"].as_u64().unwrap() > 0,
+ "Should have flow changes"
+ );
+ assert!(
+ summary["apps_changed"].as_u64().unwrap() > 0,
+ "Should have app changes"
+ );
+ assert!(
+ summary["resources_changed"].as_u64().unwrap() > 0,
+ "Should have resource changes"
+ );
+ assert!(
+ summary["variables_changed"].as_u64().unwrap() > 0,
+ "Should have variable changes"
+ );
+ assert!(
+ summary["resource_types_changed"].as_u64().unwrap() > 0,
+ "Should have resource_type changes"
+ );
// Note: folders_changed may be 0 if folder comparison didn't detect changes
// assert!(summary["folders_changed"].as_u64().unwrap() > 0, "Should have folder changes");
@@ -393,51 +425,130 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
// ==============================================================
// Scenario 1: New in parent
- let new_in_parent = diffs.iter()
+ let new_in_parent = diffs
+ .iter()
.find(|d| d["path"] == "f/shared/new_in_parent" && d["kind"] == "script")
.expect("Should find new_in_parent diff");
- assert_eq!(new_in_parent["ahead"].as_i64().unwrap(), 1, "new_in_parent should be ahead");
- assert_eq!(new_in_parent["behind"].as_i64().unwrap(), 0, "new_in_parent should not be behind");
- assert_eq!(new_in_parent["has_changes"].as_bool().unwrap(), true, "new_in_parent should have changes");
- assert_eq!(new_in_parent["exists_in_source"].as_bool().unwrap(), true, "new_in_parent should exist in source");
- assert_eq!(new_in_parent["exists_in_fork"].as_bool().unwrap(), false, "new_in_parent should not exist in fork");
+ assert_eq!(
+ new_in_parent["ahead"].as_i64().unwrap(),
+ 1,
+ "new_in_parent should be ahead"
+ );
+ assert_eq!(
+ new_in_parent["behind"].as_i64().unwrap(),
+ 0,
+ "new_in_parent should not be behind"
+ );
+ assert_eq!(
+ new_in_parent["has_changes"].as_bool().unwrap(),
+ true,
+ "new_in_parent should have changes"
+ );
+ assert_eq!(
+ new_in_parent["exists_in_source"].as_bool().unwrap(),
+ true,
+ "new_in_parent should exist in source"
+ );
+ assert_eq!(
+ new_in_parent["exists_in_fork"].as_bool().unwrap(),
+ false,
+ "new_in_parent should not exist in fork"
+ );
// Scenario 2: New in fork
- let new_in_fork = diffs.iter()
+ let new_in_fork = diffs
+ .iter()
.find(|d| d["path"] == "f/shared/new_in_fork" && d["kind"] == "script")
.expect("Should find new_in_fork diff");
- assert_eq!(new_in_fork["ahead"].as_i64().unwrap(), 0, "new_in_fork should not be ahead");
- assert_eq!(new_in_fork["behind"].as_i64().unwrap(), 1, "new_in_fork should be behind");
- assert_eq!(new_in_fork["has_changes"].as_bool().unwrap(), true, "new_in_fork should have changes");
- assert_eq!(new_in_fork["exists_in_source"].as_bool().unwrap(), false, "new_in_fork should not exist in source");
- assert_eq!(new_in_fork["exists_in_fork"].as_bool().unwrap(), true, "new_in_fork should exist in fork");
+ assert_eq!(
+ new_in_fork["ahead"].as_i64().unwrap(),
+ 0,
+ "new_in_fork should not be ahead"
+ );
+ assert_eq!(
+ new_in_fork["behind"].as_i64().unwrap(),
+ 1,
+ "new_in_fork should be behind"
+ );
+ assert_eq!(
+ new_in_fork["has_changes"].as_bool().unwrap(),
+ true,
+ "new_in_fork should have changes"
+ );
+ assert_eq!(
+ new_in_fork["exists_in_source"].as_bool().unwrap(),
+ false,
+ "new_in_fork should not exist in source"
+ );
+ assert_eq!(
+ new_in_fork["exists_in_fork"].as_bool().unwrap(),
+ true,
+ "new_in_fork should exist in fork"
+ );
// Scenario 5: Conflict
- let conflict_flow = diffs.iter()
+ let conflict_flow = diffs
+ .iter()
.find(|d| d["path"] == "f/shared/original_flow" && d["kind"] == "flow")
.expect("Should find conflict flow diff");
- assert!(conflict_flow["ahead"].as_i64().unwrap() > 0, "conflict should be ahead");
- assert!(conflict_flow["behind"].as_i64().unwrap() > 0, "conflict should be behind");
- assert_eq!(conflict_flow["has_changes"].as_bool().unwrap(), true, "conflict should have changes");
- assert_eq!(conflict_flow["exists_in_source"].as_bool().unwrap(), true, "conflict should exist in source");
- assert_eq!(conflict_flow["exists_in_fork"].as_bool().unwrap(), true, "conflict should exist in fork");
+ assert!(
+ conflict_flow["ahead"].as_i64().unwrap() > 0,
+ "conflict should be ahead"
+ );
+ assert!(
+ conflict_flow["behind"].as_i64().unwrap() > 0,
+ "conflict should be behind"
+ );
+ assert_eq!(
+ conflict_flow["has_changes"].as_bool().unwrap(),
+ true,
+ "conflict should have changes"
+ );
+ assert_eq!(
+ conflict_flow["exists_in_source"].as_bool().unwrap(),
+ true,
+ "conflict should exist in source"
+ );
+ assert_eq!(
+ conflict_flow["exists_in_fork"].as_bool().unwrap(),
+ true,
+ "conflict should exist in fork"
+ );
// Scenario 6: Deleted in fork
- let deleted = diffs.iter()
+ let deleted = diffs
+ .iter()
.find(|d| d["path"] == "f/shared/to_delete" && d["kind"] == "script")
.expect("Should find deleted diff");
- assert_eq!(deleted["exists_in_source"].as_bool().unwrap(), true, "deleted should exist in source");
- assert_eq!(deleted["exists_in_fork"].as_bool().unwrap(), false, "deleted should not exist in fork (archived)");
- assert_eq!(deleted["has_changes"].as_bool().unwrap(), true, "deleted should have changes");
+ assert_eq!(
+ deleted["exists_in_source"].as_bool().unwrap(),
+ true,
+ "deleted should exist in source"
+ );
+ assert_eq!(
+ deleted["exists_in_fork"].as_bool().unwrap(),
+ false,
+ "deleted should not exist in fork (archived)"
+ );
+ assert_eq!(
+ deleted["has_changes"].as_bool().unwrap(),
+ true,
+ "deleted should have changes"
+ );
// Scenario 7: Rename (should show as two entries)
- let old_name = diffs.iter()
+ let old_name = diffs
+ .iter()
.find(|d| d["path"] == "f/shared/old_name" && d["kind"] == "resource");
- let new_name = diffs.iter()
+ let new_name = diffs
+ .iter()
.find(|d| d["path"] == "f/shared/new_name" && d["kind"] == "resource");
// At least one of these should exist (depending on how the comparison handles renames)
- assert!(old_name.is_some() || new_name.is_some(), "Should find at least one rename-related diff");
+ assert!(
+ old_name.is_some() || new_name.is_some(),
+ "Should find at least one rename-related diff"
+ );
// ==============================================================
// Database State Assertions
@@ -450,9 +561,21 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
)
.fetch_one(&db)
.await?;
- assert_eq!(cached_new_in_parent.has_changes, Some(true), "has_changes should be cached as true");
- assert_eq!(cached_new_in_parent.exists_in_source, Some(true), "exists_in_source should be cached");
- assert_eq!(cached_new_in_parent.exists_in_fork, Some(false), "exists_in_fork should be cached");
+ assert_eq!(
+ cached_new_in_parent.has_changes,
+ Some(true),
+ "has_changes should be cached as true"
+ );
+ assert_eq!(
+ cached_new_in_parent.exists_in_source,
+ Some(true),
+ "exists_in_source should be cached"
+ );
+ assert_eq!(
+ cached_new_in_parent.exists_in_fork,
+ Some(false),
+ "exists_in_fork should be cached"
+ );
// Verify unchanged items were deleted from workspace_diff
let unchanged_original_script = sqlx::query!(
@@ -465,7 +588,11 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
// The unchanged item should either be deleted or marked as has_changes = false
// Based on the code, items with has_changes = false are deleted
if let Some(record) = unchanged_original_script {
- assert_ne!(record.has_changes, Some(false), "unchanged items with has_changes=false should be deleted");
+ assert_ne!(
+ record.has_changes,
+ Some(false),
+ "unchanged items with has_changes=false should be deleted"
+ );
}
// ==============================================================
@@ -485,7 +612,9 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
// Call the endpoint again
let _comparison2: serde_json::Value = client
.client()
- .get(&format!("{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"))
+ .get(&format!(
+ "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
+ ))
.send()
.await?
.json()
@@ -500,7 +629,171 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re
.await?;
// Should be deleted since the item doesn't actually exist in either workspace
- assert!(lazy_test.is_none(), "Non-existent item should be deleted from workspace_diff");
+ assert!(
+ lazy_test.is_none(),
+ "Non-existent item should be deleted from workspace_diff"
+ );
+
+ Ok(())
+}
+
+/// Trigger/schedule diffs go through the same `compare_workspaces` flow as
+/// scripts/flows once tally tracks them. The compare_two_trigger_or_schedule
+/// helper strips runtime fields (mode/enabled/server_id/last_server_ping/
+/// edited_at-by/email/error/extra_perms/permissioned_as), so:
+/// - a real config change shows `has_changes = true`
+/// - a runtime-only change (mode toggle, enabled flip) shows `has_changes = false`
+/// and the row is deleted from `workspace_diff`
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_compare_workspaces_trigger_and_schedule(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let client = windmill_api_client::create_client(
+ &format!("http://localhost:{port}"),
+ "SECRET_TOKEN".to_string(),
+ );
+ let base_url = format!("http://localhost:{port}/api");
+
+ // Parent + fork workspaces (fork created via INSERT to bypass
+ // clone_triggers_and_schedules — we want to control the rows manually).
+ sqlx::query!(
+ "INSERT INTO workspace (id, name, owner, parent_workspace_id)
+ VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')"
+ )
+ .execute(&db)
+ .await?;
+ sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')")
+ .execute(&db)
+ .await?;
+ sqlx::query!(
+ "INSERT INTO workspace_key(workspace_id, kind, key)
+ VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')"
+ )
+ .execute(&db)
+ .await?;
+ sqlx::query!(
+ "INSERT INTO usr(workspace_id, email, username, is_admin, role)
+ VALUES ('wm-fork-test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin')"
+ )
+ .execute(&db)
+ .await?;
+
+ // ------ Schedule: identical config in parent and fork, except `enabled`.
+ // Should be filtered out (no real diff).
+ sqlx::query!(
+ "INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled,
+ script_path, args, is_flow, email, timezone, summary, permissioned_as)
+ VALUES
+ ('test-workspace', 'f/sch/runtime_only', 'test-user', NOW(), '0 * * * * *', true,
+ 'f/scripts/x', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user'),
+ ('wm-fork-test-workspace', 'f/sch/runtime_only', 'test-user', NOW(), '0 * * * * *', false,
+ 'f/scripts/x', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user')"
+ )
+ .execute(&db)
+ .await?;
+
+ // ------ Schedule: config change (script_path) in fork. Should diff.
+ sqlx::query!(
+ "INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled,
+ script_path, args, is_flow, email, timezone, summary, permissioned_as)
+ VALUES
+ ('test-workspace', 'f/sch/config_change', 'test-user', NOW(), '0 * * * * *', false,
+ 'f/scripts/parent_path', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user'),
+ ('wm-fork-test-workspace', 'f/sch/config_change', 'test-user', NOW(), '0 * * * * *', false,
+ 'f/scripts/fork_path', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user')"
+ )
+ .execute(&db)
+ .await?;
+
+ // ------ HTTP trigger: identical config except `mode`. Should be filtered out.
+ sqlx::query!(
+ "INSERT INTO http_trigger (workspace_id, path, edited_by, edited_at, route_path,
+ route_path_key, script_path, is_flow, http_method, request_type,
+ authentication_method, mode, permissioned_as)
+ VALUES
+ ('test-workspace', 'f/rt/runtime_only', 'test-user', NOW(), 'foo', 'foo',
+ 'f/scripts/y', false, 'get', 'sync',
+ 'none', 'enabled', 'u/test-user'),
+ ('wm-fork-test-workspace', 'f/rt/runtime_only', 'test-user', NOW(), 'foo', 'foo',
+ 'f/scripts/y', false, 'get', 'sync',
+ 'none', 'disabled', 'u/test-user')"
+ )
+ .execute(&db)
+ .await?;
+
+ // ------ HTTP trigger: config change (route_path) in fork. Should diff.
+ sqlx::query!(
+ "INSERT INTO http_trigger (workspace_id, path, edited_by, edited_at, route_path,
+ route_path_key, script_path, is_flow, http_method, request_type,
+ authentication_method, mode, permissioned_as)
+ VALUES
+ ('test-workspace', 'f/rt/config_change', 'test-user', NOW(), 'parent', 'parent',
+ 'f/scripts/y', false, 'get', 'sync',
+ 'none', 'disabled', 'u/test-user'),
+ ('wm-fork-test-workspace', 'f/rt/config_change', 'test-user', NOW(), 'fork', 'fork',
+ 'f/scripts/y', false, 'get', 'sync',
+ 'none', 'disabled', 'u/test-user')"
+ )
+ .execute(&db)
+ .await?;
+
+ // Seed workspace_diff with NULL has_changes so compare_workspaces evaluates them lazily.
+ sqlx::query!(
+ "INSERT INTO workspace_diff
+ (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
+ VALUES
+ ('test-workspace', 'wm-fork-test-workspace', 'f/sch/runtime_only', 'schedule', 0, 1, NULL),
+ ('test-workspace', 'wm-fork-test-workspace', 'f/sch/config_change', 'schedule', 0, 1, NULL),
+ ('test-workspace', 'wm-fork-test-workspace', 'f/rt/runtime_only', 'http_trigger', 0, 1, NULL),
+ ('test-workspace', 'wm-fork-test-workspace', 'f/rt/config_change', 'http_trigger', 0, 1, NULL)"
+ )
+ .execute(&db)
+ .await?;
+
+ let comparison: serde_json::Value = client
+ .client()
+ .get(&format!(
+ "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
+ ))
+ .send()
+ .await?
+ .json()
+ .await?;
+
+ let diffs = comparison["diffs"].as_array().unwrap();
+
+ // The runtime-only rows should be filtered out (compare_two_trigger_or_schedule
+ // returned has_changes=false → row deleted from workspace_diff).
+ assert!(
+ !diffs.iter().any(|d| d["path"] == "f/sch/runtime_only"),
+ "schedule with only enabled-flag difference should be filtered out"
+ );
+ assert!(
+ !diffs.iter().any(|d| d["path"] == "f/rt/runtime_only"),
+ "http_trigger with only mode difference should be filtered out"
+ );
+
+ // The config-change rows should be present with has_changes=true.
+ let sch_change = diffs
+ .iter()
+ .find(|d| d["path"] == "f/sch/config_change" && d["kind"] == "schedule")
+ .expect("schedule with config change should appear in diffs");
+ assert_eq!(sch_change["has_changes"].as_bool().unwrap(), true);
+ assert_eq!(sch_change["exists_in_source"].as_bool().unwrap(), true);
+ assert_eq!(sch_change["exists_in_fork"].as_bool().unwrap(), true);
+
+ let rt_change = diffs
+ .iter()
+ .find(|d| d["path"] == "f/rt/config_change" && d["kind"] == "http_trigger")
+ .expect("http_trigger with config change should appear in diffs");
+ assert_eq!(rt_change["has_changes"].as_bool().unwrap(), true);
+
+ // Summary counts.
+ let summary = &comparison["summary"];
+ assert_eq!(summary["schedules_changed"].as_u64().unwrap(), 1);
+ assert_eq!(summary["triggers_changed"].as_u64().unwrap(), 1);
Ok(())
}
diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs
index 8de72d98f8..29fa215800 100644
--- a/backend/windmill-api-schedule/src/lib.rs
+++ b/backend/windmill-api-schedule/src/lib.rs
@@ -335,7 +335,13 @@ async fn create_schedule(
ns.is_flow,
to_json_raw_opt(ns.args.as_ref())
as Option>>,
- ns.enabled.unwrap_or(false),
+ // Default-on matches the enqueue check below (line ~410) and the trigger
+ // create path (`BaseTriggerData::mode()` defaults to `Enabled`). Every
+ // production caller passes `enabled` explicitly except the fork→parent
+ // flows (CLI merge, UI merge, `wmill push` of a fork tarball) — which
+ // either send the source's actual flag (create case) or omit `enabled`
+ // entirely (update case, where `EditSchedule` lacks the field).
+ ns.enabled.unwrap_or(true),
resolved_email,
resolved_permissioned_as,
ns.on_failure,
diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs
index fc4872b631..365c3361d9 100644
--- a/backend/windmill-api-scripts/src/scripts.rs
+++ b/backend/windmill-api-scripts/src/scripts.rs
@@ -981,7 +981,10 @@ async fn create_script_internal<'c>(
.await?;
}
let clashing_script = sqlx::query_as::<_, Script>(
- "SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2",
+ &format!(
+ "SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2",
+ windmill_common::scripts::SCRIPT_COLUMNS,
+ ),
)
.bind(&ns.path)
.bind(&w_id)
@@ -1812,7 +1815,10 @@ async fn get_script_by_path(
.await?
} else {
sqlx::query_as::<_, ScriptWithStarred>(
- "SELECT *, NULL as starred FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
+ &format!(
+ "SELECT {}, NULL as starred 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)
@@ -2327,7 +2333,10 @@ async fn get_script_by_hash_internal<'c>(
.await?
} else {
sqlx::query_as::<_, ScriptWithStarred>(
- "SELECT *, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2",
+ &format!(
+ "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2",
+ windmill_common::scripts::SCRIPT_COLUMNS,
+ ),
)
.bind(hash)
.bind(workspace_id)
diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs
index 2251246f5d..f027046808 100644
--- a/backend/windmill-api-settings/src/lib.rs
+++ b/backend/windmill-api-settings/src/lib.rs
@@ -1132,6 +1132,7 @@ async fn setup_custom_instance_pg_database_inner(
logs.created_database = "SKIP".to_string();
if !db_exists {
+ // SAFETY: `dbname` has been validated by the VALID_NAME regex and length checks above (lines 1088–1120).
sqlx::query(&format!("CREATE DATABASE \"{dbname}\""))
.execute(db)
.await?;
@@ -1144,6 +1145,7 @@ async fn setup_custom_instance_pg_database_inner(
logs.db_connect = "OK".to_string();
+ // SAFETY: `dbname` has been validated by the VALID_NAME regex and length checks above.
client
.batch_execute(&format!(
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user;
diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs
index 951060b531..e4540ca903 100644
--- a/backend/windmill-api-users/src/users.rs
+++ b/backend/windmill-api-users/src/users.rs
@@ -385,7 +385,7 @@ async fn list_users(
let rows = sqlx::query_as!(
User,
"
- SELECT *
+ SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account
FROM usr
WHERE workspace_id = $1
",
@@ -416,7 +416,7 @@ async fn list_user_usage(
SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions
FROM v2_job_completed c JOIN v2_job j USING (id)
WHERE j.workspace_id = $1
- AND j.kind NOT IN ('flow', 'flowpreview', 'flownode')
+ AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')
AND j.permissioned_as_email = usr.email
AND now() - '1 week'::interval < j.created_at
) usage
@@ -1174,7 +1174,7 @@ async fn get_workspace_user(
let user = sqlx::query_as!(
User,
- "SELECT * FROM usr WHERE username = $1 AND workspace_id = $2",
+ "SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account FROM usr WHERE username = $1 AND workspace_id = $2",
&username_to_update,
&w_id
)
@@ -1698,6 +1698,7 @@ pub async fn delete_workspace_user_internal(
"azure_trigger",
"email_trigger",
];
+ // SAFETY: `table` comes from a hardcoded allowlist `extra_perms_tables`, not user input.
for table in &extra_perms_tables {
sqlx::query(&format!(
"UPDATE {table} SET extra_perms = extra_perms - ('u/' || $1) \
diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs
index 9ca9a0dd84..f479b8c719 100644
--- a/backend/windmill-api-workspaces/src/workspaces.rs
+++ b/backend/windmill-api-workspaces/src/workspaces.rs
@@ -40,9 +40,9 @@ use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
use windmill_common::workspaces::{
- check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable,
- DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules,
- ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, WM_FORK_PREFIX,
+ check_user_against_rule, get_datatable_resource_from_db_unchecked, validate_fork_workspace_id,
+ DataTable, DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind,
+ ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings,
};
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
use windmill_common::PgDatabase;
@@ -184,6 +184,8 @@ pub fn workspaced_service() -> Router {
.route("/log_chat", post(log_ai_chat))
.route("/cloud_quotas", get(get_cloud_quotas))
.route("/prune_versions", post(prune_versions))
+ .route("/list_ws_specific", get(list_ws_specific))
+ .route("/list_ws_specific_versions", get(list_ws_specific_versions))
}
pub fn global_service() -> Router {
Router::new()
@@ -4776,6 +4778,8 @@ async fn create_workspace_fork_branch(
return Err(Error::PermissionDenied(msg));
}
+ validate_fork_workspace_id(&nw.id)?;
+
Ok(Json(
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
))
@@ -4935,13 +4939,7 @@ async fn create_workspace_fork(
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
- // Generate unique forked workspace ID with wm-fork prefix
- if !nw.id.starts_with(WM_FORK_PREFIX) {
- return Err(Error::BadRequest(format!(
- "The id `{}` is invalid for a forked workspace. It should be prefixed by {}",
- nw.id, WM_FORK_PREFIX
- )));
- }
+ validate_fork_workspace_id(&nw.id)?;
let forked_id = nw.id;
@@ -6170,6 +6168,8 @@ pub struct CompareSummary {
pub variables_changed: usize,
pub resource_types_changed: usize,
pub folders_changed: usize,
+ pub schedules_changed: usize,
+ pub triggers_changed: usize,
pub conflicts: usize, // Items that are both ahead and behind
}
@@ -6293,6 +6293,20 @@ async fn compare_workspaces(
compare_two_folders(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
+ // Triggers and schedules are diffed against a hardcoded ignore list
+ // (mode/enabled/server_id/last_server_ping/edited_at/by/error/extra_perms/permissioned_as/email)
+ // so that fork-clones — which differ from the parent only in the runtime
+ // mode/enabled flag — don't show as diffs.
+ k if TRIGGER_OR_SCHEDULE_TABLES.contains(&k) => Some(
+ compare_two_trigger_or_schedule(
+ &db,
+ item.kind.as_str(),
+ &source_workspace_id,
+ &fork_workspace_id,
+ &item.path,
+ )
+ .await?,
+ ),
k => {
tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path);
None
@@ -6381,6 +6395,14 @@ async fn compare_workspaces(
.filter(|s| s.kind == "resource_type")
.count(),
folders_changed: visible_diffs.iter().filter(|s| s.kind == "folder").count(),
+ schedules_changed: visible_diffs
+ .iter()
+ .filter(|s| s.kind == "schedule")
+ .count(),
+ triggers_changed: visible_diffs
+ .iter()
+ .filter(|s| s.kind.ends_with("_trigger"))
+ .count(),
conflicts: visible_diffs
.iter()
.filter(|s| s.ahead > 0 && s.behind > 0)
@@ -6533,6 +6555,17 @@ async fn query_visible_items<'c>(
.fetch_all(&mut **tx)
.await?
}
+ k if TRIGGER_OR_SCHEDULE_TABLES.contains(&k) => {
+ // SAFETY: `kind` comes from a hardcoded allowlist
+ // TRIGGER_OR_SCHEDULE_TABLES, not user input.
+ let sql =
+ format!("SELECT path FROM {kind} WHERE workspace_id = $1 AND path = ANY($2)");
+ sqlx::query_scalar(&sql)
+ .bind(workspace_id)
+ .bind(&paths_vec)
+ .fetch_all(&mut **tx)
+ .await?
+ }
_ => vec![], // Unknown kind
};
@@ -6791,6 +6824,35 @@ async fn compare_two_variables(
fork_workspace_id: &str,
path: &str,
) -> Result {
+ // Combine the four EXISTS checks (ws_specific × {source, fork}, variable
+ // × {source, fork}) into a single round-trip; this runs per variable
+ // during a workspace diff so the savings add up.
+ let presence = sqlx::query!(
+ r#"SELECT
+ EXISTS(SELECT 1 FROM ws_specific
+ WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $3) AS "src_ws!",
+ EXISTS(SELECT 1 FROM ws_specific
+ WHERE workspace_id = $2 AND item_kind = 'variable' AND path = $3) AS "tgt_ws!",
+ EXISTS(SELECT 1 FROM variable
+ WHERE workspace_id = $1 AND path = $3) AS "src_var!",
+ EXISTS(SELECT 1 FROM variable
+ WHERE workspace_id = $2 AND path = $3) AS "tgt_var!""#,
+ source_workspace_id,
+ fork_workspace_id,
+ path,
+ )
+ .fetch_one(db)
+ .await?;
+
+ // If either side is ws_specific, consider unchanged
+ if presence.src_ws || presence.tgt_ws {
+ return Ok(ItemComparison {
+ has_changes: false,
+ exists_in_source: presence.src_var,
+ exists_in_fork: presence.tgt_var,
+ });
+ }
+
// Get variable from each workspace
let source_variable = sqlx::query!(
"SELECT value, is_secret, description
@@ -6934,6 +6996,106 @@ async fn compare_two_folders(
});
}
+/// Fields stripped before comparing two trigger or schedule rows.
+///
+/// `mode` and `enabled` are forced to disabled/false on fork clone so they always
+/// differ between fork and parent — comparing them would mark every cloned row as
+/// "changed". The rest are runtime state (`server_id`, `last_server_ping`, `error`)
+/// or per-row metadata that diverges naturally (`edited_at/by`, `email`, `extra_perms`,
+/// `permissioned_as`). Comparing without these answers "is this trigger/schedule
+/// configured the same way?" rather than "are the rows byte-identical?".
+const TRIGGER_COMPARE_IGNORE: &[&str] = &[
+ "workspace_id",
+ "edited_by",
+ "edited_at",
+ "email",
+ "error",
+ "enabled",
+ "mode",
+ "server_id",
+ "last_server_ping",
+ "extra_perms",
+ "permissioned_as",
+ // Server-managed fields that the merge feature treats as workspace-local
+ // (regenerated by the deploy handler): GCP `subscription_id` is rewritten
+ // to `windmill__` in `CreateUpdate` mode, and Azure's
+ // `push_auth_config` carries only the regenerated `secret_hash`. Without
+ // stripping, GCP/Azure push triggers stay flagged as "changed" forever.
+ "subscription_id",
+ "push_auth_config",
+];
+
+async fn compare_two_trigger_or_schedule(
+ db: &DB,
+ table: &str,
+ source_workspace_id: &str,
+ fork_workspace_id: &str,
+ path: &str,
+) -> Result {
+ // Whitelist guard: callers in `compare_workspaces` and `query_visible_items`
+ // already match `table` against a closed set, but a stray future caller
+ // could open an injection hole. Bail loudly in debug, fail safe in release.
+ debug_assert!(
+ TRIGGER_OR_SCHEDULE_TABLES.contains(&table),
+ "compare_two_trigger_or_schedule called with unrecognized table: {table}"
+ );
+ if !TRIGGER_OR_SCHEDULE_TABLES.contains(&table) {
+ return Ok(ItemComparison {
+ has_changes: false,
+ exists_in_source: false,
+ exists_in_fork: false,
+ });
+ }
+
+ let mut select_expr = String::from("to_jsonb(t)");
+ for f in TRIGGER_COMPARE_IGNORE {
+ // The `-` operator on jsonb returns the object without the named key,
+ // or the unchanged object if the key is absent — so one ignore list
+ // works across tables with different column sets.
+ select_expr.push_str(&format!(" - '{f}'"));
+ }
+ // SAFETY: `table` comes from a hardcoded allowlist TRIGGER_OR_SCHEDULE_TABLES
+ // (guarded by the debug_assert + runtime check above), not user input.
+ // `select_expr` is built from `TRIGGER_COMPARE_IGNORE`, also a static const.
+ let sql = format!("SELECT {select_expr} FROM {table} t WHERE workspace_id = $1 AND path = $2");
+
+ let source_fut = sqlx::query_scalar::<_, serde_json::Value>(&sql)
+ .bind(source_workspace_id)
+ .bind(path)
+ .fetch_optional(db);
+ let target_fut = sqlx::query_scalar::<_, serde_json::Value>(&sql)
+ .bind(fork_workspace_id)
+ .bind(path)
+ .fetch_optional(db);
+ let (source, target) = tokio::try_join!(source_fut, target_fut)?;
+
+ let has_changes = match (source.as_ref(), target.as_ref()) {
+ (Some(s), Some(t)) => s != t,
+ (None, None) => false,
+ _ => true,
+ };
+
+ Ok(ItemComparison {
+ has_changes,
+ exists_in_source: source.is_some(),
+ exists_in_fork: target.is_some(),
+ })
+}
+
+const TRIGGER_OR_SCHEDULE_TABLES: &[&str] = &[
+ "schedule",
+ "http_trigger",
+ "websocket_trigger",
+ "kafka_trigger",
+ "nats_trigger",
+ "postgres_trigger",
+ "mqtt_trigger",
+ "sqs_trigger",
+ "gcp_trigger",
+ "azure_trigger",
+ "email_trigger",
+];
+
#[derive(Deserialize)]
struct LogAiChatPayload {
session_id: String,
@@ -7154,3 +7316,78 @@ async fn prune_versions(
Ok(Json(PruneVersionsResponse { pruned }))
}
+
+#[derive(Serialize)]
+struct WsSpecificItem {
+ item_kind: String,
+ path: String,
+}
+
+async fn list_ws_specific(
+ authed: ApiAuthed,
+ Extension(user_db): Extension,
+ Path(w_id): Path,
+) -> JsonResult> {
+ // ws_specific itself has no per-item RLS — only the workspace_id column.
+ // Joining against resource/variable under user_db forces the same
+ // path-based RLS policies that govern those tables (see_own / see_member /
+ // see_extra_perms_* / see_folder_extra_perms_user) to also gate visibility
+ // here. Without these joins, any workspace member could enumerate paths
+ // in folders they lack read access to (e.g. f/finance/prod_db_creds).
+ let mut tx = user_db.begin(&authed).await?;
+ let items = sqlx::query_as!(
+ WsSpecificItem,
+ r#"
+ SELECT s.item_kind, s.path
+ FROM ws_specific s
+ WHERE s.workspace_id = $1
+ AND (
+ (s.item_kind = 'resource' AND EXISTS (
+ SELECT 1 FROM resource r
+ WHERE r.workspace_id = s.workspace_id AND r.path = s.path
+ ))
+ OR (s.item_kind = 'variable' AND EXISTS (
+ SELECT 1 FROM variable v
+ WHERE v.workspace_id = s.workspace_id AND v.path = s.path
+ ))
+ )
+ "#,
+ &w_id
+ )
+ .fetch_all(&mut *tx)
+ .await?;
+ tx.commit().await?;
+ Ok(Json(items))
+}
+
+#[derive(Deserialize)]
+struct ListWsSpecificVersionsQuery {
+ kind: String,
+ path: String,
+}
+
+async fn list_ws_specific_versions(
+ authed: ApiAuthed,
+ Extension(db): Extension,
+ Path(w_id): Path,
+ Query(q): Query,
+) -> JsonResult> {
+ if q.kind != "resource" && q.kind != "variable" {
+ return Err(Error::BadRequest(format!(
+ "Invalid kind '{}'. Must be 'resource' or 'variable'",
+ q.kind
+ )));
+ }
+
+ let versions: Vec = sqlx::query_scalar!(
+ r#"SELECT ws AS "ws!" FROM list_ws_specific_versions($1, $2, $3, $4)"#,
+ &w_id,
+ &authed.email,
+ &q.kind,
+ &q.path,
+ )
+ .fetch_all(&db)
+ .await?;
+
+ Ok(Json(versions))
+}
diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json
index 6c6120f54b..5bd7f5c21e 100644
--- a/backend/windmill-api/openapi-deref.json
+++ b/backend/windmill-api/openapi-deref.json
@@ -33961,7 +33961,7 @@
},
"client_secret": {
"type": "string",
- "description": "Azure AD client secret"
+ "description": "Azure AD client secret. Optional — when omitted, the integration falls back to Azure Workload Identity Federation, exchanging the Kubernetes-projected service-account JWT at AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived secret stored)."
},
"token": {
"type": "string",
diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml
index 7585b06254..367b570b29 100644
--- a/backend/windmill-api/openapi-deref.yaml
+++ b/backend/windmill-api/openapi-deref.yaml
@@ -2977,7 +2977,12 @@ paths:
description: Azure AD application (client) ID
client_secret:
type: string
- description: Azure AD client secret
+ description: >-
+ Azure AD client secret. Optional — when omitted, the
+ integration falls back to Azure Workload Identity Federation,
+ exchanging the Kubernetes-projected service-account JWT at
+ AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived
+ secret stored).
token:
type: string
description: >-
diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml
index d47c0d2500..dd3c480241 100644
--- a/backend/windmill-api/openapi.yaml
+++ b/backend/windmill-api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
- version: 1.694.0
+ version: 1.697.0
title: Windmill API
contact:
@@ -5756,6 +5756,63 @@ paths:
required:
- pruned
+ /w/{workspace}/workspaces/list_ws_specific:
+ get:
+ summary: list all workspace-specific items
+ operationId: listWsSpecific
+ tags:
+ - workspace
+ parameters:
+ - $ref: "#/components/parameters/WorkspaceId"
+ responses:
+ "200":
+ description: list of workspace-specific items
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ item_kind:
+ type: string
+ path:
+ type: string
+ required:
+ - item_kind
+ - path
+
+ /w/{workspace}/workspaces/list_ws_specific_versions:
+ get:
+ summary: list workspace ids that have a version of the given item
+ operationId: listWsSpecificVersions
+ tags:
+ - workspace
+ parameters:
+ - $ref: "#/components/parameters/WorkspaceId"
+ - name: kind
+ in: query
+ required: true
+ schema:
+ type: string
+ enum:
+ - resource
+ - variable
+ - name: path
+ in: query
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: list of workspace ids that have a version of the item
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: string
+
/w/{workspace}/workspaces/public_app_rate_limit:
post:
summary: Set public app rate limit for this workspace
@@ -20929,7 +20986,10 @@ components:
description: Azure AD application (client) ID
client_secret:
type: string
- description: Azure AD client secret
+ description: >-
+ Azure AD client secret. Optional — when omitted, the integration falls back to
+ Azure Workload Identity Federation, exchanging the Kubernetes-projected service-account
+ JWT at AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived secret stored).
token:
type: string
description: Static Bearer token for testing/development (optional, if provided this is used instead of OAuth2 authentication)
@@ -22610,6 +22670,8 @@ components:
type: array
items:
type: string
+ ws_specific:
+ type: boolean
required:
- workspace_id
- path
@@ -22662,6 +22724,8 @@ components:
type: array
items:
type: string
+ ws_specific:
+ type: boolean
required:
- path
- value
@@ -22687,6 +22751,8 @@ components:
type: array
items:
type: string
+ ws_specific:
+ type: boolean
AuditLog:
type: object
@@ -23083,6 +23149,8 @@ components:
type: array
items:
type: string
+ ws_specific:
+ type: boolean
required:
- path
- value
@@ -23105,6 +23173,8 @@ components:
type: array
items:
type: string
+ ws_specific:
+ type: boolean
Resource:
type: object
@@ -23133,6 +23203,8 @@ components:
type: array
items:
type: string
+ ws_specific:
+ type: boolean
required:
- path
- resource_type
@@ -23175,6 +23247,8 @@ components:
type: array
items:
type: string
+ ws_specific:
+ type: boolean
required:
- path
- resource_type
@@ -27401,6 +27475,18 @@ components:
"resource",
"variable",
"resource_type",
+ "folder",
+ "schedule",
+ "http_trigger",
+ "websocket_trigger",
+ "kafka_trigger",
+ "nats_trigger",
+ "postgres_trigger",
+ "mqtt_trigger",
+ "sqs_trigger",
+ "gcp_trigger",
+ "azure_trigger",
+ "email_trigger",
]
description: Type of the item
path:
@@ -27435,6 +27521,8 @@ components:
- variables_changed
- resource_types_changed
- folders_changed
+ - schedules_changed
+ - triggers_changed
- conflicts
properties:
total_diffs:
@@ -27467,6 +27555,12 @@ components:
folders_changed:
type: integer
description: Number of folders with differences
+ schedules_changed:
+ type: integer
+ description: Number of schedules with differences
+ triggers_changed:
+ type: integer
+ description: Number of triggers with differences (sum across all trigger kinds)
conflicts:
type: integer
description: Number of items that are both ahead and behind (conflicts)
diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs
index 9907e35529..efe03f73dd 100644
--- a/backend/windmill-api/src/jobs.rs
+++ b/backend/windmill-api/src/jobs.rs
@@ -343,10 +343,6 @@ pub fn workspaced_service() -> Router {
"/result_by_id/{job_id}/{node_id}",
get(get_result_by_id).layer(cors.clone()),
)
- .route(
- "/flow_env_by_flow_job_id/{flow_job_id}/{var_name}",
- get(get_flow_env_by_flow_job_id).layer(cors.clone()),
- )
.route("/run/dependencies", post(run_dependencies_job))
.route("/run/dependencies_async", post(run_dependencies_job_async))
.route("/run/flow_dependencies", post(run_flow_dependencies_job))
@@ -452,130 +448,6 @@ async fn get_root_job(
Ok(Json(res))
}
-async fn get_flow_env_by_flow_job_id(
- authed: ApiAuthed,
- tokened: Tokened,
- Extension(db): Extension,
- Path((w_id, flow_job_id, var_name)): Path<(String, Uuid, String)>,
- Query(JsonPath { json_path, .. }): Query,
-) -> windmill_common::error::JsonResult> {
- // Fetch raw value (without json_path) to check for $var:/$res: references
- let raw_value = sqlx::query_scalar!(
- r#"
- SELECT
- CASE
- WHEN flow_version.id IS NOT NULL THEN
- flow_version.value -> 'flow_env' -> $3
- ELSE
- root_job.raw_flow -> 'flow_env' -> $3
- END AS "flow_env: sqlx::types::Json>"
- FROM
- v2_job current_job
- JOIN
- v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)
- AND root_job.workspace_id = current_job.workspace_id
- LEFT JOIN
- flow_version ON flow_version.id = root_job.runnable_id
- AND flow_version.path = root_job.runnable_path
- AND flow_version.workspace_id = root_job.workspace_id
- WHERE
- current_job.id = $1 AND
- current_job.workspace_id = $2"#,
- flow_job_id,
- w_id,
- var_name,
- )
- .fetch_optional(&db)
- .await?
- .and_then(|r| r.map(|x| x.0));
-
- // Resolve $var:/$res: references if present
- let resolved = if let Some(raw) = raw_value {
- let raw_str = raw.get();
- let db_authed = windmill_common::db::DbWithOptAuthed::::from_authed(
- &authed,
- db.clone(),
- None,
- );
- if let Some(path) = raw_str
- .strip_prefix("\"$var:")
- .and_then(|s| s.strip_suffix("\""))
- {
- match windmill_store::variables::get_value_internal(&db_authed, &w_id, path, false)
- .await
- {
- Ok(val) => to_raw_value(&serde_json::Value::String(val)),
- Err(e) => {
- tracing::warn!("Failed to resolve flow_env variable $var:{path}: {e}");
- raw
- }
- }
- } else if let Some(path) = raw_str
- .strip_prefix("\"$res:")
- .and_then(|s| s.strip_suffix("\""))
- {
- match windmill_store::resources::get_resource_value_interpolated_internal(
- &db_authed,
- &w_id,
- path,
- Some(flow_job_id),
- Some(&tokened.token),
- false,
- )
- .await
- {
- Ok(Some(val)) => to_raw_value(&val),
- Ok(None) => {
- tracing::warn!(
- "Failed to resolve flow_env resource $res:{path}: resource not found"
- );
- raw
- }
- Err(e) => {
- tracing::warn!("Failed to resolve flow_env resource $res:{path}: {e}");
- raw
- }
- }
- } else {
- raw
- }
- } else {
- to_raw_value(&serde_json::Value::Null)
- };
-
- // Apply json_path navigation on the (possibly resolved) value
- let flow_env = if let Some(ref jp) = json_path {
- let mut value: serde_json::Value =
- serde_json::from_str(resolved.get()).unwrap_or(serde_json::Value::Null);
- for part in jp.split('.') {
- value = match value {
- serde_json::Value::Object(ref mut map) => {
- map.remove(part).unwrap_or(serde_json::Value::Null)
- }
- serde_json::Value::Array(ref arr) => part
- .parse::()
- .ok()
- .and_then(|i| arr.get(i).cloned())
- .unwrap_or(serde_json::Value::Null),
- _ => serde_json::Value::Null,
- };
- }
- to_raw_value(&value)
- } else {
- resolved
- };
-
- log_job_view(
- &db,
- Some(&authed),
- Some(&tokened.token),
- &w_id,
- &flow_job_id,
- )
- .await?;
- Ok(Json(flow_env))
-}
-
async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result {
let root_job = sqlx::query_scalar!(
r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
@@ -897,29 +769,75 @@ async fn list_selected_job_groups(
) -> error::Result {
let mut tx = user_db.begin(&authed).await?;
+ // Single-step-flows wrap either a script or a flow. The wrapped runnable type
+ // sits in raw_flow.modules under id='a' (id='main' is also tolerated), and the
+ // wrapped script's hash (when pinned) sits in the same module's value.hash.
+ // We project singlestepflow rows onto the script/flow grouping the rest of the
+ // query uses, and use the wrapped hash (when present) so the per-version
+ // schemas subquery resolves real schemas instead of returning nulls. A
+ // path-based schema fallback covers flow-wrapped singlestepflow (no version
+ // pinning) and any singlestepflow whose pinned hash has since been deleted.
let results = sqlx::query_scalar!(
- r#"SELECT jsonb_build_object(
- 'kind', jb.kind,
- 'script_path', jb.runnable_path,
+ r#"WITH normalized AS (
+ SELECT
+ jb.id,
+ jb.workspace_id,
+ jb.runnable_path,
+ CASE
+ WHEN jb.kind IN ('flow', 'script') THEN jb.kind::text
+ WHEN jb.kind = 'singlestepflow' THEN
+ COALESCE(
+ (SELECT m->'value'->>'type'
+ FROM jsonb_array_elements(jb.raw_flow->'modules') m
+ WHERE m->>'id' IN ('a', 'main')
+ LIMIT 1),
+ 'script'
+ )
+ ELSE NULL
+ END AS norm_kind,
+ COALESCE(
+ jb.runnable_id,
+ CASE WHEN jb.kind = 'singlestepflow' THEN
+ (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint
+ FROM jsonb_array_elements(jb.raw_flow->'modules') m
+ WHERE m->>'id' IN ('a', 'main')
+ AND m->'value'->>'hash' IS NOT NULL
+ LIMIT 1)
+ END
+ ) AS effective_hash
+ FROM v2_job jb
+ WHERE jb.kind IN ('flow', 'script', 'singlestepflow')
+ AND jb.workspace_id = $1 AND jb.id = ANY($2)
+ )
+ SELECT jsonb_build_object(
+ 'kind', n.norm_kind,
+ 'script_path', n.runnable_path,
'latest_schema', COALESCE(
- (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.workspace_id = $1 AND s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),
- (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = jb.runnable_path AND jb.kind = 'flow')
+ (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.workspace_id = $1 AND s.path = n.runnable_path AND n.norm_kind = 'script' ORDER BY s.path, s.created_at DESC),
+ (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = n.runnable_path AND n.norm_kind = 'flow')
),
'schemas', ARRAY(
SELECT jsonb_build_object(
- 'script_hash', LPAD(TO_HEX(COALESCE(s.hash, f.id)), 16, '0'),
- 'job_ids', ARRAY_AGG(DISTINCT j.id),
- 'schema', (ARRAY_AGG(COALESCE(s.schema, f.schema)))[1]
- ) FROM v2_job j
- LEFT JOIN script s ON s.hash = j.runnable_id AND j.kind = 'script'
- LEFT JOIN flow_version f ON f.id = j.runnable_id AND j.kind = 'flow'
- WHERE j.id = ANY(ARRAY_AGG(jb.id))
+ 'script_hash', CASE WHEN COALESCE(s.hash, f.id) IS NULL THEN NULL ELSE LPAD(TO_HEX(COALESCE(s.hash, f.id)), 16, '0') END,
+ 'job_ids', ARRAY_AGG(DISTINCT n2.id),
+ 'schema', COALESCE(
+ (ARRAY_AGG(COALESCE(s.schema, f.schema)))[1],
+ CASE WHEN n.norm_kind = 'script' THEN
+ (SELECT DISTINCT ON (s2.path) s2.schema FROM script s2 WHERE s2.workspace_id = $1 AND s2.path = n.runnable_path ORDER BY s2.path, s2.created_at DESC)
+ END,
+ CASE WHEN n.norm_kind = 'flow' THEN
+ (SELECT fv.schema FROM flow LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.path = n.runnable_path)
+ END
+ )
+ ) FROM normalized n2
+ LEFT JOIN script s ON s.hash = n2.effective_hash AND n2.norm_kind = 'script'
+ LEFT JOIN flow_version f ON f.id = n2.effective_hash AND n2.norm_kind = 'flow'
+ WHERE n2.id = ANY(ARRAY_AGG(n.id))
GROUP BY COALESCE(s.hash, f.id)
)
- ) FROM v2_job jb
- WHERE (jb.kind = 'flow' OR jb.kind = 'script')
- AND jb.workspace_id = $1 AND jb.id = ANY($2)
- GROUP BY jb.kind, jb.runnable_path"#,
+ ) FROM normalized n
+ WHERE n.norm_kind IS NOT NULL
+ GROUP BY n.norm_kind, n.runnable_path"#,
&w_id,
&uuids
)
@@ -3234,11 +3152,11 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
q.suspend AS "suspend!",
j.runnable_path AS script_path,
j.permissioned_as_email AS email,
- (ji.kind IN ('flow', 'flowpreview')) AS "is_flow_level!",
- (ji.kind NOT IN ('flow', 'flowpreview') AND q.id = ji.id) AS "is_wac!"
+ (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS "is_flow_level!",
+ (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS "is_wac!"
FROM job_info ji
JOIN v2_job_queue q ON q.id = CASE
- WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id
+ WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id
ELSE COALESCE(ji.parent_job, ji.id)
END
JOIN v2_job j ON j.id = q.id
@@ -3536,7 +3454,7 @@ pub async fn set_flow_user_state(
r#"
UPDATE v2_job_status f SET flow_status = JSONB_SET(flow_status, ARRAY['user_states'], JSONB_SET(COALESCE(flow_status->'user_states', '{}'::jsonb), ARRAY[$1], $2))
FROM v2_job j
- WHERE f.id = $3 AND f.id = j.id AND j.workspace_id = $4 AND kind IN ('flow', 'flowpreview', 'flownode') RETURNING 1
+ WHERE f.id = $3 AND f.id = j.id AND j.workspace_id = $4 AND kind IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') RETURNING 1
"#,
key,
value,
@@ -3827,23 +3745,74 @@ fn batch_rerun_jobs_inner(
tokio::spawn(async move {
let mut job_stream = sqlx::query_as!(
BatchReRunQueryReturnType,
- r#"SELECT
- j.id,
- j.kind AS "kind: _",
- COALESCE(s.path, f.path) AS "script_path!",
- COALESCE(s.hash, f.id) AS "script_hash!: _",
+ r#"WITH norm AS (
+ SELECT
+ j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args,
+ -- Project effective kind for dispatch: pass script/flow through;
+ -- for singlestepflow, read the wrapped runnable's type from
+ -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').
+ CASE
+ WHEN j.kind IN ('script', 'flow') THEN j.kind::text
+ WHEN j.kind = 'singlestepflow' THEN
+ COALESCE(
+ (SELECT m->'value'->>'type'
+ FROM jsonb_array_elements(j.raw_flow->'modules') m
+ WHERE m->>'id' = 'a'
+ LIMIT 1),
+ 'script'
+ )
+ END AS norm_kind,
+ -- Pinned script hash for script-wrapped singlestepflow lives in
+ -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a
+ -- version, so this is NULL there (Flow rerun pushes by path).
+ (CASE WHEN j.kind = 'singlestepflow' THEN
+ (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint
+ FROM jsonb_array_elements(j.raw_flow->'modules') m
+ WHERE m->>'id' = 'a'
+ AND m->'value'->>'hash' IS NOT NULL
+ LIMIT 1)
+ END) AS ssf_hash
+ FROM v2_job j
+ WHERE j.id = ANY($1)
+ AND j.workspace_id = $2
+ AND j.kind IN ('script', 'flow', 'singlestepflow')
+ )
+ SELECT
+ n.id,
+ n.norm_kind::JOB_KIND AS "kind!: _",
+ COALESCE(s.path, f.path, n.runnable_path) AS "script_path!",
+ -- script_hash is unused on the Flow rerun path (path-based push), so
+ -- 0 is a safe placeholder when no version is pinned.
+ COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS "script_hash!: _",
COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS "scheduled_for!: _",
- args AS input,
- COALESCE(s.schema, f.schema) AS "schema: _"
- FROM v2_job j
- LEFT JOIN script s ON j.runnable_id = s.hash AND j.kind = 'script'
- LEFT JOIN flow_version f ON j.runnable_id = f.id AND j.runnable_path = f.path AND j.kind = 'flow'
- LEFT JOIN v2_job_completed jc ON jc.id = j.id
- LEFT JOIN v2_job_queue jq ON jq.id = j.id
- WHERE j.id = ANY($1)
- AND j.workspace_id = $2
- AND COALESCE(s.hash, f.id) IS NOT NULL
- AND COALESCE(s.path, f.path) IS NOT NULL"#,
+ n.args AS input,
+ -- Pinned schema for script/flow; latest-by-path fallback for
+ -- singlestepflow so input_transforms still resolve at rerun time.
+ COALESCE(
+ s.schema,
+ f.schema,
+ (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN
+ (SELECT s2.schema FROM script s2
+ WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path
+ ORDER BY s2.created_at DESC LIMIT 1)
+ END),
+ (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN
+ (SELECT fv.schema FROM flow
+ LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]
+ WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)
+ END)
+ ) AS "schema: _"
+ FROM norm n
+ LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'
+ LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'
+ LEFT JOIN v2_job_completed jc ON jc.id = n.id
+ LEFT JOIN v2_job_queue jq ON jq.id = n.id
+ WHERE n.norm_kind IS NOT NULL
+ AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL
+ AND (
+ n.kind = 'singlestepflow'
+ OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)
+ )"#,
&body.job_ids,
w_id
).fetch(&db);
@@ -3890,13 +3859,27 @@ async fn batch_rerun_handle_job(
let latest_schema;
let schema = if use_latest_version {
+ // Project singlestepflow's wrapped runnable type so the path-based schema
+ // lookup resolves it to the underlying script/flow's latest schema —
+ // without this, transforms silently no-op for singlestepflow reruns.
latest_schema = sqlx::query_scalar!(
r#"SELECT COALESCE(
- (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = jb.runnable_path AND jb.kind = 'script' ORDER BY s.path, s.created_at DESC),
- (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = jb.runnable_path AND jb.kind = 'flow')
- ) FROM v2_job jb
- WHERE jb.id = $1 AND jb.workspace_id = $2
- GROUP BY jb.kind, jb.runnable_path"#,
+ (SELECT DISTINCT ON (s.path) s.schema FROM script s WHERE s.path = norm.path AND s.workspace_id = $2 AND norm.kind = 'script' ORDER BY s.path, s.created_at DESC),
+ (SELECT flow_version.schema FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = norm.path AND flow.workspace_id = $2 AND norm.kind = 'flow')
+ ) FROM (
+ SELECT
+ jb.runnable_path AS path,
+ CASE
+ WHEN jb.kind IN ('script', 'flow') THEN jb.kind::text
+ WHEN jb.kind = 'singlestepflow' THEN COALESCE(
+ (SELECT m->'value'->>'type' FROM jsonb_array_elements(jb.raw_flow->'modules') m WHERE m->>'id' IN ('a', 'main') LIMIT 1),
+ 'script'
+ )
+ END AS kind
+ FROM v2_job jb
+ WHERE jb.id = $1 AND jb.workspace_id = $2
+ ) norm
+ GROUP BY norm.kind, norm.path"#,
&job.id,
&w_id
).fetch_optional(db).await?.flatten();
diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs
index a5d15165a2..c929a85a84 100644
--- a/backend/windmill-api/src/offboarding.rs
+++ b/backend/windmill-api/src/offboarding.rs
@@ -194,6 +194,7 @@ async fn get_offboard_preview(
];
let mut triggers = HashMap::new();
for table in &trigger_tables {
+ // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input.
let paths: Vec = sqlx::query_scalar(&format!(
"SELECT path FROM {table} WHERE path LIKE $1 AND workspace_id = $2"
))
@@ -246,6 +247,7 @@ async fn get_offboard_preview(
).fetch_all(db).await?;
let mut obo_triggers = HashMap::new();
+ // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input.
for table in &trigger_tables {
let paths: Vec = sqlx::query_scalar(&format!(
"SELECT path FROM {table} WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3"
@@ -759,6 +761,7 @@ async fn check_path_conflicts(
"flow" => " AND NOT t1.archived",
_ => "",
};
+ // SAFETY: `table_name` comes from a hardcoded allowlist `tables`, not user input.
let rows: Vec = sqlx::query_scalar(&format!(
"SELECT REGEXP_REPLACE(t1.path, '^u/' || $1 || '/', $3) \
FROM {table} t1 \
@@ -1027,6 +1030,7 @@ async fn offboard_user_from_workspace<'c>(
];
let mut triggers_reassigned: i64 = 0;
+ // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input.
for table in &trigger_tables {
let count: i64 = sqlx::query_scalar(&format!(
"WITH updated AS ( \
diff --git a/backend/windmill-api/src/trash.rs b/backend/windmill-api/src/trash.rs
index 83e607a7dc..d0d7ddc6c2 100644
--- a/backend/windmill-api/src/trash.rs
+++ b/backend/windmill-api/src/trash.rs
@@ -480,6 +480,7 @@ async fn restore_trigger(tx: &mut sqlx::PgConnection, item: &TrashItemWithData)
)));
}
+ // SAFETY: `table_name` has been validated against the `valid_tables` allowlist above.
let exists: bool = sqlx::query_scalar(&format!(
"SELECT EXISTS(SELECT 1 FROM {} WHERE path = $1 AND workspace_id = $2)",
table_name
@@ -500,6 +501,7 @@ async fn restore_trigger(tx: &mut sqlx::PgConnection, item: &TrashItemWithData)
.get("row")
.ok_or_else(|| Error::internal_err("Invalid trash data for trigger"))?;
+ // SAFETY: `table_name` has been validated against the `valid_tables` allowlist above.
sqlx::query(&format!(
"INSERT INTO {} SELECT * FROM jsonb_populate_record(null::{}, $1)",
table_name, table_name
diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs
index 91b9ab972e..e6b3b9ee0f 100644
--- a/backend/windmill-api/src/workspaces_export.rs
+++ b/backend/windmill-api/src/workspaces_export.rs
@@ -490,7 +490,7 @@ pub(crate) async fn tarball_workspace(
Some(t) => Err(Error::BadRequest(format!("Invalid Archive Type {t}"))),
}?;
{
- let folders = sqlx::query_as::<_, Folder>("SELECT * 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 FROM folder WHERE workspace_id = $1")
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
@@ -507,10 +507,13 @@ pub(crate) async fn tarball_workspace(
{
let scripts = sqlx::query_as::<_, Script>(
- "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)",
+ &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,
+ ),
)
.bind(&w_id)
.fetch_all(&mut *tx)
@@ -595,7 +598,7 @@ pub(crate) async fn tarball_workspace(
if !skip_resources.unwrap_or(false) {
let resources = sqlx::query_as!(
Resource,
- "SELECT * FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'",
+ "SELECT workspace_id, path, value, description, resource_type, extra_perms, created_by, edited_at, labels FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'",
&w_id
)
.fetch_all(&mut *tx)
@@ -612,7 +615,7 @@ pub(crate) async fn tarball_workspace(
if !skip_resource_types.unwrap_or(false) {
let resource_types = sqlx::query_as!(
ResourceType,
- "SELECT * FROM resource_type WHERE workspace_id = $1",
+ "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
@@ -651,9 +654,9 @@ pub(crate) async fn tarball_workspace(
if !skip_variables.unwrap_or(false) {
let variables =
sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) {
- "SELECT * FROM variable WHERE workspace_id = $1 AND expires_at IS NULL"
+ "SELECT workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, labels FROM variable WHERE workspace_id = $1 AND expires_at IS NULL"
} else {
- "SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL"
+ "SELECT workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, labels FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL"
})
.bind(&w_id)
.fetch_all(&mut *tx)
@@ -727,7 +730,7 @@ pub(crate) async fn tarball_workspace(
if include_schedules.unwrap_or(false) {
let schedules = sqlx::query_as::<_, Schedule>(
- "SELECT * FROM schedule
+ "SELECT workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, args, extra_perms, email, permissioned_as, error, on_failure, on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery, on_recovery_times, on_recovery_extra_args, on_success, on_success_extra_args, ws_error_handler_muted, retry, no_flow_overlap, summary, description, tag, paused_until, cron_version, dynamic_skip, labels FROM schedule
WHERE workspace_id = $1",
)
.bind(&w_id)
@@ -998,7 +1001,7 @@ pub(crate) async fn tarball_workspace(
if include_users.unwrap_or(false) {
let users = sqlx::query!(
- "SELECT * FROM usr
+ "SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via FROM usr
WHERE workspace_id = $1",
&w_id
)
diff --git a/backend/windmill-common/src/client.rs b/backend/windmill-common/src/client.rs
index 730136ac1c..68670cea31 100644
--- a/backend/windmill-common/src/client.rs
+++ b/backend/windmill-common/src/client.rs
@@ -134,26 +134,6 @@ impl AuthedClient {
.await
}
- pub async fn get_flow_env_by_flow_job_id(
- &self,
- root_job_id: &str,
- var_name: &str,
- json_path: Option,
- ) -> anyhow::Result {
- let url = format!(
- "{}/api/w/{}/jobs/flow_env_by_flow_job_id/{}/{}",
- self.base_internal_url, self.workspace, root_job_id, var_name
- );
- let query = query_from_json_path(json_path);
- make_basic_get_request(
- self,
- &url,
- Some(query),
- Some("decoding flow env variable as json"),
- )
- .await
- }
-
pub async fn get_result_by_id(
&self,
flow_job_id: &str,
diff --git a/backend/windmill-common/src/db.rs b/backend/windmill-common/src/db.rs
index e122c8453e..0048abf438 100644
--- a/backend/windmill-common/src/db.rs
+++ b/backend/windmill-common/src/db.rs
@@ -233,6 +233,7 @@ impl UserDB {
let mut tx = self.db.begin().await?;
if let Some(schema) = PG_SCHEMA.as_ref() {
+ // SAFETY: `schema` is an operator-controlled environment variable (PG_SCHEMA), set at deploy time and never user-supplied.
sqlx::query(&format!("SET LOCAL search_path TO {}", schema))
.execute(&mut *tx)
.await?;
diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs
index ef0b2dc5bf..7c7e3f79ed 100644
--- a/backend/windmill-common/src/lib.rs
+++ b/backend/windmill-common/src/lib.rs
@@ -778,6 +778,7 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
if db_exists {
// Terminate active connections
+ // SAFETY: `dbname` has been validated via validate_dbname() before reaching this point.
if let Err(e) = sqlx::query(&format!(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()",
dbname.replace('\'', "''")
@@ -789,6 +790,7 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
}
// Drop the database
+ // SAFETY: `dbname` has been validated via validate_dbname() before reaching this point.
sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", dbname))
.execute(db)
.await
@@ -837,6 +839,7 @@ pub async fn create_custom_instance_database(
)));
}
+ // SAFETY: `dbname` has been validated via validate_dbname() before reaching this point.
sqlx::query(&format!("CREATE DATABASE \"{}\"", dbname))
.execute(db)
.await
diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs
index e51d24fa44..f077687d6f 100644
--- a/backend/windmill-common/src/query_builders.rs
+++ b/backend/windmill-common/src/query_builders.rs
@@ -2430,18 +2430,30 @@ WHERE table_schema = current_schema()",
Ok(q)
}
DbType::Mysql => {
- let db_name = database_name.unwrap_or("");
+ let explicit_db = database_name.filter(|s| !s.is_empty());
let table_filter = if let Some(t) = table {
let parts: Vec<&str> = t.split('.').collect();
let tname = parts[parts.len() - 1];
- let schema = if parts.len() > 1 { parts[0] } else { db_name };
+ let schema_sql = if parts.len() > 1 {
+ format!("'{}'", escape_sql_literal(parts[0]))
+ } else {
+ explicit_db
+ .map(|dn| format!("'{}'", escape_sql_literal(dn)))
+ .unwrap_or_else(|| "DATABASE()".to_string())
+ };
format!(
- "\nWHERE\n TABLE_NAME = '{}' AND TABLE_SCHEMA = '{}'",
+ "\nWHERE\n TABLE_NAME = '{}' AND TABLE_SCHEMA = {}",
escape_sql_literal(tname),
- escape_sql_literal(schema)
+ schema_sql
)
} else {
- "\nWHERE\n TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys', '_vt')".to_string()
+ let schema_predicate = explicit_db
+ .map(|dn| format!("TABLE_SCHEMA = '{}'", escape_sql_literal(dn)))
+ .unwrap_or_else(|| "TABLE_SCHEMA = DATABASE()".to_string());
+ format!(
+ "\nWHERE\n {}\n AND TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys', '_vt')",
+ schema_predicate
+ )
};
let extra_col = if table.is_none() {
",\n TABLE_NAME as table_name"
@@ -4250,10 +4262,28 @@ mod tests {
}
#[test]
- fn test_expand_load_table_metadata_mysql_all_tables() {
+ fn test_expand_load_table_metadata_mysql_single_table_uses_session_db() {
+ let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users"}"#;
+ let sql = expand_code(marker, &ScriptLang::Mysql);
+ assert!(sql.contains("TABLE_NAME = 'users'"));
+ assert!(sql.contains("TABLE_SCHEMA = DATABASE()"));
+ }
+
+ #[test]
+ fn test_expand_load_table_metadata_mysql_all_tables_uses_session_db() {
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {}"#;
let sql = expand_code(marker, &ScriptLang::Mysql);
assert!(sql.contains("TABLE_NAME as table_name"));
+ assert!(sql.contains("TABLE_SCHEMA = DATABASE()"));
+ assert!(sql.contains("TABLE_SCHEMA NOT IN"));
+ }
+
+ #[test]
+ fn test_expand_load_table_metadata_mysql_all_tables_with_database_name() {
+ let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"databaseName":"mydb"}"#;
+ let sql = expand_code(marker, &ScriptLang::Mysql);
+ assert!(sql.contains("TABLE_NAME as table_name"));
+ assert!(sql.contains("TABLE_SCHEMA = 'mydb'"));
assert!(sql.contains("TABLE_SCHEMA NOT IN"));
}
diff --git a/backend/windmill-common/src/runnable_settings/mod.rs b/backend/windmill-common/src/runnable_settings/mod.rs
index 09bca46bbc..c5692d40bd 100644
--- a/backend/windmill-common/src/runnable_settings/mod.rs
+++ b/backend/windmill-common/src/runnable_settings/mod.rs
@@ -62,6 +62,7 @@ pub trait RunnableSettingsTrait:
async move {
let v = RUNNABLE_INDIVIDUAL_SETTINGS
.get_or_insert_async(hash, async {
+ // SAFETY: INCLUDE_FIELDS and SETTINGS_NAME are compile-time constants, not user input.
let r = sqlx::query_as::(&format!(
"SELECT {} FROM {} WHERE hash = $1",
Self::INCLUDE_FIELDS.iter().join(","),
@@ -138,7 +139,11 @@ mod private_mod {
pub type Q<'a> = Query<'a, Postgres, ::Arguments<'a>>;
pub trait RunnableSettingsTraitInternal {
+ /// Table name used in dynamic SQL via `format!()`. This is a compile-time
+ /// constant set by each settings impl — it is never user-controllable.
const SETTINGS_NAME: &'static str;
+ /// Column list used in dynamic SQL SELECT via `format!()`. This is a
+ /// compile-time constant — never user-controllable.
const INCLUDE_FIELDS: &'static [&'static str];
fn bind_arguments<'a>(&'a self, q: Q<'a>) -> Q<'a>;
diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs
index 192085d902..dd4c0bb42e 100644
--- a/backend/windmill-common/src/scripts.rs
+++ b/backend/windmill-common/src/scripts.rs
@@ -312,52 +312,10 @@ pub async fn fetch_script_for_update<'a>(
e: impl sqlx::Executor<'a, Database = sqlx::Postgres>,
) -> crate::error::Result>> {
sqlx::query_as::<_, Script>(
- "SELECT
- 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,
- concurrency_key,
- concurrent_limit,
- concurrency_time_window_s,
- debounce_key,
- debounce_delay_s,
- dedicated_worker,
- runnable_settings_handle,
- 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,
- assets,
- modules,
- labels
- FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
+ &format!(
+ "SELECT {} FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
+ SCRIPT_COLUMNS,
+ ),
)
.bind(path)
.bind(w_id)
diff --git a/backend/windmill-common/src/secret_backend/mod.rs b/backend/windmill-common/src/secret_backend/mod.rs
index 4c9389cac9..a75f51ea70 100644
--- a/backend/windmill-common/src/secret_backend/mod.rs
+++ b/backend/windmill-common/src/secret_backend/mod.rs
@@ -143,7 +143,10 @@ pub struct AzureKeyVaultSettings {
pub tenant_id: String,
/// Azure AD application (client) ID
pub client_id: String,
- /// Azure AD client secret
+ /// Azure AD client secret. Optional — when omitted, the integration falls back to
+ /// Azure Workload Identity Federation: the Kubernetes-projected service-account JWT at
+ /// `AZURE_FEDERATED_TOKEN_FILE` is exchanged with Entra ID for an access token (no
+ /// long-lived secret stored on the Windmill instance).
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option,
/// Static Bearer token for testing/development (optional)
diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs
index 3dbdadd924..57e41bb487 100644
--- a/backend/windmill-common/src/variables.rs
+++ b/backend/windmill-common/src/variables.rs
@@ -48,6 +48,8 @@ pub struct ListableVariable {
pub expires_at: Option>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub ws_specific: Option,
}
#[derive(Serialize, Deserialize, sqlx::FromRow)]
@@ -83,6 +85,8 @@ pub struct CreateVariable {
pub expires_at: Option>,
#[serde(default)]
pub labels: Option>,
+ #[serde(default)]
+ pub ws_specific: Option,
}
pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result {
diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs
index 77e69a3340..989ae9ef34 100644
--- a/backend/windmill-common/src/workspaces.rs
+++ b/backend/windmill-common/src/workspaces.rs
@@ -163,6 +163,72 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28217/sync-script-to-git-repo
/// fork of another workspace.
pub const WM_FORK_PREFIX: &str = "wm-fork-";
+/// Validate that a fork workspace id is safe to interpolate into a git branch name.
+///
+/// The id is appended verbatim to a branch like `wm-fork//`,
+/// so it must satisfy `git check-ref-format` rules. We validate synchronously at the API
+/// layer because the actual branch creation runs in a deferred git-sync worker job — without
+/// this check, the API returns 200 and the failure only surfaces later in the worker.
+pub fn validate_fork_workspace_id(id: &str) -> error::Result<()> {
+ if !id.starts_with(WM_FORK_PREFIX) {
+ return Err(Error::BadRequest(format!(
+ "The id `{}` is invalid for a forked workspace. It should be prefixed by {}",
+ id, WM_FORK_PREFIX
+ )));
+ }
+
+ if id.len() > 50 {
+ return Err(Error::BadRequest(format!(
+ "Fork workspace id `{}` is too long ({} chars). Maximum length is 50 characters (including the '{}' prefix).",
+ id, id.len(), WM_FORK_PREFIX
+ )));
+ }
+
+ let reject = |reason: &str| {
+ Err::<(), _>(Error::BadRequest(format!(
+ "Fork workspace id `{}` is invalid: {} (must be a valid git branch name component)",
+ id, reason
+ )))
+ };
+
+ if id.ends_with('.') {
+ return reject("cannot end with '.'");
+ }
+ if id.ends_with(".lock") {
+ return reject("cannot end with '.lock'");
+ }
+ if id.contains("..") {
+ return reject("cannot contain '..'");
+ }
+ if id.contains("@{") {
+ return reject("cannot contain '@{'");
+ }
+ if id.contains("//") {
+ return reject("cannot contain '//'");
+ }
+ for ch in id.chars() {
+ match ch {
+ ':' | '~' | '^' | '?' | '*' | '[' | '\\' | ' ' => {
+ return reject(&format!("contains forbidden character '{}'", ch));
+ }
+ c if c.is_ascii_control() || c == '\u{7f}' => {
+ return reject("contains a control character");
+ }
+ _ => {}
+ }
+ }
+ // Each slash-separated component cannot start with '.' or end with '.lock'.
+ for component in id.split('/') {
+ if component.starts_with('.') {
+ return reject("a path component cannot start with '.'");
+ }
+ if component.ends_with(".lock") {
+ return reject("a path component cannot end with '.lock'");
+ }
+ }
+ Ok(())
+}
+
#[derive(Serialize, Deserialize, Debug)]
pub struct GitRepositorySettings {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -666,3 +732,59 @@ async fn transform_json_unchecked(
Ok(value)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_validate_fork_workspace_id_accepts_valid() {
+ validate_fork_workspace_id("wm-fork-test-allow").unwrap();
+ validate_fork_workspace_id("wm-fork-my_workspace.42").unwrap();
+ validate_fork_workspace_id("wm-fork-a").unwrap();
+ }
+
+ #[test]
+ fn test_validate_fork_workspace_id_rejects_missing_prefix() {
+ assert!(validate_fork_workspace_id("not-a-fork").is_err());
+ assert!(validate_fork_workspace_id("wm-fork").is_err());
+ }
+
+ #[test]
+ fn test_validate_fork_workspace_id_rejects_git_unsafe_chars() {
+ for bad in [
+ "wm-fork-test:allow",
+ "wm-fork-test allow",
+ "wm-fork-test~allow",
+ "wm-fork-test^allow",
+ "wm-fork-test?allow",
+ "wm-fork-test*allow",
+ "wm-fork-test[allow",
+ "wm-fork-test\\allow",
+ "wm-fork-test\nallow",
+ ] {
+ assert!(
+ validate_fork_workspace_id(bad).is_err(),
+ "expected `{}` to be rejected",
+ bad
+ );
+ }
+ }
+
+ #[test]
+ fn test_validate_fork_workspace_id_rejects_git_unsafe_sequences() {
+ assert!(validate_fork_workspace_id("wm-fork-foo..bar").is_err());
+ assert!(validate_fork_workspace_id("wm-fork-foo@{bar").is_err());
+ assert!(validate_fork_workspace_id("wm-fork-foo//bar").is_err());
+ assert!(validate_fork_workspace_id("wm-fork-foo.").is_err());
+ assert!(validate_fork_workspace_id("wm-fork-foo.lock").is_err());
+ assert!(validate_fork_workspace_id("wm-fork-foo/.bar").is_err());
+ assert!(validate_fork_workspace_id("wm-fork-foo/bar.lock").is_err());
+ }
+
+ #[test]
+ fn test_validate_fork_workspace_id_rejects_too_long() {
+ let long_id = format!("wm-fork-{}", "a".repeat(43));
+ assert!(validate_fork_workspace_id(&long_id).is_err());
+ }
+}
diff --git a/backend/windmill-jseval/src/lib.rs b/backend/windmill-jseval/src/lib.rs
index 7172e8cfc8..f522f95263 100644
--- a/backend/windmill-jseval/src/lib.rs
+++ b/backend/windmill-jseval/src/lib.rs
@@ -56,12 +56,21 @@ const END_BRACKET_PATTERN: &str = "\"]";
// ── Regex statics ─────────────────────────────────────────────────────
lazy_static! {
+ // `results` is fetched lazily via the `__getResult` async proxy, so we
+ // wrap each `results.X` access with `(await ...)` to drive the proxy.
+ // `flow_env` used to be wrapped here too (it was an async Deno op-backed
+ // proxy in the deno_core era); QuickJS now exposes flow_env as a plain
+ // in-memory object, so no await is needed.
static ref RE: Regex = Regex::new(
- r#"(?m)(?P