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 3f97552466..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. --- 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/svelte-frontend/SKILL.md b/.agents/skills/svelte-frontend/SKILL.md index 57cac70302..b0c4b39939 100644 --- a/.agents/skills/svelte-frontend/SKILL.md +++ b/.agents/skills/svelte-frontend/SKILL.md @@ -78,3 +78,7 @@ Use the Svelte MCP tools when working on Svelte code: 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 + +## Verifying in the Browser + +After changing Svelte code, use the **Playwright MCP** (`mcp__playwright__*`) to drive the running frontend and confirm the change works. See AGENTS.md → "Verifying Frontend Changes" for the full flow. Use `playwright` (headless) on devboxes; `playwright-headed` when a display is available. diff --git a/.claude/hooks/guard-main-branch.sh b/.claude/hooks/guard-main-branch.sh index c7eeea9475..7a3a8189a0 100755 --- a/.claude/hooks/guard-main-branch.sh +++ b/.claude/hooks/guard-main-branch.sh @@ -16,6 +16,23 @@ command="$(echo "$input" | jq -r '.tool_input.command // empty')" if [[ "$command" =~ ^git\ (push|reset|revert|checkout|merge|rebase|commit|add) ]]; then branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" if [[ "$branch" == "main" ]]; then - echo "BLOCK: You are on the main branch. Create or switch to a feature branch first." + echo "BLOCK: You are on the main branch. Create or switch to a feature branch first." >&2 + exit 2 + fi +fi + +# Block force-push targeting main from any branch. +if [[ "$command" =~ ^git[[:space:]]+push([[:space:]]|$) ]]; then + has_force=false + if [[ "$command" =~ (--force([[:space:]]|=|$)|--force-with-lease|[[:space:]]-f([[:space:]]|$)) ]]; then + has_force=true + fi + # `+ref` refspec syntax is also a force push. + if [[ "$command" =~ [[:space:]]\+[A-Za-z] ]]; then + has_force=true + fi + if $has_force && [[ "$command" =~ (^|[[:space:]:])\+?main([[:space:]]|$) ]]; then + echo "BLOCK: Force-push to main is not allowed via Claude. Run it yourself if you really mean to." >&2 + exit 2 fi fi 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/settings.json b/.claude/settings.json index 0596b17e91..ca8d9a898d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -44,7 +44,25 @@ "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git add:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "Read(/tmp/**)", + "Write(/tmp/**)", + "Edit(/tmp/**)", + "Bash(rm:/tmp/*)", + "Bash(rm:/tmp/**)", + "Bash(rmdir:/tmp/*)", + "Bash(mkdir:/tmp/*)", + "Bash(mkdir:/tmp/**)", + "Bash(cp:/tmp/*)", + "Bash(cp:/tmp/**)", + "Bash(mv:/tmp/*)", + "Bash(mv:/tmp/**)", + "Bash(touch:/tmp/*)", + "Bash(touch:/tmp/**)", + "Bash(chmod:/tmp/*)", + "Bash(chmod:/tmp/**)", + "Bash(tar * /tmp/*)", + "Bash(unzip * /tmp/*)" ], "deny": [ "Read(.env)", @@ -55,7 +73,10 @@ "Read(**/*.pem)", "Read(**/*.key)", "Read(**/credentials.json)", - "Read(**/*secret*)", + "Read(**/.secret*)", + "Read(**/.secrets*)", + "Read(**/*.secret)", + "Read(**/*.secrets)", "Edit(.env)", "Edit(.env.*)", "Edit(**/.env)", @@ -69,7 +90,13 @@ "Bash(chown:*)", "Bash(truncate:*)", "Bash(shred:*)", - "Bash(unlink:*)" + "Bash(unlink:*)", + "mcp__claude_ai_Stripe", + "mcp__claude_ai_Gmail", + "mcp__claude_ai_Google_Calendar", + "mcp__claude_ai_Google_Drive", + "mcp__claude_ai_Slack", + "mcp__claude_ai_Linear" ] }, "enableAllProjectMcpServers": true, 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 — ` - + {#if shouldDownloadViaClient()} + + {:else} + + {/if} {/if} import { FolderService } from '$lib/gen' import { workspaceStore, userStore } from '$lib/stores' - import { Pen, PlusIcon } from 'lucide-svelte' + import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte' import { Button, Drawer, DrawerContent } from './common' import FolderEditor from './FolderEditor.svelte' import Select from './select/Select.svelte' @@ -32,6 +32,7 @@ disableEditing?: boolean size?: 'sm' | 'md' drawerOffset?: number + selectInputClass?: string } let { @@ -40,11 +41,10 @@ disabled = $bindable(undefined), disableEditing = $bindable(undefined), size = 'md', - drawerOffset = 0 + drawerOffset = 0, + selectInputClass }: Props = $props() - let hovering = $state(false) - async function loadFolders(): Promise { loadingFolders = true try { @@ -198,13 +198,12 @@
(hovering = true)} - onmouseleave={() => (hovering = false)} > - {#if folderName && hovering && !loadingFolders && !disabled && !disableEditing} -
-
- {/if}
diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index 92cea4bdf8..7ce36b8e11 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -216,6 +216,22 @@ : ''} {/if} + {#if comparison.summary.schedules_changed > 0} + + {comparison.summary.schedules_changed} schedule{comparison.summary + .schedules_changed !== 1 + ? 's' + : ''} + + {/if} + {#if comparison.summary.triggers_changed > 0} + + {comparison.summary.triggers_changed} trigger{comparison.summary + .triggers_changed !== 1 + ? 's' + : ''} + + {/if} {#if ciTestTotal > 0} diff --git a/frontend/src/lib/components/GfmMarkdown.svelte b/frontend/src/lib/components/GfmMarkdown.svelte index 83cce90fbe..377951b97b 100644 --- a/frontend/src/lib/components/GfmMarkdown.svelte +++ b/frontend/src/lib/components/GfmMarkdown.svelte @@ -1,19 +1,12 @@
diff --git a/frontend/src/lib/components/GitHubAppIntegration.svelte b/frontend/src/lib/components/GitHubAppIntegration.svelte index 7b35dbf84c..7aa136b072 100644 --- a/frontend/src/lib/components/GitHubAppIntegration.svelte +++ b/frontend/src/lib/components/GitHubAppIntegration.svelte @@ -295,13 +295,21 @@ {#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)} -
+
{#if installation.error} {/if} {installation.account_id} + {#if installation.provisioned_by_admin} + + Provisioned by admin + + {/if}
@@ -310,34 +318,41 @@ {#if installation.error} - Token error + Token error {:else} {installation.repositories.length} repos {/if}
- - + {#if !installation.github_base_url} + + {/if} + {#if !installation.provisioned_by_admin} + + {/if}
@@ -381,7 +396,10 @@ {#if installation.error} - Token error + Token error {:else} {installation.repositories.length} repos {/if} @@ -414,26 +432,28 @@
-
-

Import installation from other instance:

-
- - +
+ + +
-
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/HistoricInputs.svelte b/frontend/src/lib/components/HistoricInputs.svelte index ca01013e0a..3c784f354f 100644 --- a/frontend/src/lib/components/HistoricInputs.svelte +++ b/frontend/src/lib/components/HistoricInputs.svelte @@ -112,7 +112,8 @@ syncQueuedRunsCount: false, refreshRate: 10000, currentWorkspace: $workspaceStore ?? '', - skip: !runnableId + skip: !runnableId, + excludesEntrypointOverride: true }) satisfies UseJobLoaderArgs ) let jobs = $derived(jobsLoader?.jobs ?? []) diff --git a/frontend/src/lib/components/InputError.svelte b/frontend/src/lib/components/InputError.svelte index f075521740..a431140afa 100644 --- a/frontend/src/lib/components/InputError.svelte +++ b/frontend/src/lib/components/InputError.svelte @@ -2,7 +2,7 @@ import { slide } from 'svelte/transition' interface Props { - error: string + error?: string | undefined } let { error }: Props = $props() diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index f0097b877a..834ef0b96c 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -56,12 +56,36 @@ attempted_at: string } | null = $state(null) + let offlineCapStatus: { + seats_used: number + seats_cap: number + author_count: number + operator_count: number + current_cu: number + cu_cap: number + cu_over_cap: boolean + } | null = $state(null) + function showSetting(setting: string, values: Record) { if (setting == 'dev_instance') { if (values['license_key'] == undefined) { return false } } + // Hide the nsjail-only settings only when isolation is *explicitly* a + // non-nsjail mode. When `job_isolation` is unset, nsjail may still be + // enabled via the legacy env-driven path (`DISABLE_NSJAIL=false`), so + // keep the controls reachable. + if (setting == 'nsjail_tmp_backing' || setting == 'nsjail_tmpfs_size_mb') { + const isolation = values['job_isolation'] + if (isolation === 'none' || isolation === 'unshare') { + return false + } + } + // The tmpfs size knob is meaningless when /tmp is disk-backed. + if (setting == 'nsjail_tmpfs_size_mb' && values['nsjail_tmp_backing'] === 'disk') { + return false + } return true } @@ -72,6 +96,14 @@ latestKeyRenewalAttempt = await SettingService.getLatestKeyRenewalAttempt() } + async function reloadLicenseStatus() { + try { + offlineCapStatus = (await SettingService.getOfflineLicenseStatus()) as any + } catch { + offlineCapStatus = null + } + } + async function reloadLicenseKey() { $values['license_key'] = await SettingService.getGlobal({ key: 'license_key' @@ -80,7 +112,10 @@ $effect(() => { if (setting.key == 'license_key') { - untrack(() => reloadKeyrenewalAttemptInfo()) + untrack(() => { + reloadKeyrenewalAttemptInfo() + reloadLicenseStatus() + }) } }) @@ -102,11 +137,19 @@ export async function openCustomerPortal() { opening = true + const newWindow = window.open('', '_blank') try { const url = await SettingService.createCustomerPortalSession({ licenseKey: $values['license_key'] || undefined }) - window.open(url, '_blank') + if (newWindow) { + newWindow.location.href = url + } else { + window.location.href = url + } + } catch (err) { + newWindow?.close() + throw err } finally { opening = false } @@ -430,7 +473,7 @@ {/if} {/if} - {#if latestKeyRenewalAttempt} + {#if latestKeyRenewalAttempt && !offlineCapStatus} {@const attemptedAt = new Date(latestKeyRenewalAttempt.attempted_at).toLocaleString()} {@const isTrial = latestKeyRenewalAttempt.result.startsWith('error: trial:')}
@@ -500,11 +543,41 @@
{/if} + {#if offlineCapStatus} + {@const cap = offlineCapStatus} + {@const seatsOver = cap.seats_used > cap.seats_cap} + {@const cuOver = cap.cu_over_cap} +
+
+ {#if seatsOver} + + {:else} + + {/if} + + Seats: {cap.seats_used.toFixed(1)} / {cap.seats_cap} + +
+
+ {#if cuOver} + + {:else} + + {/if} + + CUs: {cap.current_cu.toFixed(2)} / {cap.cu_cap.toFixed(2)} + +
+
+ {/if} + {#if valid || expiration}
- + {#if !offlineCapStatus} + + {/if} @@ -648,6 +721,31 @@ {/each}
+
+ + +

+ Comma-separated host patterns that job HTTP clients should bypass the tracing + proxy for — those hosts will not be traced. Use this for clients that pin their + own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.) which would + otherwise fail with x509: certificate signed by unknown authority. + Independent of the worker's own NO_PROXY env, which governs the proxy's + upstream relay (e.g. through a corporate proxy). +

+
{/if} {:else if setting.fieldType == 'object_store_config'} diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index 68ca3c078f..77c94de11e 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -13,6 +13,7 @@ import HighlightTheme from './HighlightTheme.svelte' import { deepEqual } from 'fast-equals' import { isWindmillTooBigObject } from './job_args' + import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' interface Props { id?: string | undefined @@ -27,6 +28,10 @@ let runLocally: Drawer | undefined = $state() let jsonStr = $state('') + const argsDownloadName = 'windmill-args.json' + let argsApiPath = $derived(id && workspace ? `/w/${workspace}/jobs_u/get_args/${id}` : undefined) + let argsDataHref = $derived(`data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`) + function pythonCode() { return ` if __name__ == "__main__": @@ -60,10 +65,12 @@ ${Object.entries(args) {#if args && typeof args === 'object' && deepEqual( Object.keys(args ?? {}), ['reason'] ) && args['reason'] == 'PREPROCESSOR_ARGS_ARE_DISCARDED'} Preprocessor args are discarded {:else if id && workspace && args && typeof args === 'object' && deepEqual( Object.keys(args ?? {}), ['reason'] ) && args['reason'] == 'WINDMILL_TOO_BIG'} - The args are too big in size to be able to fetch alongside job. Please download the JSON file to view them. + The args are too big in size to be able to fetch alongside job. Please {#if shouldDownloadViaClient()}{:else}download the JSON file to view them{/if}. {:else}
@@ -120,17 +127,26 @@ ${Object.entries(args) {#snippet actions()} - + {#if argsApiPath && shouldDownloadViaClient()} + + {:else} + + {/if} + {:else} + + JSON is too large to be displayed in full. + + {/if} +
{:else} {/if} diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 78429177a7..7913fd4be7 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -15,6 +15,7 @@ type OpenFlow } from '$lib/gen' import { workspaceStore } from '$lib/stores' + import { WM_LOGS_SKIPPED } from '$lib/consts' import { getContext, onDestroy, tick, untrack } from 'svelte' import type { SupportedLanguage } from '$lib/common' import { sendUserToast } from '$lib/toast' @@ -129,6 +130,37 @@ } }) + function isSkippedLogsValue(logs: string | undefined): boolean { + return logs === WM_LOGS_SKIPPED + } + + function getResolvedLogs(logs: string | undefined): string { + return isSkippedLogsValue(logs) ? '' : (logs ?? '') + } + + function mergeLogs(existingLogs: string | undefined, newLogs: string | undefined): string { + const existing = getResolvedLogs(existingLogs) + const incoming = newLogs ?? '' + return existing.length === 0 ? incoming : existing.concat(incoming) + } + + function pickMoreCompleteLogs( + primaryLogs: string | undefined, + fallbackLogs: string | undefined + ): string { + const primary = getResolvedLogs(primaryLogs) + const fallback = getResolvedLogs(fallbackLogs) + // When neither side has real logs but one was the skipped sentinel, keep + // the sentinel so downstream consumers can still lazily resolve logs + // instead of treating the job as having genuinely produced none. + if (primary.length === 0 && fallback.length === 0) { + return isSkippedLogsValue(primaryLogs) || isSkippedLogsValue(fallbackLogs) + ? WM_LOGS_SKIPPED + : '' + } + return primary.length >= fallback.length ? primary : fallback + } + function clearCurrentId() { if (currentId) { if (allowConcurentRequests) { @@ -251,7 +283,8 @@ function refreshLogOffset() { if (logOffset == 0) { - logOffset = job?.logs?.length ? job.logs?.length + 1 : 0 + const currentLogs = getResolvedLogs(job?.logs) + logOffset = currentLogs.length ? currentLogs.length + 1 : 0 } } export async function getLogs() { @@ -264,7 +297,7 @@ logOffset: logOffset }) - if ((job?.logs ?? '').length == 0) { + if (getResolvedLogs(job?.logs).length == 0) { job.logs = getUpdate.new_logs ?? '' logOffset = getUpdate.log_offset ?? 0 } @@ -385,11 +418,9 @@ if (event.data.completed) { const njob = (event.data as any).job as Job & { result_stream?: string } if (njob) { - // Use whichever logs are more complete (longer) - const streamedLogs = job?.logs ?? '' - const completedLogs = njob.logs ?? '' - njob.logs = - streamedLogs.length >= completedLogs.length ? streamedLogs : completedLogs + // Use whichever logs are more complete (longer), but never + // let the WM_LOGS_SKIPPED sentinel win over real logs. + njob.logs = pickMoreCompleteLogs(job?.logs, njob.logs) const streamedResult = job?.result_stream ?? '' const completedResult = njob.result_stream ?? '' njob.result_stream = @@ -451,11 +482,10 @@ } if (previewJobUpdates.new_logs) { - if (logOffset == 0) { - job.logs = previewJobUpdates.new_logs ?? '' - } else { - job.logs = (job?.logs ?? '').concat(previewJobUpdates.new_logs) - } + job.logs = + logOffset == 0 + ? (previewJobUpdates.new_logs ?? '') + : mergeLogs(job?.logs, previewJobUpdates.new_logs) } if (previewJobUpdates.new_result_stream) { @@ -500,6 +530,17 @@ callbacks?.change?.(job) } } + // When a job is fetched with no_logs=true the server omits logs entirely. + // Flag it with a sentinel so consumers (the log panel) can tell "logs were + // intentionally skipped" apart from "job genuinely produced no logs", and + // lazily resolve the real logs on demand. + function flagSkippedLogs(j: T, effectiveNoLogs: boolean): T { + if (effectiveNoLogs && !(j as Job & { logs?: string }).logs) { + ;(j as Job & { logs?: string }).logs = WM_LOGS_SKIPPED + } + return j + } + async function loadTestJob(id: string, callbacks?: Callbacks): Promise { let isCompleted = false if (isCurrentJob(id)) { @@ -519,23 +560,29 @@ }) if ((previewJobUpdates.running ?? false) || (previewJobUpdates.completed ?? false)) { - job = await JobService.getJob({ - workspace: workspace!, - id, - noCode, - noLogs: onlyResult || noLogs - }) + job = flagSkippedLogs( + await JobService.getJob({ + workspace: workspace!, + id, + noCode, + noLogs: onlyResult || noLogs + }), + onlyResult || noLogs + ) callbacks?.change?.(job) } updateJobFromProgress(previewJobUpdates, job, callbacks) } else { - job = await JobService.getJob({ - workspace: workspace!, - id, - noLogs: onlyResult || noLogs, - noCode - }) + job = flagSkippedLogs( + await JobService.getJob({ + workspace: workspace!, + id, + noLogs: onlyResult || noLogs, + noCode + }), + onlyResult || noLogs + ) } jobUpdateLastFetch = new Date() @@ -628,12 +675,15 @@ try { // First load the job to get initial state if ((!job || job.id == '') && !onlyResult) { - job = await JobService.getJob({ - workspace: workspace!, - id, - noLogs: noLogs, - noCode - }) + job = flagSkippedLogs( + await JobService.getJob({ + workspace: workspace!, + id, + noLogs: noLogs, + noCode + }), + noLogs + ) callbacks?.change?.(job) getActiveRecording()?.recordInitialJob(id, job) @@ -775,7 +825,7 @@ clearCurrentId() } else { const njob = previewJobUpdates.job as Job & { result_stream?: string } - njob.logs = job?.logs ?? '' + njob.logs = pickMoreCompleteLogs(job?.logs, njob.logs) njob.result_stream = job?.result_stream ?? '' job = njob onJobCompleted(id, job, callbacks) diff --git a/frontend/src/lib/components/KanidmSetting.svelte b/frontend/src/lib/components/KanidmSetting.svelte index 199c4bc672..8c696fb155 100644 --- a/frontend/src/lib/components/KanidmSetting.svelte +++ b/frontend/src/lib/components/KanidmSetting.svelte @@ -1,23 +1,22 @@
@@ -85,11 +84,12 @@ bind:value={value['id']} /> - -
{/if} {#if !disable_download && !s3resource.endsWith('.csv')} -
CSV
+ {@const csvApiPath = `/w/${workspaceId}/job_helpers/download_s3_parquet_file_as_csv?file_key=${encodeURIComponent(s3resource)}${storage ? `&storage=${storage}` : ''}`} + {@const csvName = (s3resource.split('/').pop() ?? 'download') + '.csv'} + {#if shouldDownloadViaClient()} + + {:else} +
CSV
+ {/if} {/if} {#if nbRows != undefined} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 6e1244b05f..0a64225d14 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -5,7 +5,7 @@
-
+
{#if meta != undefined} + {@const nameDisabled = disabled || disableEditing} {#if !hideUser}
- { - setDirty() - const kind = e.detail - if (meta) { - if (kind === 'folder') { + { - currentTarget.select() - }} - /> - -
-
{error}
-
- {/if} + {#if pathUsageInFlowsPromise || pathUsageInAppsPromise || pathUsageInScriptsPromise} - {#await Promise.all( [pathUsageInAppsPromise, pathUsageInFlowsPromise, pathUsageInScriptsPromise] )} - - {:then [apps, flows, scripts]} + {#await Promise.all( [pathUsageInAppsPromise, pathUsageInFlowsPromise, pathUsageInScriptsPromise] ) then [apps, flows, scripts]} {#if (apps && apps.length) || (flows && flows.length) || (scripts && scripts.length)}

Used by {localeConcatAnd([ @@ -605,9 +608,3 @@ {/if}

- - diff --git a/frontend/src/lib/components/PathNameAutocomplete.svelte b/frontend/src/lib/components/PathNameAutocomplete.svelte index fa27f4dc3b..9c31ef980a 100644 --- a/frontend/src/lib/components/PathNameAutocomplete.svelte +++ b/frontend/src/lib/components/PathNameAutocomplete.svelte @@ -19,18 +19,31 @@ * the same page reuse a single fetch per workspace. */ const pathListCache = new Map() + /** Workspaces whose next fetch must bypass the server-side cache. Set by + * invalidateWorkspacePaths (e.g. after a deploy) and cleared once a forced + * fetch succeeds, so a just-created path shows up immediately instead of + * after the backend's 60s TTL. */ + const forceNextFetch = new Set() + export async function fetchWorkspacePaths(workspace: string): Promise { + const force = forceNextFetch.has(workspace) const now = Date.now() const existing = pathListCache.get(workspace) - if (existing) { + // When forcing, ignore any cached/in-flight entry — it may predate the + // deploy (or have been written by a fetch that hit the stale backend + // cache) and would otherwise mask the new path. + if (existing && !force) { if (existing.paths && now - existing.at < PATH_LIST_TTL_MS) return existing.paths if (existing.pending) return existing.pending } const pending = (async () => { try { - const res = await PathAutocompleteService.listPathAutocompletePaths({ workspace }) + const res = await PathAutocompleteService.listPathAutocompletePaths({ workspace, force }) const paths = res.paths ?? [] pathListCache.set(workspace, { at: Date.now(), paths, pending: null }) + // Only clear the force flag once a forced fetch has actually + // landed fresh data, so a failed retry still forces. + forceNextFetch.delete(workspace) return paths } catch (_e) { pathListCache.delete(workspace) @@ -43,6 +56,7 @@ export function invalidateWorkspacePaths(workspace: string) { pathListCache.delete(workspace) + forceNextFetch.add(workspace) } /** Derive the set of path segments that exist directly under a given folder @@ -72,6 +86,7 @@ + +
- {#if !hidePath} -
- {#if !can_write} -
- - You only have read access to this resource and cannot edit it - -
- {/if} - 0} + + You are going to edit the value in: {otherDirty.join(', ')} + + {/if} + + {#if current} + {#key current} + -
- {/if} - - - {#if !emptyString(resourceTypeInfo?.description)} -
-

{resourceTypeInfo?.name} description

-
- -
-
- {/if} - -
-

Resource description - {#if can_write} -

- {#if can_write && editDescription} -
-
GH Markdown
- -
- {:else if description == undefined || description == ''} -
No description provided
- {:else} -
- -
- {/if} -
- -
-
- switchTab(e.detail)} - options={{ - right: 'As JSON' - }} - /> - resourceTypeResource.refetch()} /> - {#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'} - - {:else} - - {/if} - {#if resource_type === 'git_repository' && $workspaceStore && ($userStore?.is_admin || $userStore?.is_super_admin)} - { - args = newArgs - // Update rawCode if in JSON view mode - if (viewJsonSchema) { - rawCode = JSON.stringify(args, null, 2) - } - }} - onDescriptionUpdate={(newDescription) => (description = newDescription)} - /> - {/if} -
- -
- {#if loadingSchema} - - {:else if !viewJsonSchema && resourceTypeInfo?.is_fileset} -
-
Fileset
- -
- - {:else if !viewJsonSchema && resourceSchema && resourceSchema?.properties} - {#if resourceTypeInfo?.format_extension} -
- File content ({resourceTypeInfo.format_extension}) -
-
- -
- {:else} - - {/if} - {:else if !can_write} - - {:else} - {#if !viewJsonSchema} -
-

- Resource type '{resource_type}' not found in your workspace -

- -

Define the value in JSON directly

-
- {/if} - - {#if !emptyString(jsonError)}{jsonError}{:else}
{/if} -
- -
- {/if} -
-
+ {/key} + {/if}
diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 54348e81c1..ed80717a32 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -4,6 +4,8 @@ import DrawerContent from './common/drawer/DrawerContent.svelte' import { Loader2, Save } from 'lucide-svelte' + import WsSpecificVersions from './WsSpecificVersions.svelte' + import { workspaceStore } from '$lib/stores' let { workspace = undefined, @@ -15,14 +17,17 @@ let resource_type: string | undefined = $state(undefined) let defaultValues: Record | undefined = $state(undefined) - let resourceEditor: { editResource: () => void; createResource: () => void } | undefined = - $state(undefined) + let resourceEditor: { save: () => void } | undefined = $state(undefined) let path: string | undefined = $state(undefined) + let selected: string | undefined = $state(undefined) + + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) export async function initEdit(p: string): Promise { resource_type = undefined path = p + selected = effectiveWorkspace drawer?.openDrawer?.() } @@ -33,13 +38,14 @@ path = undefined resource_type = resourceType defaultValues = nDefaultValues + selected = effectiveWorkspace drawer?.openDrawer?.() } let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit') - + {/await} {#snippet actions()} + {#if mode == 'edit' && path && effectiveWorkspace} + + {/if}
+ +
+
+ { + if (e.detail) { + rawCode = JSON.stringify(args, null, 2) + } else if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) { + textFileContent = args?.content ?? '' + } + }} + options={{ + right: 'As JSON' + }} + /> + + {#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'} + + {:else} + + {/if} + {#if resource_type === 'git_repository' && $workspaceStore && ($userStore?.is_admin || $userStore?.is_super_admin)} + { + args = newArgs + if (viewJsonSchema) { + rawCode = JSON.stringify(args, null, 2) + } + }} + onDescriptionUpdate={(newDescription) => (description = newDescription)} + /> + {/if} +
+ +
+ {#if loadingSchema} + + {:else if !viewJsonSchema && resourceTypeInfo?.is_fileset} +
+
Fileset
+ +
+ + {:else if !viewJsonSchema && resourceSchema && resourceSchema?.properties} + {#if resourceTypeInfo?.format_extension} +
+ File content ({resourceTypeInfo.format_extension}) +
+
+ +
+ {:else} + + {/if} + {:else if !can_write} + + {:else} + {#if !viewJsonSchema} +
+

+ Resource type '{resource_type}' not found in your workspace +

+ onLoadResourceType?.()} /> +

Define the value in JSON directly

+
+ {/if} + + {#if !emptyString(jsonError)}{jsonError}{:else}
{/if} +
+ +
+ {/if} +
+
diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 588b4cdaa4..360eb9276d 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -10,7 +10,7 @@ } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { userStore, workspaceStore, userWorkspaces, superadmin } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces, superadmin, devopsRole } from '$lib/stores' import { Button, ButtonType, @@ -82,7 +82,7 @@ usernames, folders, jobTriggerKinds, - isSuperAdmin: !!$superadmin, + isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, isAdminsWorkspace: $workspaceStore === 'admins' }) ) @@ -750,11 +750,12 @@ )} schema={runsFilterSearchbarSchema} presets={buildRunsFilterPresets({ - isSuperadmin: !!$superadmin, + isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, isAdminsWorkspace: $workspaceStore === 'admins' })} bind:value={filters.val} placeholder="Filter runs..." + autofocus /> diff --git a/frontend/src/lib/components/S3FilePickerInner.svelte b/frontend/src/lib/components/S3FilePickerInner.svelte index f07ccbccaf..24b8b72892 100644 --- a/frontend/src/lib/components/S3FilePickerInner.svelte +++ b/frontend/src/lib/components/S3FilePickerInner.svelte @@ -38,6 +38,7 @@ sendUserToast, type S3Object } from '$lib/utils' + import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { Alert, Button } from './common' import Section from './Section.svelte' import { createEventDispatcher, untrack, type Snippet } from 'svelte' @@ -716,14 +717,27 @@ {#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)}
{#if !hideS3SpecificDetails} - {#snippet text()} {label} is only available with an enterprise license @@ -1322,7 +1342,6 @@ } } initContent('bun', script.kind, template) - showWacAlphaModalIfNeeded() }} > WAC TypeScript @@ -1347,7 +1366,6 @@ } } initContent('python3', script.kind, template) - showWacAlphaModalIfNeeded() }} > WAC Python @@ -1966,80 +1984,34 @@
-
+
-
-
- -
- +
+ + {#if customUi?.topBar?.path != false} + onNavigate?.(item)} + /> + {/if}
-
- {#if triggersState.triggers?.some((t) => t.type === 'schedule')} - {@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary)} - {@const schedule = triggersState.triggers.findIndex((t) => t.type === 'schedule')} - - - {/if} - {#if customUi?.topBar?.path != false} -
- {#if customUi?.topBar?.editablePath != false} - - {/if} - { - currentTarget.select() - }} - /> -
- {/if} -
- {#if $enterpriseLicense && initialPath != ''} {/if} @@ -2047,47 +2019,67 @@
- {#if customUi?.topBar?.tagEdit != false} - {#if $workerTags} - {#if $workerTags?.length ?? 0 > 0} -
- -
+ {#snippet settingsButton()} + {#if customUi?.topBar?.settings != false} + + {/if} + {/snippet} + {#if compactTopbar} + + {#snippet buttonReplacement()} + {/if} - handleEditScript(false, detail)} /> @@ -2117,6 +2109,7 @@ bind:code={script.content} lang={script.language} kind={script.kind} + autoKind={script.auto_kind} {template} tag={script.tag} lastSavedCode={savedScript?.draft?.content} @@ -2127,6 +2120,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet + {initialTestPanelCollapsed} />
{:else} @@ -2134,26 +2128,3 @@ {/if} - - -
-

- Workflow-as-Code is in alpha — use in production at your own risk. It is an - alternative to the Flow editor for advanced users. Feedback welcome on - GitHub - or - Discord. -

-
- -
-
-
diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index bd3549e05a..a281b266da 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -1,5 +1,6 @@ +
diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} + + +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/WsSpecificVersions.svelte b/frontend/src/lib/components/WsSpecificVersions.svelte new file mode 100644 index 0000000000..ec1e35b853 --- /dev/null +++ b/frontend/src/lib/components/WsSpecificVersions.svelte @@ -0,0 +1,54 @@ + + +{#if versions.length > 1} + + {#snippet children({ item })} + {#each regular as v (v)} + + {/each} + {#if more.length > 0} + ({ label: v, value: v }))} + {item} + bind:selected + /> + {/if} + {/snippet} + +{/if} diff --git a/frontend/src/lib/components/ZitadelSetting.svelte b/frontend/src/lib/components/ZitadelSetting.svelte index 430c4c588c..63247bf1de 100644 --- a/frontend/src/lib/components/ZitadelSetting.svelte +++ b/frontend/src/lib/components/ZitadelSetting.svelte @@ -1,21 +1,20 @@
@@ -75,11 +74,12 @@ bind:value={value['id']} /> -