mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 16:05:00 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4df60fb8 |
@@ -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 `<input>`
|
||||
- `{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)
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: commit
|
||||
user_invocable: true
|
||||
description: Create a git commit with conventional commit format. MUST use anytime you want to commit changes.
|
||||
---
|
||||
|
||||
@@ -53,6 +52,8 @@ chore: upgrade sqlx to 0.7
|
||||
4. Stage ONLY the modified/relevant files: `git add <file1> <file2> ...`
|
||||
5. Create the commit with conventional format:
|
||||
```bash
|
||||
git commit -m "<type>: <description>"
|
||||
git commit -m "<type>: <description>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
||||
```
|
||||
6. Run `git status` to verify the commit succeeded
|
||||
|
||||
@@ -1,98 +1,97 @@
|
||||
---
|
||||
name: local-review
|
||||
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.
|
||||
description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code.
|
||||
---
|
||||
|
||||
# Local Code Review
|
||||
# Local Code Review Skill
|
||||
|
||||
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 a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only.
|
||||
|
||||
**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.
|
||||
## Review Philosophy
|
||||
|
||||
## Steps
|
||||
- **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.
|
||||
|
||||
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 <n>` or `git rev-parse <branch>`).
|
||||
## What to Flag
|
||||
|
||||
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.
|
||||
- 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
|
||||
|
||||
- **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.
|
||||
## What NOT to Flag
|
||||
|
||||
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.
|
||||
- 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
|
||||
|
||||
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.
|
||||
## Execution Steps
|
||||
|
||||
## Subagent prompt template
|
||||
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`
|
||||
|
||||
```
|
||||
Review <PR #N | branch X> against main per the policy in REVIEW.md.
|
||||
2. **Find relevant CLAUDE.md files**:
|
||||
- Read the root `CLAUDE.md`
|
||||
- Check for CLAUDE.md files in directories containing changed files
|
||||
|
||||
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 <N>` (if PR) or `git diff main...<branch>`.
|
||||
4. Get context: `gh pr view <N>` (if PR) or `git log main..<branch> --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.
|
||||
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
|
||||
|
||||
<paste output format from below>
|
||||
4. **Read changed files** where the diff alone is insufficient to understand context
|
||||
|
||||
<if --comment requested:>
|
||||
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] ..."}, ...]
|
||||
```
|
||||
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
|
||||
|
||||
## Output format
|
||||
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
|
||||
|
||||
<verdict line per REVIEW.md>
|
||||
|
||||
Found N issues:
|
||||
|
||||
1. [P0|P1|P2] <description>
|
||||
1. <description> (<reason: CLAUDE.md adherence | bug | security>)
|
||||
<file_path:line_number>
|
||||
|
||||
2. [P0|P1|P2] <description>
|
||||
2. <description> (<reason>)
|
||||
<file_path:line_number>
|
||||
```
|
||||
|
||||
End with a `Test coverage` section per the shared policy.
|
||||
|
||||
If no issues are found:
|
||||
|
||||
```
|
||||
## Code review
|
||||
|
||||
Good to merge.
|
||||
|
||||
No issues found. Checked for bugs, security, and AGENTS.md compliance.
|
||||
No issues found. Checked for bugs and CLAUDE.md compliance.
|
||||
```
|
||||
|
||||
## Posting comments (`--comment`)
|
||||
## Posting Comments (--comment flag)
|
||||
|
||||
For a top-level PR comment:
|
||||
If the user passes `--comment`, post findings as inline PR comments using:
|
||||
|
||||
```bash
|
||||
gh pr review --comment --body "<summary from subagent>"
|
||||
gh pr review --comment --body "<summary>"
|
||||
```
|
||||
|
||||
For inline comments on specific lines (using the JSON the subagent emitted):
|
||||
Or for inline comments on specific lines:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/reviews \
|
||||
-f body="<summary>" -f event="COMMENT" -f comments="<json from subagent>"
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="<summary>" -f event="COMMENT" -f comments="[...]"
|
||||
```
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
---
|
||||
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.
|
||||
@@ -607,18 +602,7 @@ 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
|
||||
### Step 17: Update OpenAPI Spec and Regenerate Types
|
||||
|
||||
Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then:
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
name: pr
|
||||
user_invocable: true
|
||||
description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR.
|
||||
---
|
||||
|
||||
@@ -51,22 +50,22 @@ The body MUST be explicit about what changed. Structure:
|
||||
## Test plan
|
||||
- [ ] <How to verify change 1>
|
||||
- [ ] <How to verify change 2>
|
||||
```
|
||||
|
||||
The harness/tooling that invoked the skill may add its own attribution trailer; the skill itself does not prescribe one.
|
||||
---
|
||||
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. **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:
|
||||
4. 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:
|
||||
5. Push to remote if needed: `git push -u origin HEAD`
|
||||
6. Create draft PR using gh CLI:
|
||||
```bash
|
||||
gh pr create --draft --title "<type>: <description>" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
@@ -79,10 +78,13 @@ The harness/tooling that invoked the skill may add its own attribution trailer;
|
||||
## Test plan
|
||||
- [ ] <test 1>
|
||||
- [ ] <test 2>
|
||||
|
||||
---
|
||||
Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
8. Return the PR URL to the user
|
||||
7. Return the PR URL to the user
|
||||
|
||||
## EE Companion PR (when `*_ee.rs` files were modified)
|
||||
|
||||
@@ -98,6 +100,9 @@ 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 "<type>: <description>" --body "$(cat <<'EOF'
|
||||
Companion PR for windmill-labs/windmill#<PR_NUMBER>
|
||||
|
||||
---
|
||||
Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
|
||||
@@ -78,7 +78,3 @@ 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.
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: update-sqlx
|
||||
description: How to safely update SQLx offline query cache. MUST use when SQL queries change.
|
||||
---
|
||||
|
||||
# SQLx Offline Query Cache
|
||||
|
||||
Windmill uses `SQLX_OFFLINE=true` in CI, which requires all `sqlx::query!` / `sqlx::query_as!` macros to have matching cached query data in `backend/.sqlx/`.
|
||||
|
||||
## When to Run
|
||||
|
||||
Run after any change to SQL queries in Rust source files. Without it, CI will fail with:
|
||||
```
|
||||
error: `SQLX_OFFLINE=true` but there is no cached data for this query
|
||||
```
|
||||
|
||||
## The Problem
|
||||
|
||||
`cargo sqlx prepare --workspace` **deletes all existing cache files** and regenerates only the ones found in the current compilation. If you don't compile with every feature flag (especially `private` for EE files), you will **silently delete EE query caches**, breaking CI for enterprise tests.
|
||||
|
||||
The standard `./update_sqlx.sh` script tries to compile with all features, but it often fails locally because the EE symlinks can be out of sync with `main`.
|
||||
|
||||
## Safe Procedure
|
||||
|
||||
Always preserve the existing EE caches from `origin/main`. Use this workflow:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 1. Restore the full cache from main (includes EE caches)
|
||||
git checkout origin/main -- .sqlx/
|
||||
|
||||
# 2. Run prepare with OSS features (what compiles locally)
|
||||
# This regenerates OSS caches to match your code changes.
|
||||
cargo sqlx prepare --workspace -- --workspace --features all_sqlx_features
|
||||
|
||||
# 3. Restore any EE caches that were deleted in step 2.
|
||||
# These are files present in origin/main but missing after prepare.
|
||||
git ls-tree origin/main backend/.sqlx/ \
|
||||
| awk '{print $4}' | sed 's|backend/\.sqlx/||' | sort > /tmp/main_files.txt
|
||||
|
||||
find backend/.sqlx -name "*.json" -printf '%P\n' | sort > /tmp/current_files.txt
|
||||
|
||||
comm -23 /tmp/main_files.txt /tmp/current_files.txt > /tmp/missing_files.txt
|
||||
|
||||
while read f; do
|
||||
git show "origin/main:backend/.sqlx/$f" > "backend/.sqlx/$f"
|
||||
done < /tmp/missing_files.txt
|
||||
|
||||
# 4. Verify nothing was lost from main
|
||||
find backend/.sqlx -name "*.json" -printf '%P\n' | sort > /tmp/current_files.txt
|
||||
comm -23 /tmp/main_files.txt /tmp/current_files.txt | wc -l
|
||||
# Should output: 0
|
||||
```
|
||||
|
||||
## If EE Compiles Locally
|
||||
|
||||
If your EE repo happens to be in sync, you can use the full script (faster):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./update_sqlx.sh
|
||||
```
|
||||
|
||||
But if it fails with EE compilation errors, use the safe procedure above.
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
- **Never** run `cargo sqlx prepare --workspace` with only OSS features and commit the result — it will delete EE caches.
|
||||
- **Never** set `SQLX_OFFLINE=true` for local `cargo sqlx prepare` — use a live database per CLAUDE.md. (CI runs with `SQLX_OFFLINE=true`, which is why the cache must be complete.)
|
||||
- **Never** skip the verification step (step 4 above).
|
||||
|
||||
## Verification
|
||||
|
||||
After committing, the diff against `origin/main` should show:
|
||||
- A few **new** cache files (for your changed queries)
|
||||
- A few **deleted** cache files (for old queries that no longer exist)
|
||||
- **Zero** net deletions from the EE cache set
|
||||
|
||||
```bash
|
||||
git diff origin/main --stat backend/.sqlx/
|
||||
```
|
||||
@@ -16,23 +16,6 @@ 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." >&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
|
||||
echo "BLOCK: You are on the main branch. Create or switch to a feature branch first."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Claude output format
|
||||
|
||||
- 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.
|
||||
+3
-30
@@ -44,25 +44,7 @@
|
||||
"Bash(git merge:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git add:*)",
|
||||
"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/*)"
|
||||
"Bash(git commit:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(.env)",
|
||||
@@ -73,10 +55,7 @@
|
||||
"Read(**/*.pem)",
|
||||
"Read(**/*.key)",
|
||||
"Read(**/credentials.json)",
|
||||
"Read(**/.secret*)",
|
||||
"Read(**/.secrets*)",
|
||||
"Read(**/*.secret)",
|
||||
"Read(**/*.secrets)",
|
||||
"Read(**/*secret*)",
|
||||
"Edit(.env)",
|
||||
"Edit(.env.*)",
|
||||
"Edit(**/.env)",
|
||||
@@ -90,13 +69,7 @@
|
||||
"Bash(chown:*)",
|
||||
"Bash(truncate:*)",
|
||||
"Bash(shred:*)",
|
||||
"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"
|
||||
"Bash(unlink:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/adding-a-trigger/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/commit/SKILL.md
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
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
|
||||
|
||||
```
|
||||
<type>: <description>
|
||||
```
|
||||
|
||||
### 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 <file1> <file2> ...`
|
||||
5. Create the commit with conventional format:
|
||||
```bash
|
||||
git commit -m "<type>: <description>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
||||
```
|
||||
6. Run `git status` to verify the commit succeeded
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/local-review/SKILL.md
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
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
|
||||
|
||||
Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only.
|
||||
|
||||
## Review Philosophy
|
||||
|
||||
- **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.
|
||||
|
||||
## What to Flag
|
||||
|
||||
- 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
|
||||
|
||||
## What NOT to Flag
|
||||
|
||||
- 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
|
||||
|
||||
## Execution Steps
|
||||
|
||||
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`
|
||||
|
||||
2. **Find relevant CLAUDE.md files**:
|
||||
- Read the root `CLAUDE.md`
|
||||
- Check for CLAUDE.md files in directories containing changed files
|
||||
|
||||
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. **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
|
||||
|
||||
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. <description> (<reason: CLAUDE.md adherence | bug | security>)
|
||||
<file_path:line_number>
|
||||
|
||||
2. <description> (<reason>)
|
||||
<file_path:line_number>
|
||||
```
|
||||
|
||||
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 "<summary>"
|
||||
```
|
||||
|
||||
Or for inline comments on specific lines:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="<summary>" -f event="COMMENT" -f comments="[...]"
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/native-trigger/SKILL.md
|
||||
@@ -0,0 +1,777 @@
|
||||
# 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<Self::CreateResponse>;
|
||||
async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result<serde_json::Value>;
|
||||
async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result<Self::TriggerData>;
|
||||
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<bool>;
|
||||
async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors);
|
||||
fn external_id_and_metadata_from_response(&self, resp) -> (String, Option<serde_json::Value>);
|
||||
|
||||
// Methods with defaults:
|
||||
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned>;
|
||||
fn service_config_from_create_response(&self, data, resp) -> Option<serde_json::Value>;
|
||||
fn additional_routes(&self) -> axum::Router;
|
||||
async fn http_client_request<T, B>(&self, url, method, workspace_id, tx, db, headers, body) -> Result<T>;
|
||||
}
|
||||
```
|
||||
|
||||
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<String>, // 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<String>` | 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<String>, // 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
// 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<Self::ServiceConfig>,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<Self::CreateResponse> {
|
||||
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<Self::ServiceConfig>,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<serde_json::Value> {
|
||||
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<Self::TriggerData> {
|
||||
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<bool> {
|
||||
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<TriggerSyncInfo>,
|
||||
errors: &mut Vec<SyncError>,
|
||||
) {
|
||||
// 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<serde_json::Value>) {
|
||||
(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: &<Self as External>::OAuthData,
|
||||
db: &DB,
|
||||
) -> Result<Vec<<Self as External>::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<String>, 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<BackgroundSyncResult> {
|
||||
// ...
|
||||
#[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<string, ServiceConfig> = {
|
||||
// ... 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 `<NativeTriggersPanel service="yourservice" ...>` 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 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<String>,
|
||||
pub resource_name: Option<String>,
|
||||
// Calendar-specific fields (only used when trigger_type = Calendar)
|
||||
pub calendar_id: Option<String>,
|
||||
pub calendar_name: Option<String>,
|
||||
// Metadata set after creation
|
||||
pub google_resource_id: Option<String>,
|
||||
pub expiration: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
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<Self::ServiceConfig>,
|
||||
resp: &Self::CreateResponse,
|
||||
) -> Option<serde_json::Value> {
|
||||
// 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<PushArgsOwned> {
|
||||
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::<serde_json::Value>(&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<Option<PushArgsOwned>> {
|
||||
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).
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/pr/SKILL.md
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
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:
|
||||
```
|
||||
<type>: <description>
|
||||
```
|
||||
|
||||
### 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] <type>: <description>`
|
||||
|
||||
## PR Body Format
|
||||
|
||||
The body MUST be explicit about what changed. Structure:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
<Clear description of what this PR does and why>
|
||||
|
||||
## Changes
|
||||
- <Specific change 1>
|
||||
- <Specific change 2>
|
||||
- <Specific change 3>
|
||||
|
||||
## Test plan
|
||||
- [ ] <How to verify change 1>
|
||||
- [ ] <How to verify change 2>
|
||||
|
||||
---
|
||||
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. 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:
|
||||
```bash
|
||||
gh pr create --draft --title "<type>: <description>" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<description>
|
||||
|
||||
## Changes
|
||||
- <change 1>
|
||||
- <change 2>
|
||||
|
||||
## Test plan
|
||||
- [ ] <test 1>
|
||||
- [ ] <test 2>
|
||||
|
||||
---
|
||||
Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
7. 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 <ee-path> 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 "<type>: <description>" --body "$(cat <<'EOF'
|
||||
Companion PR for windmill-labs/windmill#<PR_NUMBER>
|
||||
|
||||
---
|
||||
Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
5. Commit `ee-repo-ref.txt` and push the updated windmill branch
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/refine/SKILL.md
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/rust-backend/SKILL.md
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
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<T, Error>` or `JsonResult<T>`:
|
||||
|
||||
```rust
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
pub async fn get_job(db: &DB, id: Uuid) -> Result<Job> {
|
||||
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<serde_json::value::RawValue>` over `serde_json::Value` when storing/passing JSON without inspection:
|
||||
|
||||
```rust
|
||||
pub struct Job {
|
||||
pub args: Option<Box<serde_json::value::RawValue>>,
|
||||
}
|
||||
```
|
||||
|
||||
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<Uuid>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
#[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<DB>,
|
||||
Path((workspace, job_id)): Path<(String, Uuid)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<Job>> { ... }
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/svelte-frontend/SKILL.md
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
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 — `<Button>`
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronLeft } from 'lucide-svelte'
|
||||
</script>
|
||||
|
||||
<Button variant="default" onclick={handleClick}>Label</Button>
|
||||
<Button startIcon={{ icon: ChevronLeft }} iconOnly onclick={prev} />
|
||||
```
|
||||
|
||||
Props: `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`, `unifiedSize?: 'sm' | 'md' | 'lg'`, `startIcon?: { icon: SvelteComponent }`, `iconOnly?: boolean`, `disabled?: boolean`
|
||||
|
||||
### Text inputs — `<TextInput>`
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { TextInput } from '$lib/components/common'
|
||||
</script>
|
||||
|
||||
<TextInput bind:value={val} placeholder="Enter value" />
|
||||
```
|
||||
|
||||
Props: `value?: string | number` (bindable), `placeholder?: string`, `disabled?: boolean`, `error?: string | boolean`, `size?: 'sm' | 'md' | 'lg'`
|
||||
|
||||
### Selects — `<Select>`
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
</script>
|
||||
|
||||
<Select items={[{ label: 'Jan', value: 1 }]} bind:value={selected} />
|
||||
```
|
||||
|
||||
Props: `items?: Array<{ label?: string; value: any }>`, `value` (bindable), `placeholder?: string`, `clearable?: boolean`, `size?: 'sm' | 'md' | 'lg'`
|
||||
|
||||
### Icons — `lucide-svelte`
|
||||
|
||||
Never write inline SVGs. Import from `lucide-svelte`:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { ChevronLeft, X } from 'lucide-svelte'
|
||||
</script>
|
||||
<ChevronLeft size={16} />
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
Form components (TextInput, Toggle, Select, etc.) should use the unified size system when placed together.
|
||||
|
||||
## Styling
|
||||
|
||||
- Use Tailwind CSS for all styling — no custom CSS
|
||||
- Use Windmill's theming classes for colors/surfaces (see `frontend/brand-guidelines.md`)
|
||||
- Read component props JSDoc before using them
|
||||
|
||||
## Svelte MCP Server
|
||||
|
||||
Use the Svelte MCP tools when working on Svelte code:
|
||||
|
||||
1. **list-sections**: Call first to discover available docs
|
||||
2. **get-documentation**: Fetch relevant sections based on use_cases
|
||||
3. **svelte-autofixer**: MUST use on all Svelte code before finalizing — keep calling until no issues
|
||||
4. **playground-link**: Only after user confirms and code was NOT written to project files
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/update-sqlx/SKILL.md
|
||||
@@ -1,4 +0,0 @@
|
||||
#:schema https://developers.openai.com/codex/config-schema.json
|
||||
|
||||
[mcp_servers.svelte]
|
||||
url = "https://mcp.svelte.dev/mcp"
|
||||
@@ -20,10 +20,4 @@ sed -i '' -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
|
||||
|
||||
sed -i '' -E "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
|
||||
|
||||
# windmill-parser-wasm is its own workspace (excluded from the backend workspace
|
||||
# because of nightly-only cargo-features), so its version lives in
|
||||
# [workspace.package] and its Cargo.lock is not regenerated by the backend step.
|
||||
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.toml
|
||||
sed -i '' -E "s/(name = \"windmill[^\"]*\"\nversion = )\"[^\"]*\"/\\1\"$VERSION\"/g" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.lock
|
||||
|
||||
cd ${root_dirpath}/frontend && npm i --package-lock-only
|
||||
|
||||
@@ -21,10 +21,4 @@ sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
|
||||
|
||||
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
|
||||
|
||||
# windmill-parser-wasm is its own workspace (excluded from the backend workspace
|
||||
# because of nightly-only cargo-features), so its version lives in
|
||||
# [workspace.package] and its Cargo.lock is not regenerated by the backend step.
|
||||
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.toml
|
||||
sed -i -zE "s/(name = \"windmill[^\"]*\"\nversion = )\"[^\"]*\"/\\1\"$VERSION\"/g" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.lock
|
||||
|
||||
cd ${root_dirpath}/frontend && npm i --package-lock-only --ignore-scripts
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Codex output format
|
||||
|
||||
- Read `./.github/codex/pr-review-context.md` for PR metadata and the diff commands.
|
||||
- Return a markdown PR comment starting with `## Codex Review`.
|
||||
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
|
||||
@@ -1,6 +0,0 @@
|
||||
# Pi output format
|
||||
|
||||
- Read `./.github/pi/pr-review-context.md` for PR metadata and the diff commands.
|
||||
- Return a markdown PR comment starting with `## Pi Review`.
|
||||
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
|
||||
- Output ONLY the final review markdown — no preamble, no thinking, no tool transcripts.
|
||||
@@ -145,18 +145,6 @@ jobs:
|
||||
RUST_LOG_STYLE: never
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||
CARGO_BUILD_JOBS: 12
|
||||
# backend/Cargo.toml sets split-debuginfo = "unpacked", which on
|
||||
# windows-msvc is coerced to "packed": every test-binary link spawns
|
||||
# the mspdbsrv.exe PDB type server and writes a large .pdb. With 12
|
||||
# parallel link jobs this races the type-server cap (LNK1318 "LIMIT
|
||||
# (12)") and exhausts the runner disk (LNK1180). CI needs no debug
|
||||
# info, so disable PDB generation for the dev/test profiles here.
|
||||
CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off"
|
||||
CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off"
|
||||
# Tests' poll-time stack frames (deep nested async fn chains in
|
||||
# debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky
|
||||
# overflows under parallel-test contention.
|
||||
RUST_MIN_STACK: 4194304
|
||||
VCPKGRS_DYNAMIC: 1
|
||||
OPENSSL_DIR: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed\x64-windows-static
|
||||
DENO_PATH: ${{ steps.runtime-paths.outputs.DENO_PATH }}
|
||||
|
||||
@@ -244,11 +244,6 @@ jobs:
|
||||
RUST_LOG_STYLE: never
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||
CARGO_BUILD_JOBS: 12
|
||||
# Tests' poll-time stack frames (deep nested async fn chains in
|
||||
# debug builds) reach ~1.8MB, leaving very thin headroom on the
|
||||
# default 2MB thread stack. 4MB gives ~2x buffer against flaky
|
||||
# overflows under parallel-test contention.
|
||||
RUST_MIN_STACK: 4194304
|
||||
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
|
||||
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
|
||||
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
|
||||
|
||||
@@ -290,49 +290,6 @@ jobs:
|
||||
path: |
|
||||
*.json
|
||||
|
||||
benchmark_wac:
|
||||
runs-on: ubicloud-standard-8
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_DB: windmill
|
||||
POSTGRES_PASSWORD: changeme
|
||||
POSTGRES_INITDB_ARGS: "-c shared_buffers=2GB -c work_mem=32MB -c effective_cache_size=4GB"
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s
|
||||
--health-retries 5
|
||||
--shm-size=2g
|
||||
windmill:
|
||||
image: ghcr.io/windmill-labs/windmill-ee:main
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill
|
||||
LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }}
|
||||
WORKER_GROUP: main
|
||||
WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets
|
||||
options: >-
|
||||
--pull always --health-interval 10s --health-timeout 5s
|
||||
--health-retries 5 --health-cmd "curl
|
||||
http://localhost:8000/api/version"
|
||||
ports:
|
||||
- 8000:8000
|
||||
steps:
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
- name: benchmark
|
||||
timeout-minutes: 30
|
||||
run: deno run -A -r
|
||||
https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/benchmark_suite.ts
|
||||
-c
|
||||
https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/suite_wac.json
|
||||
- name: Save benchmark results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark_wac
|
||||
path: |
|
||||
*.json
|
||||
|
||||
benchmark_graphs:
|
||||
runs-on: ubicloud
|
||||
needs:
|
||||
@@ -340,7 +297,6 @@ jobs:
|
||||
- benchmark_dedicated
|
||||
- benchmark_4workers
|
||||
- benchmark_8workers
|
||||
- benchmark_wac
|
||||
steps:
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Check fixture is empty
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "fixtures/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "fixtures/**"
|
||||
|
||||
jobs:
|
||||
check-empty-fixture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure fixtures/cli-sync/ has no committed snapshot
|
||||
run: bash fixtures/check-empty.sh
|
||||
@@ -10,7 +10,6 @@ on:
|
||||
- "backend/windmill-api/openapi.yaml"
|
||||
- "cli/src/main.ts"
|
||||
- "cli/src/commands/**"
|
||||
- "frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts"
|
||||
pull_request:
|
||||
paths:
|
||||
- "system_prompts/**"
|
||||
@@ -20,7 +19,6 @@ on:
|
||||
- "backend/windmill-api/openapi.yaml"
|
||||
- "cli/src/main.ts"
|
||||
- "cli/src/commands/**"
|
||||
- "frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts"
|
||||
|
||||
jobs:
|
||||
check-freshness:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Fast Claude
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai-fast')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai-fast')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai-fast')) ||
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '/ai-fast'))
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
claude-code-action:
|
||||
needs: check-membership
|
||||
if: |
|
||||
needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "windmill-internal-app[bot]"
|
||||
trigger_phrase: "/ai-fast"
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
"SQLX_OFFLINE": "true"
|
||||
}
|
||||
}
|
||||
claude_args: |
|
||||
--allowedTools "Bash,WebFetch,WebSearch"
|
||||
--model opus
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Fast Claude
|
||||
name: Claude PR Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
if: |
|
||||
needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -37,6 +38,37 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Run npm install and generate-backend-client
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
# add a build directory for cargo check
|
||||
mkdir -p build
|
||||
npm install
|
||||
npm run generate-backend-client
|
||||
|
||||
- name: install xmlsec1 and gssapi
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev libcurl4-openssl-dev mold clang
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.93.0
|
||||
|
||||
- name: cargo check
|
||||
working-directory: ./backend
|
||||
timeout-minutes: 16
|
||||
run: |
|
||||
SQLX_OFFLINE=true cargo check --features all_sqlx_features
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
@@ -52,3 +84,24 @@ jobs:
|
||||
claude_args: |
|
||||
--allowedTools "Bash,WebFetch,WebSearch"
|
||||
--model opus
|
||||
--system-prompt "## IMPORTANT INSTRUCTIONS
|
||||
- Your branch name should be a short description of the requested changes.
|
||||
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main.
|
||||
|
||||
## Code Quality Requirements
|
||||
|
||||
After making any code changes, you MUST run the appropriate validation commands:
|
||||
|
||||
**Frontend Changes:**
|
||||
- Run: \`npm run check\` in the frontend directory
|
||||
- Fix all warnings and errors before proceeding
|
||||
|
||||
**Backend Changes:**
|
||||
- Run: \`cargo check --features all_sqlx_features\` in the backend directory
|
||||
- Fix all warnings and errors before proceeding
|
||||
|
||||
**Pull Request Creation:**
|
||||
- DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue.
|
||||
|
||||
## Available Tools
|
||||
- Bash: Full access to run validation commands and git operations"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
name: CLI Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
name: Codex Auto Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ready_for_review, opened, synchronize]
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: number
|
||||
extra_prompt:
|
||||
description: 'Additional reviewer instructions appended to the standard review prompt'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
triggered_by:
|
||||
description: 'GitHub username that triggered this review (for audit only)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
required: false
|
||||
CODEX_AUTH_JSON:
|
||||
required: false
|
||||
WINDMILL_EE_PRIVATE_ACCESS:
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: codex-review-${{ inputs.pr_number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
with:
|
||||
commenter: ${{ github.event.pull_request.user.login }}
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
codex-review:
|
||||
needs: check-membership
|
||||
runs-on: ubicloud-standard-2
|
||||
timeout-minutes: 30
|
||||
if: |
|
||||
always() &&
|
||||
(
|
||||
needs.check-membership.result == 'skipped' ||
|
||||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
|
||||
) &&
|
||||
(
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check Codex configuration
|
||||
id: codex_config
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
run: |
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
echo "auth_mode=api_key" >> "$GITHUB_OUTPUT"
|
||||
elif [ -n "$CODEX_AUTH_JSON" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review."
|
||||
fi
|
||||
|
||||
- name: Resolve PR metadata
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
EVENT_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_BODY: ${{ github.event.pull_request.body }}
|
||||
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
|
||||
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
|
||||
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
|
||||
BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
|
||||
BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
|
||||
HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid')
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
||||
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
|
||||
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
|
||||
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
|
||||
else
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
BASE_REF="$EVENT_BASE_REF"
|
||||
BASE_SHA="$EVENT_BASE_SHA"
|
||||
HEAD_SHA="$EVENT_HEAD_SHA"
|
||||
PR_TITLE="$EVENT_TITLE"
|
||||
PR_BODY="$EVENT_BODY"
|
||||
IS_FORK="$EVENT_FORK"
|
||||
PR_AUTHOR="$EVENT_AUTHOR"
|
||||
fi
|
||||
if [ "$IS_FORK" = "true" ]; then
|
||||
echo "Skipping Codex review for fork PR."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
{
|
||||
echo "skip=false"
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "base_ref=$BASE_REF"
|
||||
echo "base_sha=$BASE_SHA"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "pr_author=$PR_AUTHOR"
|
||||
echo 'title<<PR_TITLE_EOF'
|
||||
printf '%s\n' "$PR_TITLE"
|
||||
echo 'PR_TITLE_EOF'
|
||||
echo 'body<<PR_BODY_EOF'
|
||||
printf '%s\n' "$PR_BODY"
|
||||
echo 'PR_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check EE access
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
id: ee
|
||||
env:
|
||||
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
run: |
|
||||
if [ -n "$EE_TOKEN" ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout EE repository
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: windmill-labs/windmill-ee-private
|
||||
path: ./windmill-ee-private
|
||||
ref: ${{ steps.ee.outputs.ee_repo_ref }}
|
||||
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Substitute EE code
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Codex CLI
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
run: npm install --global @openai/codex@0.128.0
|
||||
|
||||
- name: Configure Codex auth
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
run: |
|
||||
CODEX_HOME="$HOME/.codex"
|
||||
echo "CODEX_HOME=$CODEX_HOME" >> "$GITHUB_ENV"
|
||||
mkdir -p "$CODEX_HOME"
|
||||
chmod 700 "$CODEX_HOME"
|
||||
cat > "$CODEX_HOME/config.toml" <<'EOF'
|
||||
cli_auth_credentials_store = "file"
|
||||
EOF
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
|
||||
else
|
||||
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
|
||||
chmod 600 "$CODEX_HOME/auth.json"
|
||||
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
|
||||
fi
|
||||
|
||||
- name: Pre-fetch base and head refs for the PR
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
run: |
|
||||
git fetch --no-tags origin \
|
||||
"$PR_BASE_REF" \
|
||||
"+refs/pull/$PR_NUMBER/head"
|
||||
|
||||
- name: Fetch prior PR discussion
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
run: |
|
||||
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
|
||||
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
|
||||
> prior-comments.json || echo "[]" > prior-comments.json
|
||||
|
||||
- name: Write Codex review context
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
PR_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PR_TITLE: ${{ steps.pr.outputs.title }}
|
||||
PR_BODY: ${{ steps.pr.outputs.body }}
|
||||
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
mkdir -p .github/codex
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const lines = [
|
||||
`Repository: ${process.env.PR_REPOSITORY}`,
|
||||
`PR number: ${process.env.PR_NUMBER}`,
|
||||
];
|
||||
if (process.env.PR_AUTHOR) {
|
||||
lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
|
||||
}
|
||||
lines.push(
|
||||
`Base SHA: ${process.env.PR_BASE_SHA}`,
|
||||
`Head SHA: ${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'PR title:',
|
||||
process.env.PR_TITLE || '(empty)',
|
||||
'',
|
||||
'PR body:',
|
||||
process.env.PR_BODY || '(empty)',
|
||||
'',
|
||||
'Changed commits command:',
|
||||
`git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'Changed files command:',
|
||||
`git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'Full review diff command:',
|
||||
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
|
||||
);
|
||||
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
|
||||
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
|
||||
}
|
||||
if (fs.existsSync('prior-comments.json')) {
|
||||
try {
|
||||
const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
|
||||
if (Array.isArray(comments) && comments.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
'Prior PR discussion (most recent up to 20 comments):',
|
||||
'',
|
||||
'If you have already reviewed this PR (look for your own earlier "## Codex Review" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.',
|
||||
''
|
||||
);
|
||||
for (const c of comments) {
|
||||
lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', '');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Run Codex review
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
run: |
|
||||
cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md
|
||||
codex exec \
|
||||
-C "$GITHUB_WORKSPACE" \
|
||||
-m gpt-5.5 \
|
||||
-c 'model_reasoning_effort="xhigh"' \
|
||||
-s danger-full-access \
|
||||
-o codex-final-message.md \
|
||||
- < /tmp/codex-prompt.md
|
||||
|
||||
- name: Post Codex review comment
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = `${process.env.GITHUB_WORKSPACE}/codex-final-message.md`;
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('Codex did not produce a final message; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
const body = fs.readFileSync(path, 'utf8').trim();
|
||||
if (!body) {
|
||||
core.info('Codex final message was empty; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(process.env.PR_NUMBER),
|
||||
body,
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
name: Pi Auto Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ready_for_review, opened, synchronize]
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: number
|
||||
extra_prompt:
|
||||
description: 'Additional reviewer instructions appended to the standard review prompt'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
triggered_by:
|
||||
description: 'GitHub username that triggered this review (for audit only)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
DEEPSEEK_API_KEY:
|
||||
required: false
|
||||
WINDMILL_EE_PRIVATE_ACCESS:
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: pi-review-${{ inputs.pr_number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
with:
|
||||
commenter: ${{ github.event.pull_request.user.login }}
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
pi-review:
|
||||
needs: check-membership
|
||||
runs-on: ubicloud-standard-2
|
||||
timeout-minutes: 30
|
||||
if: |
|
||||
always() &&
|
||||
(
|
||||
needs.check-membership.result == 'skipped' ||
|
||||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
|
||||
) &&
|
||||
(
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check Pi configuration
|
||||
id: pi_config
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$DEEPSEEK_API_KEY" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "DEEPSEEK_API_KEY is not configured; skipping Pi review."
|
||||
fi
|
||||
|
||||
- name: Resolve PR metadata
|
||||
if: steps.pi_config.outputs.enabled == 'true'
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
EVENT_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_BODY: ${{ github.event.pull_request.body }}
|
||||
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
|
||||
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
|
||||
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
|
||||
BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
|
||||
BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
|
||||
HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid')
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
||||
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
|
||||
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
|
||||
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
|
||||
else
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
BASE_REF="$EVENT_BASE_REF"
|
||||
BASE_SHA="$EVENT_BASE_SHA"
|
||||
HEAD_SHA="$EVENT_HEAD_SHA"
|
||||
PR_TITLE="$EVENT_TITLE"
|
||||
PR_BODY="$EVENT_BODY"
|
||||
IS_FORK="$EVENT_FORK"
|
||||
PR_AUTHOR="$EVENT_AUTHOR"
|
||||
fi
|
||||
if [ "$IS_FORK" = "true" ]; then
|
||||
echo "Skipping Pi review for fork PR."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
{
|
||||
echo "skip=false"
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "base_ref=$BASE_REF"
|
||||
echo "base_sha=$BASE_SHA"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "pr_author=$PR_AUTHOR"
|
||||
echo 'title<<PR_TITLE_EOF'
|
||||
printf '%s\n' "$PR_TITLE"
|
||||
echo 'PR_TITLE_EOF'
|
||||
echo 'body<<PR_BODY_EOF'
|
||||
printf '%s\n' "$PR_BODY"
|
||||
echo 'PR_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check EE access
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
id: ee
|
||||
env:
|
||||
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
run: |
|
||||
if [ -n "$EE_TOKEN" ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout EE repository
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: windmill-labs/windmill-ee-private
|
||||
path: ./windmill-ee-private
|
||||
ref: ${{ steps.ee.outputs.ee_repo_ref }}
|
||||
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Substitute EE code
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Pi CLI
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
run: npm install --global @mariozechner/pi-coding-agent
|
||||
|
||||
- name: Pre-fetch base and head refs for the PR
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
run: |
|
||||
git fetch --no-tags origin \
|
||||
"$PR_BASE_REF" \
|
||||
"+refs/pull/$PR_NUMBER/head"
|
||||
|
||||
- name: Fetch prior PR discussion
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
run: |
|
||||
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
|
||||
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
|
||||
> prior-comments.json || echo "[]" > prior-comments.json
|
||||
|
||||
- name: Write Pi review context
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
PR_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PR_TITLE: ${{ steps.pr.outputs.title }}
|
||||
PR_BODY: ${{ steps.pr.outputs.body }}
|
||||
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
mkdir -p .github/pi
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const lines = [
|
||||
`Repository: ${process.env.PR_REPOSITORY}`,
|
||||
`PR number: ${process.env.PR_NUMBER}`,
|
||||
];
|
||||
if (process.env.PR_AUTHOR) {
|
||||
lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
|
||||
}
|
||||
lines.push(
|
||||
`Base SHA: ${process.env.PR_BASE_SHA}`,
|
||||
`Head SHA: ${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'PR title:',
|
||||
process.env.PR_TITLE || '(empty)',
|
||||
'',
|
||||
'PR body:',
|
||||
process.env.PR_BODY || '(empty)',
|
||||
'',
|
||||
'Changed commits command:',
|
||||
`git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'Changed files command:',
|
||||
`git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'Full review diff command:',
|
||||
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
|
||||
);
|
||||
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
|
||||
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
|
||||
}
|
||||
if (fs.existsSync('prior-comments.json')) {
|
||||
try {
|
||||
const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
|
||||
if (Array.isArray(comments) && comments.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
'Prior PR discussion (most recent up to 20 comments):',
|
||||
'',
|
||||
'If you have already reviewed this PR (look for your own earlier "## Pi Review (DeepSeek V4)" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.',
|
||||
''
|
||||
);
|
||||
for (const c of comments) {
|
||||
lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', '');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
fs.writeFileSync('.github/pi/pr-review-context.md', `${lines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Run Pi review
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
PI_SKIP_VERSION_CHECK: '1'
|
||||
run: |
|
||||
set -o pipefail
|
||||
cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
|
||||
pi -p \
|
||||
--provider deepseek \
|
||||
--model deepseek-v4-pro \
|
||||
--tools read,grep,find,ls,bash \
|
||||
--mode json \
|
||||
< /tmp/pi-prompt.md \
|
||||
| tee pi-events.jsonl \
|
||||
| jq -rc --unbuffered '
|
||||
if .type == "agent_start" then "🤖 pi agent started"
|
||||
elif .type == "turn_start" then "── turn ──"
|
||||
elif .type == "message_end" then
|
||||
"[\(.message.role)] " + (
|
||||
(.message.content // [])
|
||||
| map(
|
||||
if .type == "text" then "text(\(.text | length)c)"
|
||||
elif .type == "tool_use" then "🔧 \(.name) \(.input | @json | .[:160])"
|
||||
elif .type == "tool_result" then "✅ result"
|
||||
else .type
|
||||
end
|
||||
)
|
||||
| join(" | ")
|
||||
)
|
||||
elif .type == "turn_end" then "── turn done (\((.toolResults // []) | length) tool result(s)) ──"
|
||||
elif .type == "agent_end" then "🏁 pi agent done"
|
||||
else empty
|
||||
end
|
||||
'
|
||||
|
||||
jq -r '
|
||||
select(.type == "agent_end")
|
||||
| .messages
|
||||
| map(select(.role == "assistant"))
|
||||
| last
|
||||
| (.content[]? | select(.type == "text") | .text)
|
||||
' pi-events.jsonl > pi-final-message.md
|
||||
|
||||
- name: Post Pi review comment
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = `${process.env.GITHUB_WORKSPACE}/pi-final-message.md`;
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('Pi did not produce a final message; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
const body = fs.readFileSync(path, 'utf8').trim();
|
||||
if (!body) {
|
||||
core.info('Pi final message was empty; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(process.env.PR_NUMBER),
|
||||
body,
|
||||
});
|
||||
@@ -3,150 +3,25 @@ name: Claude Auto Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [ready_for_review, opened]
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: number
|
||||
extra_prompt:
|
||||
description: 'Additional reviewer instructions appended to the standard review prompt'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
triggered_by:
|
||||
description: 'GitHub username that triggered this review (for audit only)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
required: true
|
||||
WINDMILL_EE_PRIVATE_ACCESS:
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: claude-review-${{ inputs.pr_number || github.event.pull_request.number }}
|
||||
group: claude-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
with:
|
||||
commenter: ${{ github.event.pull_request.user.login }}
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
auto-review:
|
||||
needs: check-membership
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
always() &&
|
||||
(
|
||||
needs.check-membership.result == 'skipped' ||
|
||||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
|
||||
) &&
|
||||
(
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true)
|
||||
)
|
||||
if: github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check EE access
|
||||
id: ee
|
||||
env:
|
||||
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
run: |
|
||||
if [ -n "$EE_TOKEN" ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout EE repository
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: windmill-labs/windmill-ee-private
|
||||
path: ./windmill-ee-private
|
||||
ref: ${{ steps.ee.outputs.ee_repo_ref }}
|
||||
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Substitute EE code
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
||||
|
||||
- name: Resolve PR number
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
PR_NUMBER="$INPUT_PR_NUMBER"
|
||||
PR_AUTHOR=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.user.login')
|
||||
else
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
PR_AUTHOR="$EVENT_PR_AUTHOR"
|
||||
fi
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch prior PR discussion
|
||||
id: prior
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.resolve.outputs.pr_number }}
|
||||
run: |
|
||||
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
|
||||
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
|
||||
> prior-comments.json || echo "[]" > prior-comments.json
|
||||
jq -r '
|
||||
if length == 0 then ""
|
||||
else
|
||||
"## Prior PR discussion (most recent up to 20 comments)\n\nIf you have already reviewed this PR (look for your own earlier comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.\n\n" +
|
||||
(map("### @\(.user) (\(.created_at))\n\n\(.body)") | join("\n\n---\n\n"))
|
||||
end
|
||||
' prior-comments.json > prior-comments.md
|
||||
|
||||
- name: Read review prompt
|
||||
id: review-prompt
|
||||
env:
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
{
|
||||
echo 'REVIEW_PROMPT<<EOF'
|
||||
cat REVIEW.md
|
||||
echo ''
|
||||
cat .claude/review-prompt.md
|
||||
if [ -n "$EXTRA_PROMPT" ]; then
|
||||
echo ''
|
||||
echo '## Additional reviewer instructions'
|
||||
echo ''
|
||||
printf '%s\n' "$EXTRA_PROMPT"
|
||||
fi
|
||||
if [ -s prior-comments.md ]; then
|
||||
echo ''
|
||||
cat prior-comments.md
|
||||
fi
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Automatic PR Review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
@@ -154,10 +29,20 @@ jobs:
|
||||
track_progress: true
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ steps.resolve.outputs.pr_number }}
|
||||
PR AUTHOR: ${{ steps.resolve.outputs.pr_author }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
${{ env.REVIEW_PROMPT }}
|
||||
Please review this pull request and provide comprehensive feedback.
|
||||
|
||||
Focus on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security implications
|
||||
|
||||
Provide detailed feedback using inline comments for specific issues.
|
||||
Use top-level comments for general observations or praise.
|
||||
|
||||
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 he 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.
|
||||
claude_args: |
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
--model opus
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
name: PR Review Commands
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
parse:
|
||||
if: github.event.issue.pull_request != null
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
command: ${{ steps.parse.outputs.command }}
|
||||
extra_prompt: ${{ steps.parse.outputs.extra_prompt }}
|
||||
steps:
|
||||
- name: Parse command from comment
|
||||
id: parse
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
FIRST_LINE=$(printf '%s' "$BODY" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
|
||||
FIRST_WORD=${FIRST_LINE%% *}
|
||||
case "$FIRST_WORD" in
|
||||
/review|/codex|/pi|/claude)
|
||||
COMMAND="${FIRST_WORD#/}"
|
||||
REMAINDER_FIRST_LINE=${FIRST_LINE#"$FIRST_WORD"}
|
||||
REMAINDER_FIRST_LINE=${REMAINDER_FIRST_LINE# }
|
||||
REST=$(printf '%s' "$BODY" | tail -n +2)
|
||||
{
|
||||
echo "command=$COMMAND"
|
||||
echo 'extra_prompt<<EXTRA_EOF'
|
||||
if [ -n "$REMAINDER_FIRST_LINE" ]; then
|
||||
printf '%s\n' "$REMAINDER_FIRST_LINE"
|
||||
fi
|
||||
if [ -n "$REST" ]; then
|
||||
printf '%s\n' "$REST"
|
||||
fi
|
||||
echo 'EXTRA_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "command=" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
check-membership:
|
||||
needs: parse
|
||||
if: needs.parse.outputs.command != ''
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
acknowledge:
|
||||
needs: [parse, check-membership]
|
||||
if: needs.parse.outputs.command != '' && needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: React to comment with eyes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
run: |
|
||||
gh api -X POST \
|
||||
"/repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
|
||||
-f content=eyes >/dev/null
|
||||
|
||||
claude:
|
||||
needs: [parse, check-membership]
|
||||
if: |
|
||||
needs.check-membership.outputs.is_member == 'true' &&
|
||||
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'claude')
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/pr-ready-review.yml
|
||||
with:
|
||||
pr_number: ${{ github.event.issue.number }}
|
||||
extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
|
||||
triggered_by: ${{ github.event.comment.user.login }}
|
||||
secrets:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
|
||||
codex:
|
||||
needs: [parse, check-membership]
|
||||
if: |
|
||||
needs.check-membership.outputs.is_member == 'true' &&
|
||||
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'codex')
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/codex-pr-review.yml
|
||||
with:
|
||||
pr_number: ${{ github.event.issue.number }}
|
||||
extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
|
||||
triggered_by: ${{ github.event.comment.user.login }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
|
||||
pi:
|
||||
needs: [parse, check-membership]
|
||||
if: |
|
||||
needs.check-membership.outputs.is_member == 'true' &&
|
||||
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'pi')
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/pi-pr-review.yml
|
||||
with:
|
||||
pr_number: ${{ github.event.issue.number }}
|
||||
extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
|
||||
triggered_by: ${{ github.event.comment.user.login }}
|
||||
secrets:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
@@ -1,84 +0,0 @@
|
||||
name: Publish CLI docs repo
|
||||
|
||||
# Regenerates the windmill-cli-docs repo (consumed by context7) from the
|
||||
# canonical sources in this repo on every Windmill release.
|
||||
#
|
||||
# Required secret:
|
||||
# CLI_DOCS_DEPLOY_KEY — ed25519 private key whose public half is registered
|
||||
# as a write-access deploy key on
|
||||
# windmill-labs/windmill-cli-docs.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize pushes to windmill-cli-docs so two release tags landing close
|
||||
# together (e.g. a release-please bump + a hotfix) can't race to force-push
|
||||
# the docs repo.
|
||||
concurrency:
|
||||
group: publish-cli-docs
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout windmill (source of truth)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: windmill
|
||||
|
||||
- name: Checkout windmill-cli-docs (publish target)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: windmill-labs/windmill-cli-docs
|
||||
path: windmill-cli-docs
|
||||
ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Regenerate docs
|
||||
run: |
|
||||
python3 windmill/system_prompts/generate.py \
|
||||
--context7-dir "$GITHUB_WORKSPACE/windmill-cli-docs"
|
||||
|
||||
- name: Commit and push if changed
|
||||
working-directory: windmill-cli-docs
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
git config user.name "windmill-bot"
|
||||
git config user.email "bot@windmill.dev"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No doc changes for ${REF_NAME}."
|
||||
committed=false
|
||||
else
|
||||
committed=true
|
||||
if [ "${REF_TYPE}" = "tag" ]; then
|
||||
git commit -m "chore: sync from windmill ${REF_NAME}"
|
||||
else
|
||||
git commit -m "chore: sync from windmill (manual dispatch from ${REF_NAME})"
|
||||
fi
|
||||
git push origin HEAD
|
||||
fi
|
||||
# Always mirror the version tag on tag pushes, even when content
|
||||
# didn't change — downstream consumers tie snapshots to releases by
|
||||
# tag, and skipping it would leave the docs repo without a tag for
|
||||
# the new Windmill release.
|
||||
# workflow_dispatch from a non-tag ref skips this so we don't
|
||||
# create a junk tag named after a branch.
|
||||
if [ "${REF_TYPE}" = "tag" ]; then
|
||||
git tag -f "${REF_NAME}"
|
||||
git push origin "${REF_NAME}" --force
|
||||
echo "Mirrored tag ${REF_NAME} to windmill-cli-docs (content changed: ${committed})."
|
||||
fi
|
||||
@@ -18,7 +18,10 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- uses: cachix/install-nix-action@v20
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- name: Check rust client builds
|
||||
run: cd rust-client && nix develop ../ --command ./dev.nu --check
|
||||
timeout-minutes: 16
|
||||
@@ -10,7 +10,10 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- uses: cachix/install-nix-action@v20
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- run: cd rust-client && nix develop ../ --command ./dev.nu --check --publish
|
||||
env:
|
||||
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Spawn Ephemeral Backend
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number"
|
||||
required: true
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/spawnbackend')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/spawnbackend'))
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
spawn-backend:
|
||||
needs: check-membership
|
||||
# Only run on PR comments that contain /spawn-backend, or manual dispatch
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.issue.pull_request && needs.check-membership.outputs.is_member == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr-details
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
? context.payload.inputs.pr_number
|
||||
: context.issue.number;
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
// Get branch name and format it for Cloudflare Pages
|
||||
// Replace '/' with '-' for the URL
|
||||
const branchName = pr.data.head.ref;
|
||||
const formattedBranch = branchName.replace(/\//g, '-');
|
||||
const cfFrontendUrl = `https://${formattedBranch}.windmill.pages.dev`;
|
||||
|
||||
core.setOutput('commit_hash', pr.data.head.sha);
|
||||
core.setOutput('pr_number', prNumber);
|
||||
core.setOutput('branch_name', branchName);
|
||||
core.setOutput('cf_frontend_url', cfFrontendUrl);
|
||||
|
||||
- name: Check manager URL
|
||||
id: check-manager-url
|
||||
run: |
|
||||
if [ -z "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}" ]; then
|
||||
echo "manager_url_set=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "manager_url_set=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post error comment if manager not running
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'false'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
? Number(context.payload.inputs.pr_number)
|
||||
: context.issue.number;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: `❌ Manager URL not set (did you start the ephemeral backend manager?)\n\nThe ephemeral backend manager needs to be running to spawn backends. Please start the manager first.`
|
||||
});
|
||||
|
||||
- name: Fail if manager not running
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'false'
|
||||
run: |
|
||||
echo "Error: EPHEMERAL_BACKEND_QUEUE_URL secret is not set"
|
||||
exit 1
|
||||
|
||||
- name: Trigger Windmill flow
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'true'
|
||||
id: trigger-flow
|
||||
run: |
|
||||
JOB_UUID=$(curl -s -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run/f/f/all/run_ephemeral_backend" \
|
||||
-H "Authorization: Bearer ${{ secrets.WINDMILL_RUN_FLOW_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"manager_url": "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}",
|
||||
"commit_hash": "${{ steps.pr-details.outputs.commit_hash }}",
|
||||
"pr_number": ${{ steps.pr-details.outputs.pr_number }},
|
||||
"cf_frontend_url": "${{ steps.pr-details.outputs.cf_frontend_url }}"
|
||||
}' | tr -d '"')
|
||||
|
||||
echo "Job UUID: $JOB_UUID"
|
||||
echo "job_uuid=$JOB_UUID" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post comment with job link
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const jobUuid = '${{ steps.trigger-flow.outputs.job_uuid }}';
|
||||
const appUrl = `https://app.windmill.dev/public/windmill-labs/a106bad0256c1dfa7a4f9279c42b1a4b#${jobUuid}`;
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
? Number(context.payload.inputs.pr_number)
|
||||
: context.issue.number;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: `🚀 Spawning new ephemeral backend!\n\n${appUrl}`
|
||||
});
|
||||
@@ -20,17 +20,12 @@ rust-client/Cargo.toml
|
||||
|
||||
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Symlinked cache directories (for git worktrees)
|
||||
backend/target
|
||||
frontend/node_modules
|
||||
typescript-client/node_modules
|
||||
ai_evals/node_modules
|
||||
ai_evals/results/
|
||||
frontend/.svelte-kit
|
||||
backend/chrome_profiler.json
|
||||
.fast-check/
|
||||
__pycache__/
|
||||
.playwright-mcp/
|
||||
.codex
|
||||
@@ -3,16 +3,6 @@
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless"]
|
||||
},
|
||||
"playwright-headed": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-56
@@ -43,11 +43,10 @@ profiles:
|
||||
- Pane 0: this pane (claude agent)
|
||||
- Pane 1: backend (cargo watch -x run)
|
||||
- Pane 2: frontend (npm run dev)
|
||||
To check logs, use: \`tmux capture-pane -t $(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_name}').1 -p -S -50\` (backend) or \`tmux capture-pane -t $(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_name}').2 -p -S -50\` (frontend).
|
||||
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).
|
||||
For this window specifically, backend is running on: ${BACKEND_PORT} and frontend is running on: ${FRONTEND_PORT}.
|
||||
To connect to the database, use this connection string: ${DATABASE_URL}
|
||||
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check.
|
||||
For UI verification, use the Playwright MCP (`mcp__playwright__*`) — the `playwright` server is headless and works without a display. Navigate to http://localhost:${FRONTEND_PORT}, log in as admin@windmill.dev / changeme.
|
||||
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
|
||||
panes:
|
||||
- id: agent
|
||||
@@ -56,13 +55,11 @@ profiles:
|
||||
- id: backend
|
||||
kind: command
|
||||
split: right
|
||||
workingDir: backend
|
||||
command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/backend" && cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
|
||||
- id: frontend
|
||||
kind: command
|
||||
split: bottom
|
||||
workingDir: frontend
|
||||
command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
|
||||
|
||||
frontendOnly:
|
||||
runtime: host
|
||||
@@ -73,11 +70,10 @@ profiles:
|
||||
Pane layout (current window):
|
||||
- Pane 0: this pane (claude agent)
|
||||
- Pane 1: frontend (npm run dev)
|
||||
To check logs, use: \`tmux capture-pane -t $(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_name}').1 -p -S -50\` (frontend).
|
||||
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (frontend).
|
||||
On this window specifically, frontend is running on: ${FRONTEND_PORT}.
|
||||
To connect to the database, use this connection string: ${DATABASE_URL}
|
||||
Because we are running frontend with npm run dev, to verify your changes, just check the logs in the frontend pane. No need for npm run build.
|
||||
For UI verification, use the Playwright MCP (`mcp__playwright__*`) — the `playwright` server is headless and works without a display. Navigate to http://localhost:${FRONTEND_PORT}, log in as admin@windmill.dev / changeme.
|
||||
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
|
||||
panes:
|
||||
- id: agent
|
||||
@@ -86,8 +82,7 @@ profiles:
|
||||
- id: frontend
|
||||
kind: command
|
||||
split: right
|
||||
workingDir: frontend
|
||||
command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
|
||||
|
||||
agentOnly:
|
||||
runtime: host
|
||||
@@ -102,55 +97,9 @@ profiles:
|
||||
|
||||
integrations:
|
||||
github:
|
||||
autoRemoveOnMerge: true
|
||||
linkedRepos:
|
||||
- repo: windmill-labs/windmill-ee-private
|
||||
alias: ee-private
|
||||
dir: ../windmill-ee-private__worktrees
|
||||
linear:
|
||||
enabled: true
|
||||
autoCreateWorktrees: true
|
||||
watchTeams: [WIN,GIT]
|
||||
|
||||
oneshot:
|
||||
systemPrompt: |
|
||||
You are running in webmux ONESHOT mode.
|
||||
|
||||
# No interactive user
|
||||
There is NO interactive user — nobody is watching the chat or will respond
|
||||
to questions, approvals, or status checks. Any message asking the user to
|
||||
review, approve, confirm, take a look, or "let you know" is wasted output:
|
||||
it will not be answered.
|
||||
|
||||
# Your job
|
||||
Take the task to its real conclusion without pausing:
|
||||
1. Make the change.
|
||||
2. Validate it (run the relevant tests, typecheck, build, or quick
|
||||
manual check). For UI changes, drive the running frontend with
|
||||
the Playwright MCP (`mcp__playwright__*`, headless) and confirm
|
||||
the change works end-to-end before moving on.
|
||||
3. Commit.
|
||||
4. Push.
|
||||
5. Open a pull request.
|
||||
Only then are you done.
|
||||
|
||||
# Decisions
|
||||
When something is ambiguous, pick the most reasonable default and proceed.
|
||||
When you would normally ask "should I X or Y?", just pick one and continue
|
||||
— note the choice in the PR description if it matters.
|
||||
|
||||
# PR readiness
|
||||
Default to opening the PR as a draft. If you are highly confident in the
|
||||
change — the scope is small and well-understood, validation passed
|
||||
cleanly, and you would not change anything if a reviewer pushed back —
|
||||
open the PR as ready-for-review directly (omit `--draft` when invoking
|
||||
`gh pr create`, or call `gh pr ready <number>` after creation). Err on
|
||||
the side of draft when validation was partial, the change touches
|
||||
public APIs or shared infrastructure, or you made a non-obvious judgment
|
||||
call.
|
||||
|
||||
# Ending your turn
|
||||
Never end your turn with a question, a suggestion to "take a look", or a
|
||||
request for approval. Stop only when the PR is open, or when you hit a
|
||||
technical error you cannot recover from yourself (in which case clearly
|
||||
state the blocker).
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Windmill
|
||||
|
||||
Open-source platform for internal tools, workflows, API integrations, background jobs, and UIs. Rust backend + Svelte 5 frontend.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Understand**: Before coding, explore the codebase (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code, `Grep` to find usages. Read `docs/` for domain context.
|
||||
2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages
|
||||
3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
|
||||
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
|
||||
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
|
||||
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi.
|
||||
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
|
||||
|
||||
## Dev Environment
|
||||
|
||||
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
|
||||
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/` (port 3000+)
|
||||
- **DB**: `psql postgres://postgres:changeme@localhost:5432/windmill`
|
||||
- **Login**: `admin@windmill.dev` / `changeme`
|
||||
- **Instance settings**: navigate to `/#superadmin-settings`
|
||||
- **Migrations**: use `cargo sqlx migrate add -r <name>` from `backend/` to create new migrations (never generate timestamps manually)
|
||||
|
||||
## Verifying Frontend Changes
|
||||
|
||||
After modifying frontend code, drive the running dev server with the **Playwright MCP** to verify the change in a real browser — don't claim a UI change works without exercising it.
|
||||
|
||||
Two MCP servers are registered in `.mcp.json`:
|
||||
- `playwright` — headless Chromium, default for devboxes (no display required)
|
||||
- `playwright-headed` — windowed Chromium, when a display is available
|
||||
|
||||
**One-time setup:** run `npx playwright install chromium` to download the browser binary (Playwright won't fetch it automatically on first use).
|
||||
|
||||
Typical flow:
|
||||
1. Ensure backend (`cargo run`) and frontend (`REMOTE=http://localhost:8000 npm run dev`) are running
|
||||
2. `mcp__playwright__browser_navigate` to the relevant page (login at `admin@windmill.dev` / `changeme`)
|
||||
3. `mcp__playwright__browser_snapshot` to inspect the accessibility tree (preferred over screenshots for reading the DOM)
|
||||
4. `mcp__playwright__browser_click` / `browser_fill_form` / `browser_type` to interact
|
||||
5. `mcp__playwright__browser_take_screenshot` for visual confirmation
|
||||
6. `mcp__playwright__browser_console_messages` / `browser_network_requests` to surface errors
|
||||
|
||||
If you cannot exercise a UI change (no dev server, etc.), say so explicitly rather than claiming success.
|
||||
|
||||
## Banned Patterns
|
||||
|
||||
### `$bindable(default_value)` on optional props
|
||||
|
||||
Using `$bindable(default_value)` on props that can be `undefined` is **banned**. This pattern causes subtle bugs because the default value masks the `undefined` state.
|
||||
|
||||
**Bad:**
|
||||
|
||||
```svelte
|
||||
let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props()
|
||||
```
|
||||
|
||||
**Correct alternatives:**
|
||||
|
||||
1. **Use `$derived` with nullish coalescing** — handle the potential `undefined` at the usage site:
|
||||
|
||||
```svelte
|
||||
let { my_prop = $bindable() }: { my_prop?: string } = $props()
|
||||
let effective_value = $derived(my_prop ?? default_value)
|
||||
```
|
||||
|
||||
2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value.
|
||||
|
||||
## Code Navigation
|
||||
|
||||
`wm-ts-nav` is an AST-aware code navigator. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries.
|
||||
|
||||
**MUST use `outline` before `Read`** on unfamiliar files — a 500-line file costs ~500 lines of context, while `outline` costs ~20. Then **MUST use `body "X"`** instead of reading a full file to see one function/struct. Use `Read` with offset/limit only when you need surrounding context that `body` doesn't capture.
|
||||
- `refs "X" --caller` instead of reading files to find which function contains each reference
|
||||
- `callers "X"` / `callees "X"` for call-graph questions
|
||||
|
||||
EE files (`*_ee.rs`, `*_ee.ts`, `*_ee.svelte`) are indexed — you can `outline`, `def`, `body`, `refs` etc. on them just like regular files.
|
||||
|
||||
```bash
|
||||
NAV="sh wm-ts-nav/nav"
|
||||
# Use --root backend for Rust, --root frontend/src for TS/Svelte
|
||||
$NAV --root backend outline backend/path/to/file.rs # file structure
|
||||
$NAV --root backend def "ServiceName" # find definition
|
||||
$NAV --root backend body "decrypt_oauth_data" # extract source code
|
||||
$NAV --root backend search "%" --parent ServiceName # methods on a type
|
||||
$NAV --root backend search "Trigger" --kind struct # find by kind
|
||||
$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller
|
||||
$NAV --root backend callers "X" # who calls X?
|
||||
$NAV --root backend callees "X" # what does X call?
|
||||
```
|
||||
|
||||
**Limitations** — syntax-level analysis, no type inference. Use **Grep** instead when completeness matters (finding all usages, exhaustiveness checks):
|
||||
- `refs`/`callers`/`callees` can't follow re-exports, glob imports, or different import paths to the same symbol
|
||||
- Trait impls, macro-generated symbols (`sqlx::FromRow`), and namespace member access (`ns.X`) are invisible
|
||||
- `callees` shows all identifiers in a function body, not just actual calls
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics
|
||||
- Search for existing code to reuse before writing new code
|
||||
- Follow established patterns in the codebase
|
||||
- Keep changes focused — don't refactor beyond what's asked
|
||||
-1151
File diff suppressed because it is too large
Load Diff
@@ -1 +1,86 @@
|
||||
@AGENTS.md
|
||||
# Windmill
|
||||
|
||||
Open-source platform for internal tools, workflows, API integrations, background jobs, and UIs. Rust backend + Svelte 5 frontend.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Understand**: Before coding, explore the codebase (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code, `Grep` to find usages. Read `docs/` for domain context.
|
||||
2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages
|
||||
3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
|
||||
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
|
||||
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
|
||||
- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance
|
||||
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
|
||||
## Dev Environment
|
||||
|
||||
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
|
||||
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/` (port 3000+)
|
||||
- **DB**: `psql postgres://postgres:changeme@localhost:5432/windmill`
|
||||
- **Login**: `admin@windmill.dev` / `changeme`
|
||||
- **Instance settings**: navigate to `/#superadmin-settings`
|
||||
|
||||
## Banned Patterns
|
||||
|
||||
### `$bindable(default_value)` on optional props
|
||||
|
||||
Using `$bindable(default_value)` on props that can be `undefined` is **banned**. This pattern causes subtle bugs because the default value masks the `undefined` state.
|
||||
|
||||
**Bad:**
|
||||
|
||||
```svelte
|
||||
let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props()
|
||||
```
|
||||
|
||||
**Correct alternatives:**
|
||||
|
||||
1. **Use `$derived` with nullish coalescing** — handle the potential `undefined` at the usage site:
|
||||
|
||||
```svelte
|
||||
let { my_prop = $bindable() }: { my_prop?: string } = $props()
|
||||
let effective_value = $derived(my_prop ?? default_value)
|
||||
```
|
||||
|
||||
2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value.
|
||||
|
||||
## Code Navigation
|
||||
|
||||
`wm-ts-nav` is an AST-aware code navigator. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries.
|
||||
|
||||
**MUST use `outline` before `Read`** on unfamiliar files — a 500-line file costs ~500 lines of context, while `outline` costs ~20. Then **MUST use `body "X"`** instead of reading a full file to see one function/struct. Use `Read` with offset/limit only when you need surrounding context that `body` doesn't capture.
|
||||
- `refs "X" --caller` instead of reading files to find which function contains each reference
|
||||
- `callers "X"` / `callees "X"` for call-graph questions
|
||||
|
||||
EE files (`*_ee.rs`, `*_ee.ts`, `*_ee.svelte`) are indexed — you can `outline`, `def`, `body`, `refs` etc. on them just like regular files.
|
||||
|
||||
```bash
|
||||
NAV="sh wm-ts-nav/nav"
|
||||
# Use --root backend for Rust, --root frontend/src for TS/Svelte
|
||||
$NAV --root backend outline backend/path/to/file.rs # file structure
|
||||
$NAV --root backend def "ServiceName" # find definition
|
||||
$NAV --root backend body "decrypt_oauth_data" # extract source code
|
||||
$NAV --root backend search "%" --parent ServiceName # methods on a type
|
||||
$NAV --root backend search "Trigger" --kind struct # find by kind
|
||||
$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller
|
||||
$NAV --root backend callers "X" # who calls X?
|
||||
$NAV --root backend callees "X" # what does X call?
|
||||
```
|
||||
|
||||
**Limitations** — syntax-level analysis, no type inference. Use **Grep** instead when completeness matters (finding all usages, exhaustiveness checks):
|
||||
- `refs`/`callers`/`callees` can't follow re-exports, glob imports, or different import paths to the same symbol
|
||||
- Trait impls, macro-generated symbols (`sqlx::FromRow`), and namespace member access (`ns.X`) are invisible
|
||||
- `callees` shows all identifiers in a function body, not just actual calls
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics
|
||||
- Search for existing code to reuse before writing new code
|
||||
- Follow established patterns in the codebase
|
||||
- Keep changes focused — don't refactor beyond what's asked
|
||||
|
||||
+1
-9
@@ -162,19 +162,11 @@ ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini gnupg lsb-release \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini \
|
||||
&& if echo "$features" | grep -q "ee"; then apt-get install -y --no-install-recommends libsasl2-modules-gssapi-mit krb5-user; fi \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
|
||||
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN if [ "$WITH_GIT" = "true" ]; then \
|
||||
apt-get update -y \
|
||||
&& apt-get install -y git \
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Pull request review — shared policy
|
||||
|
||||
You are reviewing a GitHub pull request for this repository. Apply this policy alongside your tool's output requirements.
|
||||
|
||||
## Read the project rules first
|
||||
|
||||
- Read `AGENTS.md` (repo root) and any `AGENTS.md` in directories touched by the diff before reviewing — they are the canonical contributor guide.
|
||||
- Quote the exact rule from `AGENTS.md` when flagging a violation.
|
||||
|
||||
## Verdict (first line of the review)
|
||||
|
||||
Start every review with a single verdict line, before any other section (the only thing that may appear above the verdict is the optional `cc @<PR_AUTHOR>` ping described in "Pinging the author" below). Pick exactly one:
|
||||
|
||||
- **Good to merge** — no blocking issues and no nits worth surfacing.
|
||||
- **Mergeable, but should ideally address nits: <short list>** — no blockers, but P2 findings that are worth a look. The list must name each nit briefly (e.g. "doc/code mismatch in `foo.rs`, half-finished `pub fn bar`").
|
||||
- **Should address issues before merging: <short list>** — at least one P0 or P1 finding. The list must name each blocking issue briefly (e.g. "missing auth check on new `/api/x` handler, SQL injection in `build_query`").
|
||||
|
||||
The names in the list must match findings detailed later in the review. If you list a nit or issue here, it must appear with full context in the body. Do not invent items that aren't in the body, and do not bury blockers in the body without surfacing them in the verdict.
|
||||
|
||||
## Pinging the author
|
||||
|
||||
If the prompt context provides a `PR AUTHOR` (GitHub login) and the verdict is NOT "Good to merge" (i.e. it is "Mergeable, but should ideally address nits: ..." or "Should address issues before merging: ..."), prepend a single line `cc @<PR_AUTHOR>` to the top-level review comment, above the verdict line. This pings the author so they get a notification that there are items to address. Skip the ping entirely when the verdict is "Good to merge" — there is nothing for the author to act on. Do not add the ping to inline comments; the top-level summary comment is the only place it belongs.
|
||||
|
||||
## Review policy
|
||||
|
||||
- Only report issues you are confident are real and introduced by this pull request.
|
||||
- Focus on bugs, security problems, performance, and clear `AGENTS.md` violations.
|
||||
- Do not report style nits, speculative concerns, pre-existing issues, or anything a normal linter / typechecker would obviously catch.
|
||||
- Self-validate each finding before posting: "is this definitely a real issue?" If uncertain, discard it.
|
||||
- Read additional files only when the diff is not enough to validate a finding.
|
||||
- Do not modify any files.
|
||||
|
||||
## Severity triage
|
||||
|
||||
Tag each finding with a severity. Always report P0 and P1. Report P2 only when the diff invites it (a new `pub fn`, a new module, a new exported component, a meaningful refactor).
|
||||
|
||||
- **P0** — RCE, auth bypass, data loss, secrets in code, SQL injection, path traversal, broken auth on a public surface.
|
||||
- **P1** — significant bug, missing auth/authorization check on a new public surface, blocking I/O on a likely async path, race condition, missing input validation on caller-controlled parameters, observable performance regression.
|
||||
- **P2** — wrong module placement, doc/code mismatch, half-finished public abstractions (`pub fn` + `#[allow(dead_code)]` + `TODO`), `AGENTS.md` style violations, naming that contradicts the function's behavior.
|
||||
|
||||
## Checklist for new public surfaces
|
||||
|
||||
For any new `pub fn` / `pub async fn` / exported Svelte component / exported prop introduced by this PR, verify:
|
||||
|
||||
- (a) auth/authorization expectations are documented in the doc comment OR enforced in the function body. A new `pub fn` that touches workspace data, secrets, files, or processes without an auth check or documented "caller MUST verify" contract is a P1.
|
||||
- (b) the function is placed in a module whose stated purpose matches what it does. Check the module-level doc comment (`//!`) — a config-file reader inside `external_ip.rs` is a P2.
|
||||
- (c) it is not half-finished. `pub fn` + `#[allow(dead_code)]` + a `TODO` is a smell that says the function should land together with its caller, not separately. Cite the relevant `AGENTS.md` rule.
|
||||
- (d) input validation defends against injection / traversal / overflow / NUL bytes at every parameter that may be caller-controlled.
|
||||
|
||||
## Test coverage assessment
|
||||
|
||||
End your review with a short "Test coverage" section calibrated to the layers actually changed by the diff. Skip categories the PR does not touch.
|
||||
|
||||
- **Backend** (Rust under `backend/`) — expect Rust unit tests for new logic. For new or modified API handlers, worker steps, queue/cron behavior, or DB access, also expect or note the absence of integration tests. Pure-refactor backend PRs don't need new tests if existing tests cover the surface.
|
||||
- **Frontend** (Svelte / TS under `frontend/`) — the codebase does not generally test Svelte components, so do not ask for component tests. Only flag missing tests for new pure-logic utilities (the kind of file that already has a sibling `*.test.ts`, e.g. `flowDiff`, `previousResults`, copilot logic).
|
||||
- **CI / workflows / docs / config-only** — no automated tests expected; say so explicitly so the reader knows you considered it.
|
||||
|
||||
Then state what manual verification, if any, is still needed before merge:
|
||||
|
||||
- Describe each manual scenario as a short paragraph (not a numbered list): what page / action / input, and what observable outcome confirms correctness.
|
||||
- If the diff has no in-app surface to exercise (purely backend internals, CI, docs, or refactor), say that plainly.
|
||||
|
||||
## Additional reviewer instructions
|
||||
|
||||
If the prompt or context includes an "Additional reviewer instructions" section, treat it as extra guidance from the human who triggered this review and follow it.
|
||||
|
||||
## Prior PR discussion
|
||||
|
||||
If the prompt or context includes a "Prior PR discussion" section, this PR has already received review activity. Look for your own previous comment, take it into account, focus on what changed in the latest commits, and do not repeat findings the human already pushed back on or addressed.
|
||||
@@ -1,2 +0,0 @@
|
||||
.env
|
||||
results/
|
||||
@@ -1,183 +0,0 @@
|
||||
# AI Evals Authoring Guide
|
||||
|
||||
This folder contains black-box benchmark cases for:
|
||||
|
||||
- `flow`
|
||||
- `app`
|
||||
- `script`
|
||||
- `cli`
|
||||
- `global`
|
||||
|
||||
The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape.
|
||||
|
||||
## Core rules
|
||||
|
||||
1. Write prompts like a real user request.
|
||||
2. Prefer behavior, inputs, constraints, and outcomes over internal implementation details.
|
||||
3. Keep deterministic validation narrow and hard.
|
||||
4. Put semantic expectations in `judgeChecklist`.
|
||||
5. Use `expected` fixtures only when exact structure really matters.
|
||||
|
||||
## Prompt writing
|
||||
|
||||
Prompts should sound like something a user would naturally ask.
|
||||
|
||||
Good:
|
||||
|
||||
- "Create a flow that routes support requests based on customer tier."
|
||||
- "Add a reset button that sets the counter back to 0."
|
||||
- "Create a flow that reuses the existing greeting script instead of duplicating the logic."
|
||||
|
||||
Bad:
|
||||
|
||||
- "Use `branchone` with 3 branches and a default branch."
|
||||
- "Create a `rawscript` step with this exact topology."
|
||||
- "This is a benchmark harness."
|
||||
|
||||
Do not write prompts as if the user knows Windmill internals unless the case is explicitly testing a power-user workflow.
|
||||
|
||||
## Flow-specific rules
|
||||
|
||||
This is the main principle you asked for:
|
||||
|
||||
- flow prompts should read like requests from a user who does not know the product internals
|
||||
- the user should ask for behavior, not for `branchone`, `branchall`, `rawscript`, `preprocessor_module`, `failure_module`, exact graph topology, or other internal constructs
|
||||
|
||||
That means:
|
||||
|
||||
- creation cases should describe the business behavior and expected result
|
||||
- modification cases may mention existing step names, because the user can see the current flow
|
||||
- only mention special Windmill constructs when the case is explicitly about those constructs
|
||||
|
||||
Examples:
|
||||
|
||||
- acceptable creation prompt:
|
||||
"Create a purchase approval flow that pauses for approval and asks the approver for a comment."
|
||||
- avoid:
|
||||
"Create a suspend step with one required event and a resume form."
|
||||
|
||||
For flow cases, do not fail a case just because the model chose a different valid topology.
|
||||
|
||||
## App-specific rules
|
||||
|
||||
App prompts should focus on user-visible behavior:
|
||||
|
||||
- what the UI should let the user do
|
||||
- what should persist
|
||||
- what backend behavior is needed
|
||||
|
||||
Avoid prompting in terms of React structure, component names, or implementation unless the case is specifically about editing an existing app.
|
||||
|
||||
## CLI-specific rules
|
||||
|
||||
CLI prompts can be more explicit about paths and file names because real CLI users often do specify them.
|
||||
|
||||
Still, avoid benchmark phrasing. The prompt should read like a repo task, not a harness instruction.
|
||||
|
||||
When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior.
|
||||
|
||||
## Global-specific rules
|
||||
|
||||
Global prompts should exercise workspace-level drafting behavior:
|
||||
|
||||
- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant
|
||||
- writing AI drafts rather than saving or deploying by default
|
||||
- producing coherent multi-artifact changes when the request crosses artifact boundaries
|
||||
|
||||
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
|
||||
|
||||
## Deterministic validation
|
||||
|
||||
Use deterministic validation only for hard failures such as:
|
||||
|
||||
- missing required files
|
||||
- unexpected extra files when the prompt says not to create them
|
||||
- syntax errors
|
||||
- unresolved flow refs
|
||||
- missing required special modules or suspend config
|
||||
- obvious artifact corruption
|
||||
|
||||
Do not use deterministic validation to enforce one preferred implementation for broad creation tasks.
|
||||
|
||||
Examples of bad hard checks:
|
||||
|
||||
- exact step topology for a creation flow
|
||||
- exact branch structure when the prompt only asked for routing behavior
|
||||
- exact input shape when multiple reasonable shapes are acceptable
|
||||
|
||||
## Judge checklist
|
||||
|
||||
Every non-trivial case should have a `judgeChecklist`.
|
||||
|
||||
The checklist should capture:
|
||||
|
||||
- the user-visible behavior that must be present
|
||||
- important constraints
|
||||
- key completion criteria
|
||||
|
||||
The checklist should not duplicate low-level implementation details unless they are truly required by the task.
|
||||
|
||||
Good checklist items:
|
||||
|
||||
- "the flow calculates the order total with 8% tax"
|
||||
- "the app persists recipes appropriately for a raw Windmill app"
|
||||
- "the flow reuses the existing workspace script instead of rewriting the logic"
|
||||
|
||||
Bad checklist items:
|
||||
|
||||
- "uses `branchone`"
|
||||
- "contains a `rawscript` node"
|
||||
|
||||
## When to use `expected`
|
||||
|
||||
Use `expected` fixtures when the case is structure-sensitive, for example:
|
||||
|
||||
- exact file creation
|
||||
- exact script content
|
||||
- modification cases where a specific file must change in a specific way
|
||||
- cases where preserving an existing structure is part of the requirement
|
||||
|
||||
Do not use a full `expected` artifact as the semantic oracle for broad creation tasks when multiple valid outputs should pass.
|
||||
|
||||
## When to use `initial`
|
||||
|
||||
Use `initial` when the benchmark is about:
|
||||
|
||||
- editing an existing artifact
|
||||
- reusing existing workspace assets
|
||||
- preserving existing behavior while adding a change
|
||||
|
||||
If the case is greenfield, prefer no `initial`.
|
||||
|
||||
## Case design ladder
|
||||
|
||||
Prefer suites that get gradually harder:
|
||||
|
||||
1. trivial create case
|
||||
2. realistic create case
|
||||
3. reuse-existing-assets case
|
||||
4. modification case
|
||||
5. refactor case
|
||||
6. edge-case or niche product behavior
|
||||
|
||||
The last cases in a suite should cover unusual or product-specific behavior.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
Avoid these:
|
||||
|
||||
- benchmark framing in prompts
|
||||
- over-specified internal topology for creation tasks
|
||||
- judge checklists that just restate implementation details
|
||||
- deterministic validation that encodes one preferred solution
|
||||
- fixtures that are so minimal or brittle that they create false negatives
|
||||
|
||||
## Before adding a case
|
||||
|
||||
Ask:
|
||||
|
||||
1. Would a real user plausibly write this prompt?
|
||||
2. If the model solves it in a different valid way, would the case still pass?
|
||||
3. Are the hard deterministic checks only catching objectively broken output?
|
||||
4. Does the `judgeChecklist` describe the real success criteria?
|
||||
5. If this case fails, will the reason be understandable from the saved artifacts?
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -1,227 +0,0 @@
|
||||
# AI Evals
|
||||
|
||||
Small benchmark runner for the Windmill AI generation modes:
|
||||
|
||||
- `cli`
|
||||
- `flow`
|
||||
- `script`
|
||||
- `app`
|
||||
- `global`
|
||||
|
||||
The benchmark always tests the current production prompts, tools, and guidance in this checkout.
|
||||
|
||||
Each attempt runs:
|
||||
|
||||
1. the real production path
|
||||
2. deterministic validation
|
||||
3. LLM judging
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd ai_evals
|
||||
bun install
|
||||
```
|
||||
|
||||
Frontend modes also require frontend dependencies:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
bun install
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
List model aliases:
|
||||
|
||||
```bash
|
||||
cd ai_evals
|
||||
bun run cli -- models
|
||||
```
|
||||
|
||||
List cases:
|
||||
|
||||
```bash
|
||||
cd ai_evals
|
||||
bun run cli -- cases
|
||||
bun run cli -- cases flow
|
||||
```
|
||||
|
||||
Run benchmarks:
|
||||
|
||||
```bash
|
||||
cd ai_evals
|
||||
bun run cli -- run flow
|
||||
bun run cli -- run flow flow-test4-order-processing-loop --model opus
|
||||
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
|
||||
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
|
||||
bun run cli -- run flow --record
|
||||
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
|
||||
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
|
||||
bun run cli -- run global global-test1-script-create
|
||||
bun run cli -- run cli bun-hello-script
|
||||
```
|
||||
|
||||
Public CLI surface:
|
||||
|
||||
- `models`
|
||||
- `cases [mode]`
|
||||
- `run <mode> [caseIds...]`
|
||||
|
||||
`run` options:
|
||||
|
||||
- `--runs <n>`: repeat each case `n` times
|
||||
- `--output <path>`: custom result JSON path
|
||||
- `--model <alias>`: choose the model under test
|
||||
- `--models <a,b,c>`: run the same cases sequentially against several model aliases
|
||||
- `--verbose`: stream assistant output for frontend runs
|
||||
- `--record`: append a compact tracked summary line to `ai_evals/history/<mode>.jsonl` for full-suite runs only
|
||||
- `--backend-validation <mode>`: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals
|
||||
|
||||
## Models
|
||||
|
||||
Use `bun run cli -- models` to see the current aliases.
|
||||
|
||||
Today:
|
||||
|
||||
- `haiku`
|
||||
- `sonnet`
|
||||
- `opus`
|
||||
- `4o`
|
||||
- `gemini-flash`
|
||||
- `gemini-pro`
|
||||
- `gemini-3-flash-preview`
|
||||
- `gemini-3.1-pro-preview`
|
||||
|
||||
Notes:
|
||||
|
||||
- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5`
|
||||
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases
|
||||
- `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there
|
||||
- the judge model is separate and currently defaults to `claude-sonnet-4-6`
|
||||
|
||||
## Case Format
|
||||
|
||||
Cases live in one YAML file per mode under `ai_evals/cases/`.
|
||||
|
||||
Minimal shape:
|
||||
|
||||
```yaml
|
||||
- id: flow-test0-sum-two-numbers
|
||||
prompt: |-
|
||||
Create a flow that takes two numbers, `a` and `b`, and returns their sum.
|
||||
initial: ai_evals/fixtures/...
|
||||
expected: ai_evals/fixtures/...
|
||||
```
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `initial`: starting state fixture
|
||||
- `expected`: expected artifact fixture
|
||||
- `validate`: extra deterministic validation rules
|
||||
- `runtime.backendPreview`: optional real backend preview config for smoke validation
|
||||
|
||||
For `flow` mode, `validate` can express requirements such as:
|
||||
|
||||
- accepted input schema shapes
|
||||
- required `results.*` reference validity
|
||||
- required module/code/input characteristics
|
||||
|
||||
For `app` mode, `validate` can express narrow hard requirements such as:
|
||||
|
||||
- required frontend file paths or backend runnable keys
|
||||
- minimum backend runnable counts
|
||||
- required backend runnable types
|
||||
- minimum datatable / datatable-table counts
|
||||
- specific required datatable tables
|
||||
|
||||
For `global` mode, `validate` can express draft-level requirements such as:
|
||||
|
||||
- required draft type/path/language
|
||||
- required or forbidden snippets in draft values
|
||||
- required or forbidden draft counts
|
||||
- forbidden draft paths
|
||||
|
||||
App fixtures can also include an optional `datatables.json` file at the fixture root.
|
||||
|
||||
For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of
|
||||
existing scripts and flows. That lets the real `search_workspace` and
|
||||
`get_runnable_details` tools discover reusable workspace runnables during evals.
|
||||
|
||||
If `--backend-validation preview` is enabled:
|
||||
|
||||
- `script` evals run a real backend script preview in an isolated temp workspace
|
||||
- `flow` evals run a real backend flow preview only for cases that define `runtime.backendPreview`
|
||||
- `flow` cases with `initial.workspace` fixtures seed those scripts and flows into the preview workspace before preview
|
||||
- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` creates or reuses that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures
|
||||
|
||||
Supported backend env vars:
|
||||
|
||||
- `WMILL_AI_EVAL_BACKEND_VALIDATION=preview`
|
||||
- `WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000`
|
||||
- `WMILL_AI_EVAL_BACKEND_EMAIL=admin@windmill.dev`
|
||||
- `WMILL_AI_EVAL_BACKEND_PASSWORD=changeme`
|
||||
- `WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` to reuse an existing workspace on CE installs with low workspace limits
|
||||
|
||||
Frontend modes require a reachable Windmill backend and send model requests through the workspace AI proxy at `/api/w/{workspace}/ai/proxy`. At startup, `ai_evals` checks the resolved backend URL and fails early with setup guidance if the backend cannot be reached or login fails.
|
||||
|
||||
For frontend modes:
|
||||
|
||||
- `ai_evals` creates a temporary backend workspace, or creates/reuses `WMILL_AI_EVAL_BACKEND_WORKSPACE` when it is set
|
||||
- it upserts a provider resource under `f/evals/ai/<provider>`
|
||||
- frontend requests go through `/api/w/{workspace}/ai/proxy`
|
||||
|
||||
## Results And Artifacts
|
||||
|
||||
Every run writes:
|
||||
|
||||
- a summary JSON under `ai_evals/results/`
|
||||
- generated artifacts in a sibling directory
|
||||
|
||||
If `--record` is used, the CLI also appends one compact JSON line to:
|
||||
|
||||
- `ai_evals/history/flow.jsonl`
|
||||
- `ai_evals/history/script.jsonl`
|
||||
- `ai_evals/history/app.jsonl`
|
||||
- `ai_evals/history/global.jsonl`
|
||||
- `ai_evals/history/cli.jsonl`
|
||||
|
||||
Each recorded line contains:
|
||||
|
||||
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`)
|
||||
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`)
|
||||
- average token usage (`averageTokenUsagePerAttempt`)
|
||||
- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate)
|
||||
- `failedCaseIds`
|
||||
|
||||
Example:
|
||||
|
||||
- summary: `ai_evals/results/2026-04-09T09-40-33.051Z__flow.json`
|
||||
- artifacts: `ai_evals/results/2026-04-09T09-40-33.051Z__flow/`
|
||||
|
||||
Typical artifacts by mode:
|
||||
|
||||
- `flow`: `flow.json`
|
||||
- `script`: `script.json` plus the generated script file
|
||||
- `app`: `app.json` plus frontend/backend files
|
||||
- `global`: `global-drafts.json`
|
||||
- `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files
|
||||
- backend-validated attempts also include `backend-preview.json`
|
||||
|
||||
## Layout
|
||||
|
||||
- `cases/`: one YAML file per mode
|
||||
- `fixtures/`: initial and expected fixtures
|
||||
- `core/`: shared loading, model resolution, validation, judging, and result writing
|
||||
- `modes/`: one runner per mode
|
||||
- `history/`: optional tracked pass-rate history written by `run --record`, one JSONL file per mode
|
||||
- `results/`: local benchmark output and artifacts
|
||||
|
||||
## Notes
|
||||
|
||||
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
|
||||
- Global mode evaluates the production global AI tools and validates the resulting AI draft store.
|
||||
- CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow.
|
||||
- CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions.
|
||||
- Frontend progress streams live while the benchmark is running.
|
||||
- Deterministic validators should stay focused on real correctness constraints, not one exact implementation shape.
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
anthropicUsageToBenchmarkTokenUsage,
|
||||
extractCliResultTokenUsage,
|
||||
extractProposedWmillCommands,
|
||||
parseWmillInvocationLog,
|
||||
} from "./runtime";
|
||||
|
||||
describe("anthropicUsageToBenchmarkTokenUsage", () => {
|
||||
it("includes cache tokens in prompt usage", () => {
|
||||
expect(
|
||||
anthropicUsageToBenchmarkTokenUsage({
|
||||
input_tokens: 120,
|
||||
output_tokens: 45,
|
||||
cache_creation_input_tokens: 30,
|
||||
cache_read_input_tokens: 5,
|
||||
})
|
||||
).toEqual({
|
||||
prompt: 155,
|
||||
completion: 45,
|
||||
total: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when usage is absent", () => {
|
||||
expect(anthropicUsageToBenchmarkTokenUsage(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCliResultTokenUsage", () => {
|
||||
it("reads aggregate usage from the SDK result event", () => {
|
||||
expect(
|
||||
extractCliResultTokenUsage({
|
||||
type: "result",
|
||||
usage: {
|
||||
input_tokens: 400,
|
||||
output_tokens: 120,
|
||||
cache_creation_input_tokens: 50,
|
||||
cache_read_input_tokens: 25,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
prompt: 475,
|
||||
completion: 120,
|
||||
total: 595,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to modelUsage when aggregate usage is unavailable", () => {
|
||||
expect(
|
||||
extractCliResultTokenUsage({
|
||||
type: "result",
|
||||
modelUsage: {
|
||||
opus: {
|
||||
inputTokens: 200,
|
||||
outputTokens: 60,
|
||||
cacheCreationInputTokens: 10,
|
||||
cacheReadInputTokens: 5,
|
||||
},
|
||||
haiku: {
|
||||
inputTokens: 80,
|
||||
outputTokens: 20,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 15,
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
prompt: 310,
|
||||
completion: 80,
|
||||
total: 390,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractProposedWmillCommands", () => {
|
||||
it("extracts proposed commands from bullets, code blocks, and inline code", () => {
|
||||
expect(
|
||||
extractProposedWmillCommands(`
|
||||
Next:
|
||||
- \`wmill generate-metadata --yes\`
|
||||
- wmill sync push
|
||||
|
||||
You can inspect failures with \`wmill job logs 123\`.
|
||||
`)
|
||||
).toEqual([
|
||||
"wmill generate-metadata --yes",
|
||||
"wmill sync push",
|
||||
"wmill job logs 123",
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts inline prose commands that are not wrapped in backticks", () => {
|
||||
expect(
|
||||
extractProposedWmillCommands(
|
||||
"The first command is wmill sync pull before you edit locally."
|
||||
)
|
||||
).toEqual(["wmill sync pull"]);
|
||||
});
|
||||
|
||||
it("extracts multiple inline prose commands from a single sentence", () => {
|
||||
expect(
|
||||
extractProposedWmillCommands(
|
||||
"Run wmill generate-metadata and then wmill sync push when you are ready."
|
||||
)
|
||||
).toEqual(["wmill generate-metadata", "wmill sync push"]);
|
||||
});
|
||||
|
||||
it("ignores negated command mentions", () => {
|
||||
expect(
|
||||
extractProposedWmillCommands(
|
||||
"Do not run `wmill sync push`. Instead run `wmill sync pull` first."
|
||||
)
|
||||
).toEqual(["wmill sync pull"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWmillInvocationLog", () => {
|
||||
it("parses stubbed wmill invocations into structured records", () => {
|
||||
expect(
|
||||
parseWmillInvocationLog(`noise
|
||||
__WMILL_BENCHMARK__
|
||||
2026-04-21T12:00:00+00:00
|
||||
/tmp/workspace
|
||||
2
|
||||
generate-metadata
|
||||
--yes
|
||||
__WMILL_BENCHMARK__
|
||||
2026-04-21T12:00:05+00:00
|
||||
/tmp/workspace
|
||||
3
|
||||
sync
|
||||
push
|
||||
--dry-run
|
||||
`)
|
||||
).toEqual([
|
||||
{
|
||||
argv: ["generate-metadata", "--yes"],
|
||||
cwd: "/tmp/workspace",
|
||||
timestamp: "2026-04-21T12:00:00+00:00",
|
||||
},
|
||||
{
|
||||
argv: ["sync", "push", "--dry-run"],
|
||||
cwd: "/tmp/workspace",
|
||||
timestamp: "2026-04-21T12:00:05+00:00",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,532 +0,0 @@
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { delimiter, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getCliEvalModel, resolveEvalModel, type CliEvalModelConfig } from "../../core/models";
|
||||
import type {
|
||||
BenchmarkTokenUsage,
|
||||
CliToolInvocation,
|
||||
CliTrace,
|
||||
CliWmillInvocation,
|
||||
} from "../../core/types";
|
||||
|
||||
export type ToolInvocation = CliToolInvocation;
|
||||
|
||||
export interface PromptRunResult {
|
||||
output: string;
|
||||
durationMs: number;
|
||||
tokenUsage: BenchmarkTokenUsage | null;
|
||||
trace: CliTrace;
|
||||
}
|
||||
|
||||
interface AnthropicUsageLike {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
}
|
||||
|
||||
interface AnthropicModelUsageLike {
|
||||
inputTokens?: number | null;
|
||||
outputTokens?: number | null;
|
||||
cacheCreationInputTokens?: number | null;
|
||||
cacheReadInputTokens?: number | null;
|
||||
}
|
||||
|
||||
interface CliResultMessageLike {
|
||||
type?: string;
|
||||
usage?: AnthropicUsageLike | null;
|
||||
modelUsage?: Record<string, AnthropicModelUsageLike> | null;
|
||||
}
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
export const DEFAULT_CLI_EVAL_MODEL: CliEvalModelConfig = getCliEvalModel(resolveEvalModel("cli"));
|
||||
const WMILL_STUB_DIR_NAME = ".wmill-benchmark-bin";
|
||||
const WMILL_LOG_FILE_NAME = ".wmill-benchmark-wmill-invocations.log";
|
||||
const WMILL_LOG_MARKER = "__WMILL_BENCHMARK__";
|
||||
const NEGATED_COMMAND_PREFIX = /(?:^|\b)(?:do not|don't|dont|never|instead of)\s+(?:run|use)?\s*$/i;
|
||||
const COMMAND_STOP_WORDS = new Set([
|
||||
"and",
|
||||
"before",
|
||||
"after",
|
||||
"then",
|
||||
"instead",
|
||||
"otherwise",
|
||||
"because",
|
||||
"so",
|
||||
"if",
|
||||
"when",
|
||||
"while",
|
||||
"once",
|
||||
]);
|
||||
const COMMAND_STOP_TOKENS = new Set(["-", "–", "—", "|"]);
|
||||
|
||||
export function getGeneratedSkillsSource(): string {
|
||||
return join(REPO_ROOT, "system_prompts", "auto-generated", "skills");
|
||||
}
|
||||
|
||||
export function anthropicUsageToBenchmarkTokenUsage(
|
||||
usage: AnthropicUsageLike | null | undefined
|
||||
): BenchmarkTokenUsage | null {
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt =
|
||||
(usage.input_tokens ?? 0) +
|
||||
(usage.cache_creation_input_tokens ?? 0) +
|
||||
(usage.cache_read_input_tokens ?? 0);
|
||||
const completion = usage.output_tokens ?? 0;
|
||||
|
||||
return {
|
||||
prompt,
|
||||
completion,
|
||||
total: prompt + completion,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractCliResultTokenUsage(message: unknown): BenchmarkTokenUsage | null {
|
||||
if (!message || typeof message !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resultMessage = message as CliResultMessageLike;
|
||||
if (resultMessage.type !== "result") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usage = anthropicUsageToBenchmarkTokenUsage(resultMessage.usage);
|
||||
if (usage) {
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (!resultMessage.modelUsage || typeof resultMessage.modelUsage !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prompt = 0;
|
||||
let completion = 0;
|
||||
let sawModelUsage = false;
|
||||
|
||||
for (const modelUsage of Object.values(resultMessage.modelUsage)) {
|
||||
if (!modelUsage || typeof modelUsage !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
prompt +=
|
||||
(modelUsage.inputTokens ?? 0) +
|
||||
(modelUsage.cacheCreationInputTokens ?? 0) +
|
||||
(modelUsage.cacheReadInputTokens ?? 0);
|
||||
completion += modelUsage.outputTokens ?? 0;
|
||||
sawModelUsage = true;
|
||||
}
|
||||
|
||||
if (!sawModelUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
completion,
|
||||
total: prompt + completion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPromptAndCapture(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
maxTurns: number = 3,
|
||||
modelConfig: CliEvalModelConfig = DEFAULT_CLI_EVAL_MODEL
|
||||
): Promise<PromptRunResult> {
|
||||
const toolsUsed: ToolInvocation[] = [];
|
||||
const skillsInvoked: string[] = [];
|
||||
const bashCommands: string[] = [];
|
||||
let output = "";
|
||||
let assistantMessageCount = 0;
|
||||
let tokenUsage: BenchmarkTokenUsage | null = null;
|
||||
const startedAt = Date.now();
|
||||
const stubBinDir = join(cwd, WMILL_STUB_DIR_NAME);
|
||||
const wmillLogPath = join(cwd, WMILL_LOG_FILE_NAME);
|
||||
|
||||
const options: Options = {
|
||||
cwd,
|
||||
model: modelConfig.model,
|
||||
maxTurns,
|
||||
settingSources: ["project"],
|
||||
allowedTools: ["Skill", "Read", "Glob", "Grep", "Bash", "Write", "Edit"],
|
||||
env: {
|
||||
...getQueryEnv(),
|
||||
PATH: process.env.PATH ? `${stubBinDir}${delimiter}${process.env.PATH}` : stubBinDir,
|
||||
WMILL_BENCHMARK_LOG_PATH: wmillLogPath,
|
||||
},
|
||||
};
|
||||
|
||||
await installWmillStub(stubBinDir);
|
||||
|
||||
for await (const message of query({ prompt, options })) {
|
||||
if (message.type === "assistant") {
|
||||
assistantMessageCount += 1;
|
||||
const content = message.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use") {
|
||||
const input = normalizeToolInput(block.input);
|
||||
toolsUsed.push({
|
||||
tool: block.name,
|
||||
input,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
if (block.name === "Skill") {
|
||||
const skillInput = input as { skill?: string };
|
||||
if (skillInput.skill) {
|
||||
pushUnique(skillsInvoked, skillInput.skill);
|
||||
}
|
||||
}
|
||||
|
||||
if (block.name === "Bash") {
|
||||
for (const command of extractBashCommands(input)) {
|
||||
pushUnique(bashCommands, command);
|
||||
}
|
||||
}
|
||||
} else if (block.type === "text") {
|
||||
output += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.type === "result") {
|
||||
const resultMessage = message as { result?: string };
|
||||
tokenUsage = extractCliResultTokenUsage(message) ?? tokenUsage;
|
||||
if (typeof resultMessage.result === "string") {
|
||||
output += resultMessage.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proposedCommands = extractProposedWmillCommands(output);
|
||||
const wmillInvocations = await readWmillInvocationLog(wmillLogPath);
|
||||
|
||||
return {
|
||||
output,
|
||||
durationMs: Date.now() - startedAt,
|
||||
tokenUsage,
|
||||
trace: {
|
||||
toolsUsed,
|
||||
skillsInvoked,
|
||||
assistantMessageCount,
|
||||
bashCommands,
|
||||
proposedCommands,
|
||||
executedWmillCommands: wmillInvocations.map(formatExecutedWmillCommand),
|
||||
wmillInvocations,
|
||||
firstMutationToolIndex: getFirstMutationToolIndex(toolsUsed),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function wasSkillInvoked(result: PromptRunResult, skillName: string): boolean {
|
||||
return result.trace.skillsInvoked.some((skill) => skill === skillName);
|
||||
}
|
||||
|
||||
export function wasToolUsed(result: PromptRunResult, toolName: string): boolean {
|
||||
return result.trace.toolsUsed.some((tool) => tool.tool === toolName);
|
||||
}
|
||||
|
||||
export function formatCliRunModelLabel(modelConfig: CliEvalModelConfig): string {
|
||||
return `${modelConfig.provider}:${modelConfig.model}`;
|
||||
}
|
||||
|
||||
export function getToolInputs(
|
||||
result: PromptRunResult,
|
||||
toolName: string
|
||||
): Record<string, unknown>[] {
|
||||
return result.trace.toolsUsed
|
||||
.filter((tool) => tool.tool === toolName)
|
||||
.map((tool) => tool.input);
|
||||
}
|
||||
|
||||
export function extractProposedWmillCommands(output: string): string[] {
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
for (const command of extractInlineBacktickCommands(line)) {
|
||||
pushUnique(commands, command);
|
||||
}
|
||||
|
||||
for (const command of extractInlineProseCommands(line.replace(/^\s*(?:[-*]|\d+\.)\s*/, ""))) {
|
||||
pushUnique(commands, command);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function parseWmillInvocationLog(raw: string): CliWmillInvocation[] {
|
||||
const entries: CliWmillInvocation[] = [];
|
||||
const lines = raw.split(/\r?\n/);
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (lines[index] !== WMILL_LOG_MARKER) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = lines[index + 1] ?? "";
|
||||
const cwd = lines[index + 2] ?? "";
|
||||
const argCount = Number.parseInt(lines[index + 3] ?? "", 10);
|
||||
if (!Number.isFinite(argCount) || argCount < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = index + 4;
|
||||
const argv = lines.slice(start, start + argCount);
|
||||
entries.push({ argv, cwd, timestamp });
|
||||
index = start + argCount - 1;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function installWmillStub(binDir: string): Promise<void> {
|
||||
await mkdir(binDir, { recursive: true });
|
||||
|
||||
const stubPath = join(binDir, "wmill");
|
||||
const script = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
{
|
||||
printf '${WMILL_LOG_MARKER}\\n'
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
printf '%s\\n' "$PWD"
|
||||
printf '%s\\n' "$#"
|
||||
printf '%s\\n' "$@"
|
||||
} >> "\${WMILL_BENCHMARK_LOG_PATH:?}"
|
||||
printf 'wmill benchmark stub: do not execute Windmill CLI commands during ai_evals; describe them in the final response instead.\\n' >&2
|
||||
exit 97
|
||||
`;
|
||||
|
||||
await writeFile(stubPath, script, "utf8");
|
||||
await chmod(stubPath, 0o755);
|
||||
}
|
||||
|
||||
async function readWmillInvocationLog(logPath: string): Promise<CliWmillInvocation[]> {
|
||||
const raw = await readFile(logPath, "utf8").catch(() => null);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
return parseWmillInvocationLog(raw);
|
||||
}
|
||||
|
||||
function getQueryEnv(): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(process.env).flatMap(([key, value]) =>
|
||||
typeof value === "string" ? [[key, value]] : []
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeToolInput(input: unknown): Record<string, unknown> {
|
||||
if (input && typeof input === "object" && !Array.isArray(input)) {
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
return { raw: input };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function extractBashCommands(input: Record<string, unknown>): string[] {
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const key of ["command", "cmd", "script", "raw"]) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string") {
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const command = normalizeCommandCandidate(line);
|
||||
if (command) {
|
||||
pushUnique(commands, command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function extractInlineBacktickCommands(line: string): string[] {
|
||||
const commands: string[] = [];
|
||||
const regex = /`(wmill [^`\n]+)`/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
if (hasNegatedCommandPrefix(line.slice(0, match.index))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = normalizeCommandCandidate(match[1]);
|
||||
if (command) {
|
||||
pushUnique(commands, command);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function extractInlineProseCommands(line: string): string[] {
|
||||
const commands: string[] = [];
|
||||
let searchFrom = 0;
|
||||
|
||||
while (true) {
|
||||
const inlineIndex = line.toLowerCase().indexOf("wmill ", searchFrom);
|
||||
if (inlineIndex === -1) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
if (!hasNegatedCommandPrefix(line.slice(0, inlineIndex))) {
|
||||
const command = extractInlineProseCommandAt(line, inlineIndex);
|
||||
if (command) {
|
||||
pushUnique(commands, command);
|
||||
}
|
||||
}
|
||||
|
||||
searchFrom = inlineIndex + "wmill ".length;
|
||||
}
|
||||
}
|
||||
|
||||
function extractInlineProseCommandAt(line: string, startIndex: number): string | null {
|
||||
const tokens = ["wmill"];
|
||||
let cursor = startIndex + "wmill".length;
|
||||
|
||||
while (cursor < line.length) {
|
||||
while (cursor < line.length && /\s/.test(line[cursor]!)) {
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (cursor >= line.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const current = line[cursor]!;
|
||||
if ("`.,;:()[]{}".includes(current)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const token = readCommandToken(line, cursor);
|
||||
if (!token) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (COMMAND_STOP_WORDS.has(token.value.toLowerCase())) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (COMMAND_STOP_TOKENS.has(token.value)) {
|
||||
break;
|
||||
}
|
||||
|
||||
tokens.push(token.value);
|
||||
cursor = token.nextIndex;
|
||||
}
|
||||
|
||||
if (tokens.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeCommandCandidate(tokens.join(" "));
|
||||
}
|
||||
|
||||
function readCommandToken(
|
||||
line: string,
|
||||
startIndex: number
|
||||
): { value: string; nextIndex: number } | null {
|
||||
const firstChar = line[startIndex]!;
|
||||
|
||||
if (firstChar === `"` || firstChar === `'`) {
|
||||
const endIndex = line.indexOf(firstChar, startIndex + 1);
|
||||
const nextIndex = endIndex === -1 ? line.length : endIndex + 1;
|
||||
return {
|
||||
value: line.slice(startIndex, nextIndex),
|
||||
nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
if (firstChar === "<") {
|
||||
const endIndex = line.indexOf(">", startIndex + 1);
|
||||
const nextIndex = endIndex === -1 ? line.length : endIndex + 1;
|
||||
return {
|
||||
value: line.slice(startIndex, nextIndex),
|
||||
nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
let endIndex = startIndex;
|
||||
while (endIndex < line.length && !/[\s`.,;:()[\]{}#]/.test(line[endIndex]!)) {
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
if (endIndex === startIndex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: line.slice(startIndex, endIndex),
|
||||
nextIndex: endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNegatedCommandPrefix(prefix: string): boolean {
|
||||
const normalizedPrefix = prefix
|
||||
.toLowerCase()
|
||||
.replace(/[`"'“”‘’]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trimEnd();
|
||||
|
||||
return NEGATED_COMMAND_PREFIX.test(normalizedPrefix);
|
||||
}
|
||||
|
||||
function normalizeCommandCandidate(value: string): string | null {
|
||||
const trimmed = value.trim().replace(/^`|`$/g, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = trimmed
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[`.;:,]+$/g, "")
|
||||
.trim();
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function formatExecutedWmillCommand(entry: CliWmillInvocation): string {
|
||||
return ["wmill", ...entry.argv].join(" ").trim();
|
||||
}
|
||||
|
||||
function getFirstMutationToolIndex(toolsUsed: ToolInvocation[]): number | null {
|
||||
for (const [index, tool] of toolsUsed.entries()) {
|
||||
if (tool.tool === "Write" || tool.tool === "Edit") {
|
||||
return index;
|
||||
}
|
||||
|
||||
if (tool.tool === "Bash" && extractBashCommands(tool.input).some(isLikelyMutatingBashCommand)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLikelyMutatingBashCommand(command: string): boolean {
|
||||
return (
|
||||
/\b(?:mkdir|touch|rm|mv|cp|install|tee)\b/.test(command) ||
|
||||
/\b(?:cat|echo|printf)\b.*(?:>|>>|\|\s*tee\b)/.test(command) ||
|
||||
/\bsed\s+-i\b/.test(command) ||
|
||||
/\bperl\s+-pi\b/.test(command) ||
|
||||
/\bwmill\b/.test(command)
|
||||
);
|
||||
}
|
||||
|
||||
function pushUnique(values: string[], value: string): void {
|
||||
if (!values.includes(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import type { BackendValidationSettings } from '../../core/backendValidation'
|
||||
import { BackendPreviewClient } from './backendPreview'
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH
|
||||
})
|
||||
|
||||
describe('BackendPreviewClient', () => {
|
||||
it('updates an existing seeded script on path conflict and waits for deployment', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
globalThis.fetch = mockFetch(
|
||||
requests,
|
||||
textResponse(200, 'token'),
|
||||
textResponse(200, ''),
|
||||
textResponse(400, 'Path conflict for f/evals/add_two_numbers with non-archived hash 123'),
|
||||
jsonResponse(200, { hash: '123' }),
|
||||
textResponse(200, '456'),
|
||||
jsonResponse(200, { lock: 'script.lock', lock_error_logs: null })
|
||||
)
|
||||
|
||||
const client = new BackendPreviewClient(
|
||||
buildSettings({ baseUrl: 'http://backend.test/script-upsert' })
|
||||
)
|
||||
|
||||
await client.createScript({
|
||||
workspaceId: 'test',
|
||||
path: 'f/evals/add_two_numbers',
|
||||
summary: 'Add two numbers',
|
||||
content: 'export async function main(a: number, b: number) { return a + b }',
|
||||
language: 'bun'
|
||||
})
|
||||
|
||||
expect(requests.map((entry) => entry.url)).toEqual([
|
||||
'http://backend.test/script-upsert/api/auth/login',
|
||||
'http://backend.test/script-upsert/api/w/test/folders/create',
|
||||
'http://backend.test/script-upsert/api/w/test/scripts/create',
|
||||
'http://backend.test/script-upsert/api/w/test/scripts/get/p/f/evals/add_two_numbers',
|
||||
'http://backend.test/script-upsert/api/w/test/scripts/create',
|
||||
'http://backend.test/script-upsert/api/w/test/scripts/deployment_status/h/456'
|
||||
])
|
||||
|
||||
const updateRequest = requests[4]
|
||||
expect(updateRequest.init?.method).toBe('POST')
|
||||
expect(JSON.parse(String(updateRequest.init?.body))).toMatchObject({
|
||||
path: 'f/evals/add_two_numbers',
|
||||
parent_hash: '123',
|
||||
language: 'bun'
|
||||
})
|
||||
})
|
||||
|
||||
it('updates an existing seeded flow on create conflict', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
globalThis.fetch = mockFetch(
|
||||
requests,
|
||||
textResponse(200, 'token'),
|
||||
textResponse(200, ''),
|
||||
textResponse(400, 'Flow f/evals/add_numbers_flow already exists'),
|
||||
textResponse(200, '')
|
||||
)
|
||||
|
||||
const client = new BackendPreviewClient(
|
||||
buildSettings({ baseUrl: 'http://backend.test/flow-upsert' })
|
||||
)
|
||||
|
||||
await client.createFlow({
|
||||
workspaceId: 'test',
|
||||
path: 'f/evals/add_numbers_flow',
|
||||
summary: 'Add numbers',
|
||||
value: { modules: [] }
|
||||
})
|
||||
|
||||
expect(requests.map((entry) => entry.url)).toEqual([
|
||||
'http://backend.test/flow-upsert/api/auth/login',
|
||||
'http://backend.test/flow-upsert/api/w/test/folders/create',
|
||||
'http://backend.test/flow-upsert/api/w/test/flows/create',
|
||||
'http://backend.test/flow-upsert/api/w/test/flows/update/f/evals/add_numbers_flow'
|
||||
])
|
||||
|
||||
const updateRequest = requests[3]
|
||||
expect(updateRequest.init?.method).toBe('POST')
|
||||
expect(JSON.parse(String(updateRequest.init?.body))).toMatchObject({
|
||||
path: 'f/evals/add_numbers_flow',
|
||||
value: { modules: [] }
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes shared-workspace validations inside the overridden workspace', async () => {
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/api/auth/login')) {
|
||||
return textResponse(200, 'token')
|
||||
}
|
||||
if (url.endsWith('/api/workspaces/exists')) {
|
||||
return textResponse(200, 'true')
|
||||
}
|
||||
if (url.endsWith('/api/w/shared-preview/flows/list_paths')) {
|
||||
return jsonResponse(200, [])
|
||||
}
|
||||
if (url.endsWith('/api/w/shared-preview/scripts/list_paths')) {
|
||||
return jsonResponse(200, [])
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
}
|
||||
|
||||
const client = new BackendPreviewClient(
|
||||
buildSettings({
|
||||
baseUrl: 'http://backend.test/shared-lock',
|
||||
workspaceOverride: 'shared-preview'
|
||||
})
|
||||
)
|
||||
|
||||
const order: string[] = []
|
||||
let releaseFirst: (() => void) | undefined
|
||||
let notifyFirstStart: (() => void) | undefined
|
||||
const firstStarted = new Promise<void>((resolve) => {
|
||||
notifyFirstStart = resolve
|
||||
})
|
||||
|
||||
const first = client.withWorkspace('flow-test1', 1, async () => {
|
||||
order.push('first:start')
|
||||
notifyFirstStart?.()
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
order.push('first:end')
|
||||
})
|
||||
|
||||
const second = client.withWorkspace('flow-test2', 1, async () => {
|
||||
order.push('second:start')
|
||||
order.push('second:end')
|
||||
})
|
||||
|
||||
await firstStarted
|
||||
expect(order).toEqual(['first:start'])
|
||||
|
||||
releaseFirst?.()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(order).toEqual(['first:start', 'first:end', 'second:start', 'second:end'])
|
||||
})
|
||||
|
||||
it('clears managed shared-workspace assets before preview runs', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
globalThis.fetch = mockFetch(
|
||||
requests,
|
||||
textResponse(200, 'token'),
|
||||
textResponse(200, 'true'),
|
||||
jsonResponse(200, ['f/evals/old_subflow', 'u/admin/keep_flow']),
|
||||
textResponse(200, ''),
|
||||
jsonResponse(200, ['f/evals/old_script', 'f/shared/keep_script']),
|
||||
textResponse(200, '')
|
||||
)
|
||||
|
||||
const client = new BackendPreviewClient(
|
||||
buildSettings({
|
||||
baseUrl: 'http://backend.test/shared-cleanup',
|
||||
workspaceOverride: 'shared-preview'
|
||||
})
|
||||
)
|
||||
|
||||
await client.withWorkspace('flow-test1', 1, async () => undefined)
|
||||
|
||||
expect(requests.map((entry) => entry.url)).toEqual([
|
||||
'http://backend.test/shared-cleanup/api/auth/login',
|
||||
'http://backend.test/shared-cleanup/api/workspaces/exists',
|
||||
'http://backend.test/shared-cleanup/api/w/shared-preview/flows/list_paths',
|
||||
'http://backend.test/shared-cleanup/api/w/shared-preview/flows/delete/f/evals/old_subflow',
|
||||
'http://backend.test/shared-cleanup/api/w/shared-preview/scripts/list_paths',
|
||||
'http://backend.test/shared-cleanup/api/w/shared-preview/scripts/delete/p/f/evals/old_script'
|
||||
])
|
||||
})
|
||||
|
||||
it('retries login after a cached login failure', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
globalThis.fetch = mockFetch(
|
||||
requests,
|
||||
textResponse(503, 'backend starting'),
|
||||
textResponse(200, 'token'),
|
||||
textResponse(200, 'true'),
|
||||
jsonResponse(200, []),
|
||||
jsonResponse(200, [])
|
||||
)
|
||||
|
||||
const client = new BackendPreviewClient(
|
||||
buildSettings({
|
||||
baseUrl: 'http://backend.test/login-retry',
|
||||
workspaceOverride: 'shared-preview'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(client.withWorkspace('flow-test1', 1, async () => undefined)).rejects.toThrow(
|
||||
'login for backend validation failed'
|
||||
)
|
||||
await expect(client.withWorkspace('flow-test1', 1, async () => 'ok')).resolves.toBe('ok')
|
||||
|
||||
expect(
|
||||
requests.filter((entry) => entry.url === 'http://backend.test/login-retry/api/auth/login')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
function buildSettings(
|
||||
overrides: Partial<BackendValidationSettings> = {}
|
||||
): BackendValidationSettings {
|
||||
return {
|
||||
mode: 'preview',
|
||||
baseUrl: 'http://backend.test/default',
|
||||
email: 'admin@windmill.dev',
|
||||
password: 'changeme',
|
||||
pollIntervalMs: 1,
|
||||
maxWaitMs: 50,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mockFetch(
|
||||
requests: Array<{ url: string; init?: RequestInit }>,
|
||||
...responses: Response[]
|
||||
): typeof fetch {
|
||||
const queue = [...responses]
|
||||
return async (input, init) => {
|
||||
const url = String(input)
|
||||
requests.push({ url, init })
|
||||
const next = queue.shift()
|
||||
if (!next) {
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
function textResponse(status: number, body: string): Response {
|
||||
return new Response(body, { status })
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { BackendValidationSettings } from '../../core/backendValidation'
|
||||
|
||||
interface CompletedJobResultMaybe {
|
||||
completed: boolean
|
||||
result: unknown
|
||||
success?: boolean
|
||||
started?: boolean
|
||||
}
|
||||
|
||||
interface ScriptDeploymentStatus {
|
||||
lock?: unknown
|
||||
lock_error_logs?: string | null
|
||||
}
|
||||
|
||||
export interface CompletedPreviewJob {
|
||||
id: string
|
||||
success: boolean
|
||||
result: unknown
|
||||
logs?: string | null
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
const tokenCache = new Map<string, Promise<string>>()
|
||||
const sharedWorkspaceQueue = new Map<string, Promise<void>>()
|
||||
const managedSharedWorkspacePrefixes = ['f/evals/']
|
||||
const DEFAULT_WORKSPACE_PREFIX = 'ai-evals'
|
||||
|
||||
export class BackendPreviewClient {
|
||||
constructor(private readonly settings: BackendValidationSettings) {}
|
||||
|
||||
async withWorkspace<T>(
|
||||
caseId: string,
|
||||
attempt: number,
|
||||
body: (workspaceId: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const workspaceId =
|
||||
this.settings.workspaceOverride ??
|
||||
buildWorkspaceId(caseId, attempt)
|
||||
|
||||
const run = async () => {
|
||||
await this.ensureWorkspace(workspaceId)
|
||||
if (this.settings.workspaceOverride) {
|
||||
await this.clearManagedSharedWorkspaceAssets(workspaceId)
|
||||
}
|
||||
|
||||
try {
|
||||
return await body(workspaceId)
|
||||
} finally {
|
||||
if (!this.settings.workspaceOverride) {
|
||||
await this.deleteWorkspace(workspaceId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.workspaceOverride) {
|
||||
return await withSharedWorkspaceLock(workspaceId, run)
|
||||
}
|
||||
|
||||
return await run()
|
||||
}
|
||||
|
||||
async createScript(input: {
|
||||
workspaceId: string
|
||||
path: string
|
||||
summary: string
|
||||
description?: string
|
||||
schema?: Record<string, unknown>
|
||||
content: string
|
||||
language: string
|
||||
}): Promise<void> {
|
||||
await this.ensureFolderForPath(input.workspaceId, input.path)
|
||||
|
||||
const payload = {
|
||||
path: input.path,
|
||||
summary: input.summary,
|
||||
description: input.description ?? '',
|
||||
content: input.content,
|
||||
schema: input.schema ?? { type: 'object', properties: {}, required: [] },
|
||||
is_template: false,
|
||||
language: input.language,
|
||||
kind: 'script'
|
||||
}
|
||||
|
||||
const response = await this.request(`/w/${encodeURIComponent(input.workspaceId)}/scripts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await this.waitForScriptDeployment(input.workspaceId, input.path, (await response.text()).trim())
|
||||
return
|
||||
}
|
||||
|
||||
const message = await response.text()
|
||||
if (!isConflictMessage(message)) {
|
||||
throw new Error(`create script ${input.path} failed: ${response.status} ${response.statusText} - ${message}`)
|
||||
}
|
||||
|
||||
const currentScript = await this.getScriptByPath(input.workspaceId, input.path)
|
||||
const currentHash = readStringField(currentScript, 'hash', `script ${input.path}`)
|
||||
const updateResponse = await this.request(
|
||||
`/w/${encodeURIComponent(input.workspaceId)}/scripts/create`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
parent_hash: currentHash
|
||||
})
|
||||
}
|
||||
)
|
||||
await expectOk(updateResponse, `update script ${input.path}`)
|
||||
await this.waitForScriptDeployment(input.workspaceId, input.path, (await updateResponse.text()).trim())
|
||||
}
|
||||
|
||||
async createFlow(input: {
|
||||
workspaceId: string
|
||||
path: string
|
||||
summary: string
|
||||
description?: string
|
||||
schema?: Record<string, unknown>
|
||||
value: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
await this.ensureFolderForPath(input.workspaceId, input.path)
|
||||
|
||||
const payload = {
|
||||
path: input.path,
|
||||
summary: input.summary,
|
||||
description: input.description ?? '',
|
||||
schema: input.schema ?? { type: 'object', properties: {}, required: [] },
|
||||
value: input.value
|
||||
}
|
||||
|
||||
const response = await this.request(`/w/${encodeURIComponent(input.workspaceId)}/flows/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = await response.text()
|
||||
if (!isConflictMessage(message)) {
|
||||
throw new Error(`create flow ${input.path} failed: ${response.status} ${response.statusText} - ${message}`)
|
||||
}
|
||||
|
||||
const updateResponse = await this.request(
|
||||
`/w/${encodeURIComponent(input.workspaceId)}/flows/update/${input.path}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
)
|
||||
await expectOk(updateResponse, `update flow ${input.path}`)
|
||||
}
|
||||
|
||||
async runScriptPreview(input: {
|
||||
workspaceId: string
|
||||
content: string
|
||||
args: Record<string, unknown>
|
||||
language: string
|
||||
path?: string
|
||||
timeoutSeconds?: number
|
||||
}): Promise<CompletedPreviewJob> {
|
||||
const response = await this.request(
|
||||
withQuery(`/w/${encodeURIComponent(input.workspaceId)}/jobs/run/preview`, {
|
||||
timeout: input.timeoutSeconds
|
||||
}),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: input.content,
|
||||
args: input.args,
|
||||
language: input.language,
|
||||
path: input.path
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expectOk(response, 'start script preview')
|
||||
const jobId = (await response.text()).trim()
|
||||
return await this.waitForCompletedJob(input.workspaceId, jobId)
|
||||
}
|
||||
|
||||
async runFlowPreview(input: {
|
||||
workspaceId: string
|
||||
value: Record<string, unknown>
|
||||
args: Record<string, unknown>
|
||||
timeoutSeconds?: number
|
||||
path?: string
|
||||
}): Promise<CompletedPreviewJob> {
|
||||
const response = await this.request(
|
||||
withQuery(`/w/${encodeURIComponent(input.workspaceId)}/jobs/run/preview_flow`, {
|
||||
timeout: input.timeoutSeconds
|
||||
}),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
value: input.value,
|
||||
args: input.args,
|
||||
path: input.path
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expectOk(response, 'start flow preview')
|
||||
const jobId = (await response.text()).trim()
|
||||
return await this.waitForCompletedJob(input.workspaceId, jobId)
|
||||
}
|
||||
|
||||
private async ensureWorkspace(workspaceId: string): Promise<void> {
|
||||
const existsResponse = await this.request('/workspaces/exists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: workspaceId })
|
||||
})
|
||||
await expectOk(existsResponse, `check workspace ${workspaceId}`)
|
||||
|
||||
if ((await existsResponse.text()).trim() === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
const createResponse = await this.request('/workspaces/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: workspaceId, name: workspaceId })
|
||||
})
|
||||
try {
|
||||
await expectOk(createResponse, `create workspace ${workspaceId}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('maximum number of workspaces')) {
|
||||
throw new Error(
|
||||
`${message}. Reuse an existing workspace with WMILL_AI_EVAL_BACKEND_WORKSPACE=<workspace-id>.`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteWorkspace(workspaceId: string): Promise<void> {
|
||||
const response = await this.request(`/workspaces/delete/${encodeURIComponent(workspaceId)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await expectOk(response, `delete workspace ${workspaceId}`)
|
||||
}
|
||||
|
||||
private async ensureFolderForPath(workspaceId: string, path: string): Promise<void> {
|
||||
const folderName = extractFolderName(path)
|
||||
if (!folderName) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/folders/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: folderName })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = await response.text()
|
||||
if (!message.toLowerCase().includes('already exists')) {
|
||||
throw new Error(`Failed to create folder ${folderName}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForCompletedJob(
|
||||
workspaceId: string,
|
||||
jobId: string
|
||||
): Promise<CompletedPreviewJob> {
|
||||
const deadline = Date.now() + this.settings.maxWaitMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const maybeResponse = await this.request(
|
||||
`/w/${encodeURIComponent(workspaceId)}/jobs_u/completed/get_result_maybe/${encodeURIComponent(jobId)}?get_started=false`
|
||||
)
|
||||
await expectOk(maybeResponse, `poll job ${jobId}`)
|
||||
const maybeResult = (await maybeResponse.json()) as CompletedJobResultMaybe
|
||||
|
||||
if (maybeResult.completed) {
|
||||
const completedResponse = await this.request(
|
||||
`/w/${encodeURIComponent(workspaceId)}/jobs_u/completed/get/${encodeURIComponent(jobId)}`
|
||||
)
|
||||
await expectOk(completedResponse, `get completed job ${jobId}`)
|
||||
const completedJob = (await completedResponse.json()) as Record<string, unknown>
|
||||
return {
|
||||
id: jobId,
|
||||
success: Boolean(maybeResult.success),
|
||||
result: maybeResult.result,
|
||||
logs:
|
||||
typeof completedJob.logs === 'string' || completedJob.logs === null
|
||||
? (completedJob.logs as string | null)
|
||||
: null,
|
||||
raw: completedJob
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, this.settings.pollIntervalMs))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for preview job ${jobId} to complete`)
|
||||
}
|
||||
|
||||
private async getScriptByPath(workspaceId: string, path: string): Promise<Record<string, unknown>> {
|
||||
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/get/p/${path}`)
|
||||
await expectOk(response, `get script ${path}`)
|
||||
return (await response.json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
private async clearManagedSharedWorkspaceAssets(workspaceId: string): Promise<void> {
|
||||
const flowPaths = await this.listFlowPaths(workspaceId)
|
||||
for (const path of flowPaths.filter(isManagedSharedWorkspacePath)) {
|
||||
await this.deleteFlowByPath(workspaceId, path)
|
||||
}
|
||||
|
||||
const scriptPaths = await this.listScriptPaths(workspaceId)
|
||||
for (const path of scriptPaths.filter(isManagedSharedWorkspacePath)) {
|
||||
await this.deleteScriptByPath(workspaceId, path)
|
||||
}
|
||||
}
|
||||
|
||||
private async listFlowPaths(workspaceId: string): Promise<string[]> {
|
||||
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/flows/list_paths`)
|
||||
await expectOk(response, `list flows in workspace ${workspaceId}`)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
private async listScriptPaths(workspaceId: string): Promise<string[]> {
|
||||
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/list_paths`)
|
||||
await expectOk(response, `list scripts in workspace ${workspaceId}`)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
private async deleteFlowByPath(workspaceId: string, path: string): Promise<void> {
|
||||
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/flows/delete/${path}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await expectOk(response, `delete flow ${path}`)
|
||||
}
|
||||
|
||||
private async deleteScriptByPath(workspaceId: string, path: string): Promise<void> {
|
||||
const response = await this.request(`/w/${encodeURIComponent(workspaceId)}/scripts/delete/p/${path}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
await expectOk(response, `delete script ${path}`)
|
||||
}
|
||||
|
||||
private async waitForScriptDeployment(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
hash: string
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + this.settings.maxWaitMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const response = await this.request(
|
||||
`/w/${encodeURIComponent(workspaceId)}/scripts/deployment_status/h/${encodeURIComponent(hash)}`
|
||||
)
|
||||
await expectOk(response, `check deployment status for script ${path}`)
|
||||
const deployment = (await response.json()) as ScriptDeploymentStatus
|
||||
if (deployment.lock != null) {
|
||||
return
|
||||
}
|
||||
if (deployment.lock_error_logs) {
|
||||
throw new Error(`Script deployment failed for ${path}: ${deployment.lock_error_logs}`)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, this.settings.pollIntervalMs))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for script ${path} (${hash}) to deploy`)
|
||||
}
|
||||
|
||||
private async request(path: string, init?: RequestInit): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
return await fetch(`${this.settings.baseUrl}/api${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
const cacheKey = `${this.settings.baseUrl}|${this.settings.email}`
|
||||
let tokenPromise = tokenCache.get(cacheKey)
|
||||
if (!tokenPromise) {
|
||||
tokenPromise = this.login().catch((error) => {
|
||||
if (tokenCache.get(cacheKey) === tokenPromise) {
|
||||
tokenCache.delete(cacheKey)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
tokenCache.set(cacheKey, tokenPromise)
|
||||
}
|
||||
return await tokenPromise
|
||||
}
|
||||
|
||||
private async login(): Promise<string> {
|
||||
const response = await fetch(`${this.settings.baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: this.settings.email,
|
||||
password: this.settings.password
|
||||
})
|
||||
})
|
||||
await expectOk(response, 'login for backend validation')
|
||||
return (await response.text()).trim()
|
||||
}
|
||||
}
|
||||
|
||||
async function withSharedWorkspaceLock<T>(workspaceId: string, body: () => Promise<T>): Promise<T> {
|
||||
const previous = sharedWorkspaceQueue.get(workspaceId) ?? Promise.resolve()
|
||||
let releaseCurrent: (() => void) | undefined
|
||||
const current = new Promise<void>((resolve) => {
|
||||
releaseCurrent = resolve
|
||||
})
|
||||
const tail = previous.catch(() => undefined).then(() => current)
|
||||
sharedWorkspaceQueue.set(workspaceId, tail)
|
||||
|
||||
await previous.catch(() => undefined)
|
||||
|
||||
try {
|
||||
return await body()
|
||||
} finally {
|
||||
releaseCurrent?.()
|
||||
if (sharedWorkspaceQueue.get(workspaceId) === tail) {
|
||||
sharedWorkspaceQueue.delete(workspaceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkspaceId(caseId: string, attempt: number): string {
|
||||
const caseSlug = caseId
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 30)
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}`
|
||||
}
|
||||
|
||||
function extractFolderName(path: string): string | null {
|
||||
if (!path.startsWith('f/')) {
|
||||
return null
|
||||
}
|
||||
const segments = path.split('/').slice(1, -1)
|
||||
return segments.length > 0 ? segments.join('/') : null
|
||||
}
|
||||
|
||||
function withQuery(
|
||||
path: string,
|
||||
params: Record<string, string | number | undefined>
|
||||
): string {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
query.set(key, String(value))
|
||||
}
|
||||
const suffix = query.toString()
|
||||
return suffix ? `${path}?${suffix}` : path
|
||||
}
|
||||
|
||||
async function expectOk(response: Response, context: string): Promise<void> {
|
||||
if (response.ok) {
|
||||
return
|
||||
}
|
||||
throw new Error(`${context} failed: ${response.status} ${response.statusText} - ${await response.text()}`)
|
||||
}
|
||||
|
||||
function readStringField(
|
||||
value: Record<string, unknown>,
|
||||
field: string,
|
||||
context: string
|
||||
): string {
|
||||
const candidate = value[field]
|
||||
if (typeof candidate === 'string' && candidate.length > 0) {
|
||||
return candidate
|
||||
}
|
||||
throw new Error(`${context} is missing string field ${field}`)
|
||||
}
|
||||
|
||||
function isConflictMessage(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
return normalized.includes('already exists') || normalized.includes('path conflict')
|
||||
}
|
||||
|
||||
function isManagedSharedWorkspacePath(path: string): boolean {
|
||||
return managedSharedWorkspacePrefixes.some((prefix) => path.startsWith(prefix))
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { loadSelectedCases } from "../../core/cases";
|
||||
import { resolveBackendValidationSettings } from "../../core/backendValidation";
|
||||
import {
|
||||
formatRunModelLabel,
|
||||
getFrontendEvalModel,
|
||||
resolveEvalModel,
|
||||
} from "../../core/models";
|
||||
import { buildRunResult } from "../../core/results";
|
||||
import { runSuite } from "../../core/runSuite";
|
||||
import type { BenchmarkRunResult, ModeRunner } from "../../core/types";
|
||||
import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings";
|
||||
import { emitFrontendBenchmarkProgress } from "./progress";
|
||||
import { DEFAULT_JUDGE_MODEL } from "../../core/judge";
|
||||
|
||||
export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global";
|
||||
|
||||
export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult> {
|
||||
const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE);
|
||||
const caseIds = parseOptionalJsonStringArray(
|
||||
process.env.WMILL_FRONTEND_AI_EVAL_CASE_IDS,
|
||||
);
|
||||
const runs = parsePositiveInteger(
|
||||
process.env.WMILL_FRONTEND_AI_EVAL_RUNS,
|
||||
"WMILL_FRONTEND_AI_EVAL_RUNS",
|
||||
);
|
||||
const emitProgress = process.env.WMILL_FRONTEND_AI_EVAL_PROGRESS === "1";
|
||||
const verbose = process.env.WMILL_FRONTEND_AI_EVAL_VERBOSE === "1";
|
||||
const model = resolveEvalModel(
|
||||
mode,
|
||||
process.env.WMILL_FRONTEND_AI_EVAL_MODEL,
|
||||
);
|
||||
const backendValidation = resolveBackendValidationSettings({
|
||||
evalMode: mode,
|
||||
requestedMode: process.env.WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION,
|
||||
});
|
||||
const backendSettings = resolveWindmillBackendSettings();
|
||||
|
||||
const selectedCases = await loadSelectedCases(mode, caseIds);
|
||||
const modeRunner = await getModeRunner(
|
||||
mode,
|
||||
getFrontendEvalModel(model),
|
||||
backendValidation,
|
||||
backendSettings,
|
||||
);
|
||||
const runModel = formatRunModelLabel(mode, model);
|
||||
const caseResults = await runSuite({
|
||||
modeRunner,
|
||||
cases: selectedCases,
|
||||
runs,
|
||||
runModel,
|
||||
judgeModel: DEFAULT_JUDGE_MODEL,
|
||||
concurrency: verbose ? 1 : undefined,
|
||||
verbose,
|
||||
onProgress: emitProgress
|
||||
? (event) => emitFrontendBenchmarkProgress(event)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return buildRunResult({
|
||||
mode,
|
||||
runs,
|
||||
runModel,
|
||||
judgeModel: DEFAULT_JUDGE_MODEL,
|
||||
caseResults,
|
||||
});
|
||||
}
|
||||
|
||||
async function getModeRunner(
|
||||
mode: FrontendBenchmarkMode,
|
||||
model: ReturnType<typeof getFrontendEvalModel>,
|
||||
backendValidation: ReturnType<typeof resolveBackendValidationSettings>,
|
||||
backendSettings: ReturnType<typeof resolveWindmillBackendSettings>,
|
||||
): Promise<ModeRunner<any, any, any>> {
|
||||
switch (mode) {
|
||||
case "flow": {
|
||||
const { createFlowModeRunner } = await import("../../modes/flow");
|
||||
return createFlowModeRunner(model, backendValidation, backendSettings);
|
||||
}
|
||||
case "app": {
|
||||
const { createAppModeRunner } = await import("../../modes/app");
|
||||
return createAppModeRunner(model, backendSettings);
|
||||
}
|
||||
case "script": {
|
||||
const { createScriptModeRunner } = await import("../../modes/script");
|
||||
return createScriptModeRunner(
|
||||
model,
|
||||
backendValidation,
|
||||
backendSettings,
|
||||
);
|
||||
}
|
||||
case "global": {
|
||||
const { createGlobalModeRunner } = await import("../../modes/global");
|
||||
return createGlobalModeRunner(model, backendSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseMode(value: string | undefined): FrontendBenchmarkMode {
|
||||
if (value === "flow" || value === "app" || value === "script" || value === "global") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`);
|
||||
}
|
||||
|
||||
function parseOptionalJsonStringArray(value: string | undefined): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.some((entry) => typeof entry !== "string")
|
||||
) {
|
||||
throw new Error(
|
||||
"WMILL_FRONTEND_AI_EVAL_CASE_IDS must be a JSON string array",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(
|
||||
value: string | undefined,
|
||||
envName: string,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${envName} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { mkdtemp } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import type {
|
||||
BackendRunnable,
|
||||
AppAIChatHelpers,
|
||||
DataTableSchema,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
|
||||
import {
|
||||
getAppTools,
|
||||
prepareAppSystemMessage,
|
||||
prepareAppUserMessage,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import { createAppFileHelpers, type AppEvalChatHelpers } from "./fileHelpers";
|
||||
import { runEval } from "../shared";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import type {
|
||||
EvalCaseRuntimeAppAdditionalContext,
|
||||
EvalCaseRuntimeAppContextSpec,
|
||||
ModeRunContext,
|
||||
} from "../../../../core/types";
|
||||
import type { TokenUsage } from "../shared/types";
|
||||
import type { AppFilesState } from "../../../../core/validators";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
import {
|
||||
createAppBackendRunnableContextElement,
|
||||
createAppDatatableContextElement,
|
||||
createAppFrontendFileContextElement,
|
||||
type ContextElement,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/context";
|
||||
|
||||
export interface AppEvalResult {
|
||||
success: boolean;
|
||||
files: AppFilesState;
|
||||
error?: string;
|
||||
assistantMessageCount: number;
|
||||
toolCallCount: number;
|
||||
toolsUsed: string[];
|
||||
tokenUsage: TokenUsage;
|
||||
}
|
||||
|
||||
export interface AppEvalOptions {
|
||||
initialFrontend?: Record<string, string>;
|
||||
initialBackend?: AppFilesState["backend"];
|
||||
initialDatatables?: AppFilesState["datatables"];
|
||||
appContext?: EvalCaseRuntimeAppContextSpec;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
|
||||
export async function runAppEval(
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
options: AppEvalOptions,
|
||||
): Promise<AppEvalResult> {
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot ??
|
||||
(await mkdtemp(join(tmpdir(), "wmill-frontend-app-benchmark-")));
|
||||
const { helpers, getEvalState, cleanup } = await createAppFileHelpers(
|
||||
options?.initialFrontend ?? {},
|
||||
(options?.initialBackend ?? {}) as Record<string, BackendRunnable>,
|
||||
options?.initialDatatables ?? [],
|
||||
workspaceRoot,
|
||||
);
|
||||
|
||||
try {
|
||||
const systemMessage = prepareAppSystemMessage();
|
||||
const tools = getAppTools() as ProductionTool<AppAIChatHelpers>[];
|
||||
const model = options?.model ?? "claude-haiku-4-5-20251001";
|
||||
const additionalContext = await buildAdditionalContext(
|
||||
options?.appContext,
|
||||
helpers,
|
||||
);
|
||||
const userMessage = prepareAppUserMessage(
|
||||
userPrompt,
|
||||
helpers.getSelectedContext(),
|
||||
additionalContext,
|
||||
);
|
||||
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
tools,
|
||||
helpers,
|
||||
apiKey,
|
||||
getOutput: getEvalState,
|
||||
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
|
||||
onAssistantToken: options?.runContext?.onAssistantChunk,
|
||||
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
|
||||
onToolCall: options?.runContext?.onToolCall,
|
||||
options: {
|
||||
maxIterations: options?.maxIterations,
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: options?.provider,
|
||||
backend: options.backend,
|
||||
caseId: options?.runContext?.caseId,
|
||||
attempt: options?.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
files: rawResult.output,
|
||||
success: rawResult.success,
|
||||
error: rawResult.error,
|
||||
assistantMessageCount: rawResult.iterations,
|
||||
toolCallCount: rawResult.toolCallsCount,
|
||||
toolsUsed: rawResult.toolsCalled,
|
||||
tokenUsage: rawResult.tokenUsage,
|
||||
};
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildAdditionalContext(
|
||||
appContext: EvalCaseRuntimeAppContextSpec | undefined,
|
||||
helpers: AppEvalChatHelpers,
|
||||
): Promise<ContextElement[]> {
|
||||
const entries = appContext?.additional ?? [];
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const datatables = entries.some((entry) => entry.type === "datatable")
|
||||
? await helpers.getDatatables()
|
||||
: [];
|
||||
|
||||
return entries.map((entry) =>
|
||||
buildAdditionalContextElement(entry, helpers, datatables),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAdditionalContextElement(
|
||||
entry: EvalCaseRuntimeAppAdditionalContext,
|
||||
helpers: AppAIChatHelpers,
|
||||
datatables: DataTableSchema[],
|
||||
): ContextElement {
|
||||
if (entry.type === "frontend") {
|
||||
const content = helpers.getFrontendFile(entry.path);
|
||||
if (content === undefined) {
|
||||
throw new Error(`App eval @ frontend context not found: ${entry.path}`);
|
||||
}
|
||||
return createAppFrontendFileContextElement(entry.path, content);
|
||||
}
|
||||
|
||||
if (entry.type === "backend") {
|
||||
const runnable = helpers.getBackendRunnable(entry.key);
|
||||
if (!runnable) {
|
||||
throw new Error(`App eval @ backend context not found: ${entry.key}`);
|
||||
}
|
||||
return createAppBackendRunnableContextElement(entry.key, runnable);
|
||||
}
|
||||
|
||||
const datatable = datatables.find(
|
||||
(candidate) => candidate.datatable_name === entry.datatableName,
|
||||
);
|
||||
const columns = datatable?.schemas?.[entry.schema]?.[entry.table];
|
||||
if (!columns) {
|
||||
throw new Error(
|
||||
`App eval @ datatable context not found: ${entry.datatableName}/${entry.schema}.${entry.table}`,
|
||||
);
|
||||
}
|
||||
|
||||
return createAppDatatableContextElement(
|
||||
entry.datatableName,
|
||||
entry.schema,
|
||||
entry.table,
|
||||
columns,
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadAppFixture } from "./appFixtureLoader";
|
||||
|
||||
const RECIPE_BOOK_FIXTURE = fileURLToPath(
|
||||
new URL("../../../../fixtures/frontend/app/initial/recipe_book", import.meta.url)
|
||||
);
|
||||
|
||||
describe("loadAppFixture", () => {
|
||||
it("loads datatables from app fixtures when present", async () => {
|
||||
const fixture = await loadAppFixture(RECIPE_BOOK_FIXTURE);
|
||||
|
||||
expect(fixture.datatables).toEqual([
|
||||
{
|
||||
datatable_name: "main",
|
||||
schemas: {
|
||||
public: {
|
||||
recipes: {
|
||||
id: "int4",
|
||||
name: "text",
|
||||
ingredients: "text",
|
||||
instructions: "text",
|
||||
created_at: "timestamp=now()",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import type {
|
||||
BackendRunnable,
|
||||
DataTableSchema,
|
||||
InlineScript,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
|
||||
import type { AppFilesState } from "../../../../core/validators";
|
||||
|
||||
/**
|
||||
* Backend runnable metadata stored in meta.json files.
|
||||
*/
|
||||
interface BackendMeta {
|
||||
name: string;
|
||||
language: "bun" | "python3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively reads all files in a directory and returns them as a record.
|
||||
* File paths are relative to the base directory with a leading '/'.
|
||||
*/
|
||||
async function readFilesRecursively(
|
||||
dir: string,
|
||||
basePath: string = "",
|
||||
): Promise<Record<string, string>> {
|
||||
// @ts-ignore - Node.js fs/promises
|
||||
const { readdir, readFile } = await import("fs/promises");
|
||||
// @ts-ignore - Node.js path
|
||||
const { join } = await import("path");
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
const relativePath = basePath
|
||||
? `${basePath}/${entry.name}`
|
||||
: `/${entry.name}`;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subFiles = await readFilesRecursively(fullPath, relativePath);
|
||||
Object.assign(result, subFiles);
|
||||
} else {
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
result[relativePath] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads frontend files from a directory.
|
||||
* All files are read recursively and paths become keys with leading '/'.
|
||||
*/
|
||||
async function loadFrontend(
|
||||
frontendPath: string,
|
||||
): Promise<Record<string, string>> {
|
||||
// @ts-ignore - Node.js fs/promises
|
||||
const { access } = await import("fs/promises");
|
||||
|
||||
try {
|
||||
await access(frontendPath);
|
||||
} catch {
|
||||
// Directory doesn't exist, return empty
|
||||
return {};
|
||||
}
|
||||
|
||||
return readFilesRecursively(frontendPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads backend runnables from a directory.
|
||||
* Each subdirectory is a runnable with:
|
||||
* - main.ts or main.py: The code content
|
||||
* - meta.json: Metadata { name, language }
|
||||
*/
|
||||
async function loadBackend(
|
||||
backendPath: string,
|
||||
): Promise<Record<string, BackendRunnable>> {
|
||||
// @ts-ignore - Node.js fs/promises
|
||||
const { readdir, readFile, access } = await import("fs/promises");
|
||||
// @ts-ignore - Node.js path
|
||||
const { join } = await import("path");
|
||||
|
||||
try {
|
||||
await access(backendPath);
|
||||
} catch {
|
||||
// Directory doesn't exist, return empty
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, BackendRunnable> = {};
|
||||
const entries = await readdir(backendPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const runnableKey = entry.name;
|
||||
const runnablePath = join(backendPath, entry.name);
|
||||
|
||||
// Read meta.json
|
||||
const metaPath = join(runnablePath, "meta.json");
|
||||
let meta: BackendMeta;
|
||||
try {
|
||||
const metaContent = await readFile(metaPath, "utf-8");
|
||||
meta = JSON.parse(metaContent);
|
||||
} catch {
|
||||
console.warn(
|
||||
`Missing or invalid meta.json for runnable '${runnableKey}', skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find and read the main file (main.ts or main.py)
|
||||
const runnableFiles = await readdir(runnablePath);
|
||||
const mainFile = runnableFiles.find(
|
||||
(f) => f === "main.ts" || f === "main.py",
|
||||
);
|
||||
|
||||
if (!mainFile) {
|
||||
console.warn(
|
||||
`No main.ts or main.py found for runnable '${runnableKey}', skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await readFile(join(runnablePath, mainFile), "utf-8");
|
||||
|
||||
const inlineScript: InlineScript = {
|
||||
language: meta.language,
|
||||
content,
|
||||
};
|
||||
|
||||
result[runnableKey] = {
|
||||
name: meta.name,
|
||||
type: "inline",
|
||||
inlineScript,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadDatatables(fixturePath: string): Promise<DataTableSchema[]> {
|
||||
// @ts-ignore - Node.js fs/promises
|
||||
const { readFile } = await import("fs/promises");
|
||||
// @ts-ignore - Node.js path
|
||||
const { join } = await import("path");
|
||||
|
||||
try {
|
||||
const content = await readFile(
|
||||
join(fixturePath, "datatables.json"),
|
||||
"utf-8",
|
||||
);
|
||||
const parsed = JSON.parse(content);
|
||||
return Array.isArray(parsed) ? (parsed as DataTableSchema[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an app fixture from a directory structure.
|
||||
*
|
||||
* Expected structure:
|
||||
* ```
|
||||
* fixturePath/
|
||||
* ├── frontend/
|
||||
* │ └── index.tsx # → frontend["/index.tsx"]
|
||||
* │ └── components/
|
||||
* │ └── Button.tsx # → frontend["/components/Button.tsx"]
|
||||
* └── backend/
|
||||
* └── incrementCounter/
|
||||
* ├── main.ts # The code content
|
||||
* └── meta.json # { "name": "...", "language": "bun" }
|
||||
* ```
|
||||
*
|
||||
* @param fixturePath - Path to the fixture directory
|
||||
* @returns AppFiles object with frontend and backend
|
||||
*/
|
||||
export async function loadAppFixture(
|
||||
fixturePath: string,
|
||||
): Promise<AppFilesState> {
|
||||
// @ts-ignore - Node.js path
|
||||
const { join } = await import("path");
|
||||
|
||||
const frontend = await loadFrontend(join(fixturePath, "frontend"));
|
||||
const backend = await loadBackend(join(fixturePath, "backend"));
|
||||
const datatables = await loadDatatables(fixturePath);
|
||||
|
||||
return { frontend, backend, datatables };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an app fixture and returns the separate frontend and backend objects.
|
||||
* Convenience function for use with runAppEval options.
|
||||
*/
|
||||
export async function loadAppFixtureForEval(fixturePath: string): Promise<{
|
||||
initialFrontend: Record<string, string>;
|
||||
initialBackend: AppFilesState["backend"];
|
||||
initialDatatables: DataTableSchema[];
|
||||
}> {
|
||||
const { frontend, backend, datatables } = await loadAppFixture(fixturePath);
|
||||
return {
|
||||
initialFrontend: frontend,
|
||||
initialBackend: backend,
|
||||
initialDatatables: datatables,
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createAppFileHelpers } from "./fileHelpers";
|
||||
|
||||
describe("createAppFileHelpers", () => {
|
||||
it("exposes generated wmill typings and returns real lint diagnostics", async () => {
|
||||
const { helpers, cleanup } = await createAppFileHelpers(
|
||||
{
|
||||
"/index.tsx":
|
||||
"import { backend } from 'wmill'\nexport default function App() { void backend.listRecipes(); return <div /> }\n",
|
||||
},
|
||||
{
|
||||
listRecipes: {
|
||||
name: "List recipes",
|
||||
type: "inline",
|
||||
inlineScript: {
|
||||
language: "bun",
|
||||
content: "export async function main() { return [] }\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
expect(helpers.listFrontendFiles()).toContain("/wmill.d.ts");
|
||||
expect(helpers.getFrontendFile("/wmill.d.ts")).toContain("listRecipes");
|
||||
|
||||
const lintResult = helpers.setFrontendFile(
|
||||
"/index.tsx",
|
||||
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n"
|
||||
);
|
||||
|
||||
expect(lintResult.errorCount).toBeGreaterThan(0);
|
||||
expect(lintResult.errors.frontend["/index.tsx"]?.join("\n")).toContain(
|
||||
"Property 'deleteRecipe' does not exist"
|
||||
);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,329 +0,0 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import type {
|
||||
AppAIChatHelpers,
|
||||
AppDatatableMetadata,
|
||||
AppFiles,
|
||||
BackendRunnable,
|
||||
DataTableSchema,
|
||||
LintResult,
|
||||
SelectedContext
|
||||
} from '../../../../../frontend/src/lib/components/copilot/chat/app/core'
|
||||
import { buildAppWmillTypes, collectAppDiagnostics } from '../../../../core/appDiagnostics'
|
||||
|
||||
export interface AppEvalChatHelpers extends AppAIChatHelpers {
|
||||
getDatatables: () => Promise<DataTableSchema[]>
|
||||
}
|
||||
|
||||
async function writeFrontendFile(
|
||||
workspaceRoot: string | undefined,
|
||||
path: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
if (!workspaceRoot) {
|
||||
return
|
||||
}
|
||||
const relativePath = path.startsWith('/') ? path.slice(1) : path
|
||||
const fullPath = join(workspaceRoot, 'frontend', relativePath)
|
||||
await mkdir(dirname(fullPath), { recursive: true })
|
||||
await writeFile(fullPath, content, 'utf8')
|
||||
}
|
||||
|
||||
async function removeFrontendFile(workspaceRoot: string | undefined, path: string): Promise<void> {
|
||||
if (!workspaceRoot) {
|
||||
return
|
||||
}
|
||||
const relativePath = path.startsWith('/') ? path.slice(1) : path
|
||||
await rm(join(workspaceRoot, 'frontend', relativePath), { force: true })
|
||||
}
|
||||
|
||||
async function writeBackendRunnable(
|
||||
workspaceRoot: string | undefined,
|
||||
key: string,
|
||||
runnable: BackendRunnable
|
||||
): Promise<void> {
|
||||
if (!workspaceRoot) {
|
||||
return
|
||||
}
|
||||
const runnableDir = join(workspaceRoot, 'backend', key)
|
||||
await mkdir(runnableDir, { recursive: true })
|
||||
|
||||
const meta: { name: string; language?: string; type?: string; path?: string } = {
|
||||
name: runnable.name
|
||||
}
|
||||
|
||||
if (runnable.type === 'inline' && runnable.inlineScript) {
|
||||
meta.language = runnable.inlineScript.language
|
||||
const extension = runnable.inlineScript.language === 'python3' ? 'py' : 'ts'
|
||||
await writeFile(
|
||||
join(runnableDir, `main.${extension}`),
|
||||
runnable.inlineScript.content,
|
||||
'utf8'
|
||||
)
|
||||
} else {
|
||||
meta.type = runnable.type
|
||||
if (runnable.path) {
|
||||
meta.path = runnable.path
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(join(runnableDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8')
|
||||
}
|
||||
|
||||
async function removeBackendRunnable(workspaceRoot: string | undefined, key: string): Promise<void> {
|
||||
if (!workspaceRoot) {
|
||||
return
|
||||
}
|
||||
await rm(join(workspaceRoot, 'backend', key), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function persistDatatables(
|
||||
workspaceRoot: string | undefined,
|
||||
datatables: DataTableSchema[]
|
||||
): Promise<void> {
|
||||
if (!workspaceRoot) {
|
||||
return
|
||||
}
|
||||
await writeFile(
|
||||
join(workspaceRoot, 'datatables.json'),
|
||||
JSON.stringify(datatables, null, 2) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export async function createAppFileHelpers(
|
||||
initialFrontend: Record<string, string> = {},
|
||||
initialBackend: Record<string, BackendRunnable> = {},
|
||||
initialDatatables: DataTableSchema[] = [],
|
||||
workspaceRoot?: string
|
||||
): Promise<{
|
||||
helpers: AppEvalChatHelpers
|
||||
getFiles: () => AppFiles
|
||||
getEvalState: () => {
|
||||
frontend: Record<string, string>
|
||||
backend: Record<string, BackendRunnable>
|
||||
datatables: DataTableSchema[]
|
||||
}
|
||||
getFrontend: () => Record<string, string>
|
||||
getBackend: () => Record<string, BackendRunnable>
|
||||
getDatatables: () => DataTableSchema[]
|
||||
cleanup: () => Promise<void>
|
||||
workspaceDir: string | null
|
||||
}> {
|
||||
let frontend = { ...initialFrontend }
|
||||
let backend = { ...initialBackend }
|
||||
let snapshotId = 0
|
||||
const snapshots = new Map<
|
||||
number,
|
||||
{
|
||||
frontend: Record<string, string>
|
||||
backend: Record<string, BackendRunnable>
|
||||
datatables: DataTableSchema[]
|
||||
}
|
||||
>()
|
||||
const datatables: DataTableSchema[] = structuredClone(initialDatatables)
|
||||
|
||||
function lint(): LintResult {
|
||||
return collectAppDiagnostics({
|
||||
frontend,
|
||||
backend
|
||||
}).lintResult
|
||||
}
|
||||
|
||||
function getGeneratedWmillTypes(): string {
|
||||
return buildAppWmillTypes(backend)
|
||||
}
|
||||
|
||||
for (const [path, content] of Object.entries(frontend)) {
|
||||
await writeFrontendFile(workspaceRoot, path, content)
|
||||
}
|
||||
for (const [key, runnable] of Object.entries(backend)) {
|
||||
await writeBackendRunnable(workspaceRoot, key, runnable)
|
||||
}
|
||||
await persistDatatables(workspaceRoot, datatables)
|
||||
|
||||
const helpers: AppEvalChatHelpers = {
|
||||
listFrontendFiles: () => [
|
||||
...Object.keys(frontend).filter((path) => path !== '/wmill.d.ts'),
|
||||
'/wmill.d.ts'
|
||||
],
|
||||
getFrontendFile: (path: string) => {
|
||||
if (path === '/wmill.d.ts') {
|
||||
return getGeneratedWmillTypes()
|
||||
}
|
||||
return frontend[path]
|
||||
},
|
||||
getFrontendFiles: () => ({
|
||||
...Object.fromEntries(
|
||||
Object.entries(frontend).filter(([path]) => path !== '/wmill.d.ts')
|
||||
),
|
||||
'/wmill.d.ts': getGeneratedWmillTypes()
|
||||
}),
|
||||
setFrontendFile: (path: string, content: string) => {
|
||||
if (path === '/wmill.d.ts') {
|
||||
return lint()
|
||||
}
|
||||
frontend[path] = content
|
||||
void writeFrontendFile(workspaceRoot, path, content)
|
||||
return lint()
|
||||
},
|
||||
deleteFrontendFile: (path: string) => {
|
||||
if (path === '/wmill.d.ts') {
|
||||
return
|
||||
}
|
||||
delete frontend[path]
|
||||
void removeFrontendFile(workspaceRoot, path)
|
||||
},
|
||||
listBackendRunnables: () =>
|
||||
Object.entries(backend).map(([key, runnable]) => ({
|
||||
key,
|
||||
name: runnable.name
|
||||
})),
|
||||
getBackendRunnable: (key: string) => backend[key],
|
||||
getBackendRunnables: () => ({ ...backend }),
|
||||
setBackendRunnable: async (key: string, runnable: BackendRunnable) => {
|
||||
backend[key] = runnable
|
||||
await writeBackendRunnable(workspaceRoot, key, runnable)
|
||||
return lint()
|
||||
},
|
||||
deleteBackendRunnable: (key: string) => {
|
||||
delete backend[key]
|
||||
void removeBackendRunnable(workspaceRoot, key)
|
||||
},
|
||||
getFiles: (): AppFiles => ({
|
||||
frontend: { ...frontend },
|
||||
backend: { ...backend }
|
||||
}),
|
||||
getSelectedContext: (): SelectedContext => ({}),
|
||||
snapshot: () => {
|
||||
const id = ++snapshotId
|
||||
snapshots.set(id, {
|
||||
frontend: { ...frontend },
|
||||
backend: { ...backend },
|
||||
datatables: structuredClone(datatables)
|
||||
})
|
||||
return id
|
||||
},
|
||||
revertToSnapshot: (id: number) => {
|
||||
const snapshot = snapshots.get(id)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
frontend = { ...snapshot.frontend }
|
||||
backend = { ...snapshot.backend }
|
||||
datatables.splice(0, datatables.length, ...structuredClone(snapshot.datatables))
|
||||
void syncWorkspace()
|
||||
},
|
||||
lint,
|
||||
getDatatables: async () => structuredClone(datatables),
|
||||
listDatatableTables: async () =>
|
||||
datatables.map(
|
||||
(datatable): AppDatatableMetadata => {
|
||||
const schemas = Object.fromEntries(
|
||||
Object.entries(datatable.schemas).map(([schemaName, tables]) => [
|
||||
schemaName,
|
||||
Object.keys(tables)
|
||||
])
|
||||
)
|
||||
return {
|
||||
datatable_name: datatable.datatable_name,
|
||||
schemas,
|
||||
tableCount: Object.values(schemas).reduce(
|
||||
(sum, tableNames) => sum + tableNames.length,
|
||||
0
|
||||
),
|
||||
error: datatable.error
|
||||
}
|
||||
}
|
||||
),
|
||||
getDatatableTableSchema: async (
|
||||
datatableName: string,
|
||||
schemaName: string,
|
||||
tableName: string
|
||||
) => {
|
||||
const datatable = datatables.find((entry) => entry.datatable_name === datatableName)
|
||||
return structuredClone(datatable?.schemas?.[schemaName]?.[tableName] ?? {})
|
||||
},
|
||||
getAvailableDatatableNames: () => datatables.map((datatable) => datatable.datatable_name),
|
||||
execDatatableSql: async (
|
||||
datatableName: string,
|
||||
sql: string,
|
||||
newTable?: { schema: string; name: string }
|
||||
) => {
|
||||
if (newTable) {
|
||||
datatables.push({
|
||||
datatable_name: datatableName,
|
||||
schemas: {
|
||||
[newTable.schema]: {
|
||||
[newTable.name]: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
await persistDatatables(workspaceRoot, datatables)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
result: [
|
||||
{
|
||||
datatableName,
|
||||
sql
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
addTableToWhitelist: (datatableName: string, schemaName: string, tableName: string) => {
|
||||
const existing = datatables.find((entry) => entry.datatable_name === datatableName)
|
||||
if (existing) {
|
||||
existing.schemas[schemaName] ??= {}
|
||||
existing.schemas[schemaName][tableName] ??= {}
|
||||
} else {
|
||||
datatables.push({
|
||||
datatable_name: datatableName,
|
||||
schemas: {
|
||||
[schemaName]: {
|
||||
[tableName]: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
void persistDatatables(workspaceRoot, datatables)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWorkspace(): Promise<void> {
|
||||
if (!workspaceRoot) {
|
||||
return
|
||||
}
|
||||
await rm(join(workspaceRoot, 'frontend'), { recursive: true, force: true })
|
||||
await rm(join(workspaceRoot, 'backend'), { recursive: true, force: true })
|
||||
for (const [path, content] of Object.entries(frontend)) {
|
||||
await writeFrontendFile(workspaceRoot, path, content)
|
||||
}
|
||||
for (const [key, runnable] of Object.entries(backend)) {
|
||||
await writeBackendRunnable(workspaceRoot, key, runnable)
|
||||
}
|
||||
await persistDatatables(workspaceRoot, datatables)
|
||||
}
|
||||
|
||||
return {
|
||||
helpers,
|
||||
getFiles: () => ({
|
||||
frontend: { ...frontend },
|
||||
backend: { ...backend }
|
||||
}),
|
||||
getEvalState: () => ({
|
||||
frontend: { ...frontend },
|
||||
backend: { ...backend },
|
||||
datatables: structuredClone(datatables)
|
||||
}),
|
||||
getFrontend: () => ({ ...frontend }),
|
||||
getBackend: () => ({ ...backend }),
|
||||
getDatatables: () => structuredClone(datatables),
|
||||
cleanup: async () => {
|
||||
if (workspaceRoot) {
|
||||
await rm(workspaceRoot, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
workspaceDir: workspaceRoot ?? null
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import type { FlowModule, InputTransform } from '../../../../../frontend/src/lib/gen'
|
||||
import type { ExtendedOpenFlow } from '../../../../../frontend/src/lib/components/flows/types'
|
||||
import type { FlowAIChatHelpers } from '../../../../../frontend/src/lib/components/copilot/chat/flow/core'
|
||||
import type { ScriptLintResult } from '../../../../../frontend/src/lib/components/copilot/chat/shared'
|
||||
import type { WorkspaceMutationTarget } from '../../../../../frontend/src/lib/components/copilot/chat/workspaceTools'
|
||||
import {
|
||||
createInlineScriptSession
|
||||
} from '../../../../../frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils'
|
||||
import {
|
||||
applyFlowJsonUpdate,
|
||||
updateRawScriptModuleContent
|
||||
} from '../../../../../frontend/src/lib/components/copilot/chat/flow/helperUtils'
|
||||
import {
|
||||
registerBenchmarkWorkspace,
|
||||
registerBenchmarkWorkspaceRunnables,
|
||||
unregisterBenchmarkWorkspaceRunnables,
|
||||
createBenchmarkCompletedJob,
|
||||
type BenchmarkWorkspaceFlow,
|
||||
type BenchmarkWorkspaceScript
|
||||
} from '../../mockBackend'
|
||||
|
||||
const EMPTY_SCRIPT_LINT_RESULT: ScriptLintResult = {
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
errors: [],
|
||||
warnings: []
|
||||
}
|
||||
|
||||
export interface FlowWorkspaceFixtures {
|
||||
scripts?: BenchmarkWorkspaceScript[]
|
||||
flows?: BenchmarkWorkspaceFlow[]
|
||||
}
|
||||
|
||||
export async function createFlowFileHelpers(
|
||||
initialModules: FlowModule[] = [],
|
||||
initialSchema?: Record<string, any>,
|
||||
initialPreprocessorModule?: FlowModule,
|
||||
initialFailureModule?: FlowModule,
|
||||
workspaceRoot?: string,
|
||||
workspaceFixtures?: FlowWorkspaceFixtures,
|
||||
currentFlowPath?: string
|
||||
): Promise<{
|
||||
helpers: FlowAIChatHelpers
|
||||
getFlow: () => ExtendedOpenFlow
|
||||
getModules: () => FlowModule[]
|
||||
cleanup: () => Promise<void>
|
||||
workspaceDir: string | null
|
||||
}> {
|
||||
let flow: ExtendedOpenFlow = {
|
||||
value: {
|
||||
modules: structuredClone(initialModules),
|
||||
preprocessor_module: structuredClone(initialPreprocessorModule),
|
||||
failure_module: structuredClone(initialFailureModule)
|
||||
},
|
||||
summary: '',
|
||||
schema: initialSchema ?? {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
properties: {},
|
||||
required: [],
|
||||
type: 'object'
|
||||
}
|
||||
}
|
||||
const inlineScriptSession = createInlineScriptSession()
|
||||
|
||||
const flowFilePath = workspaceRoot ? join(workspaceRoot, 'flow.json') : null
|
||||
|
||||
async function persistFlow(): Promise<void> {
|
||||
if (!flowFilePath) {
|
||||
return
|
||||
}
|
||||
await mkdir(dirname(flowFilePath), { recursive: true })
|
||||
await writeFile(flowFilePath, JSON.stringify(flow, null, 2) + '\n', 'utf8')
|
||||
}
|
||||
|
||||
await persistFlow()
|
||||
|
||||
if (workspaceRoot) {
|
||||
registerBenchmarkWorkspace(workspaceRoot)
|
||||
if (workspaceFixtures) {
|
||||
registerBenchmarkWorkspaceRunnables(workspaceRoot, workspaceFixtures)
|
||||
}
|
||||
}
|
||||
|
||||
const setFlowJson: FlowAIChatHelpers['setFlowJson'] = async ({
|
||||
modules,
|
||||
schema,
|
||||
preprocessorModule,
|
||||
failureModule
|
||||
}) => {
|
||||
const result = applyFlowJsonUpdate(flow, inlineScriptSession, {
|
||||
modules,
|
||||
schema,
|
||||
preprocessorModule,
|
||||
failureModule
|
||||
})
|
||||
await persistFlow()
|
||||
return result
|
||||
}
|
||||
|
||||
const helpers: FlowAIChatHelpers & {
|
||||
getWorkspaceMutationTarget: () => WorkspaceMutationTarget
|
||||
} = {
|
||||
getFlowAndSelectedId: () => ({ flow, selectedId: '' }),
|
||||
getRootModules: () => flow.value.modules,
|
||||
inlineScriptSession,
|
||||
getWorkspaceMutationTarget: () => ({
|
||||
kind: 'flow',
|
||||
path: currentFlowPath,
|
||||
deployed: Boolean(currentFlowPath)
|
||||
}),
|
||||
setSnapshot: () => {},
|
||||
revertToSnapshot: () => {},
|
||||
setCode: async (id: string, code: string) => {
|
||||
updateRawScriptModuleContent(flow, id, code)
|
||||
inlineScriptSession.set(id, code)
|
||||
await persistFlow()
|
||||
},
|
||||
setFlowJson,
|
||||
getFlowInputsSchema: async () => flow.schema ?? {},
|
||||
updateExprsToSet: (_id: string, _inputTransforms: Record<string, InputTransform>) => {},
|
||||
acceptAllModuleActions: () => {},
|
||||
rejectAllModuleActions: () => {},
|
||||
hasPendingChanges: () => false,
|
||||
selectStep: (_id: string) => {},
|
||||
testFlow: async (args?: Record<string, any>) => {
|
||||
if (workspaceRoot) {
|
||||
const runPath = join(workspaceRoot, 'test-run.json')
|
||||
await writeFile(
|
||||
runPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
requestedArgs: args ?? {},
|
||||
modules: flow.value.modules.map((module) => module.id),
|
||||
preprocessor_module: flow.value.preprocessor_module?.id ?? null,
|
||||
failure_module: flow.value.failure_module?.id ?? null
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
return createBenchmarkCompletedJob({
|
||||
workspace: workspaceRoot ?? 'benchmark',
|
||||
jobKind: 'flowpreview',
|
||||
result: {
|
||||
requestedArgs: args ?? {},
|
||||
modules: flow.value.modules.map((module) => module.id),
|
||||
preprocessor_module: flow.value.preprocessor_module?.id ?? null,
|
||||
failure_module: flow.value.failure_module?.id ?? null,
|
||||
mocked: true
|
||||
},
|
||||
logs: 'Mock benchmark flow test run completed successfully.'
|
||||
})
|
||||
},
|
||||
getLintErrors: async () => EMPTY_SCRIPT_LINT_RESULT
|
||||
}
|
||||
|
||||
return {
|
||||
helpers,
|
||||
getFlow: () => flow,
|
||||
getModules: () => flow.value.modules,
|
||||
cleanup: async () => {
|
||||
if (workspaceRoot) {
|
||||
unregisterBenchmarkWorkspaceRunnables(workspaceRoot)
|
||||
}
|
||||
if (workspaceRoot) {
|
||||
await rm(workspaceRoot, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
workspaceDir: workspaceRoot ?? null
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { mkdtemp } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import type { FlowModule } from "$lib/gen";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import type { ExtendedOpenFlow } from "$lib/components/flows/types";
|
||||
import {
|
||||
flowTools,
|
||||
prepareFlowSystemMessage,
|
||||
prepareFlowUserMessage,
|
||||
type FlowAIChatHelpers,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/flow/core";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import {
|
||||
createFlowFileHelpers,
|
||||
type FlowWorkspaceFixtures,
|
||||
} from "./fileHelpers";
|
||||
import { runEval } from "../shared";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { TokenUsage, ToolCallDetail } from "../shared/types";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
|
||||
export interface FlowFixture {
|
||||
path?: string;
|
||||
value?: {
|
||||
modules?: FlowModule[];
|
||||
preprocessor_module?: FlowModule;
|
||||
failure_module?: FlowModule;
|
||||
};
|
||||
schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FlowEvalResult {
|
||||
success: boolean;
|
||||
flow: ExtendedOpenFlow;
|
||||
error?: string;
|
||||
assistantMessageCount: number;
|
||||
toolCallCount: number;
|
||||
toolsUsed: string[];
|
||||
toolCallDetails: ToolCallDetail[];
|
||||
tokenUsage: TokenUsage;
|
||||
}
|
||||
|
||||
export interface FlowEvalOptions {
|
||||
initialFlow?: FlowFixture;
|
||||
workspaceFixtures?: FlowWorkspaceFixtures;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
|
||||
export async function runFlowEval(
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
options: FlowEvalOptions,
|
||||
): Promise<FlowEvalResult> {
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot ??
|
||||
(await mkdtemp(join(tmpdir(), "wmill-frontend-flow-benchmark-")));
|
||||
const { helpers, getFlow, cleanup } = await createFlowFileHelpers(
|
||||
options?.initialFlow?.value?.modules ?? [],
|
||||
options?.initialFlow?.schema,
|
||||
options?.initialFlow?.value?.preprocessor_module,
|
||||
options?.initialFlow?.value?.failure_module,
|
||||
workspaceRoot,
|
||||
options?.workspaceFixtures,
|
||||
options?.initialFlow?.path,
|
||||
);
|
||||
|
||||
try {
|
||||
const systemMessage = prepareFlowSystemMessage();
|
||||
const tools = flowTools as ProductionTool<FlowAIChatHelpers>[];
|
||||
const model = options?.model ?? "claude-haiku-4-5-20251001";
|
||||
const userMessage = prepareFlowUserMessage(
|
||||
userPrompt,
|
||||
helpers.getFlowAndSelectedId(),
|
||||
[],
|
||||
helpers.inlineScriptSession,
|
||||
);
|
||||
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
tools,
|
||||
helpers,
|
||||
apiKey,
|
||||
getOutput: getFlow,
|
||||
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
|
||||
onAssistantToken: options?.runContext?.onAssistantChunk,
|
||||
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
|
||||
onToolCall: options?.runContext?.onToolCall,
|
||||
options: {
|
||||
maxIterations: options?.maxIterations,
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: options?.provider,
|
||||
backend: options.backend,
|
||||
caseId: options?.runContext?.caseId,
|
||||
attempt: options?.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
flow: rawResult.output,
|
||||
success: rawResult.success,
|
||||
error: rawResult.error,
|
||||
assistantMessageCount: rawResult.iterations,
|
||||
toolCallCount: rawResult.toolCallsCount,
|
||||
toolsUsed: rawResult.toolsCalled,
|
||||
toolCallDetails: rawResult.toolCallDetails,
|
||||
tokenUsage: rawResult.tokenUsage,
|
||||
};
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import {
|
||||
globalTools,
|
||||
prepareGlobalSystemMessage,
|
||||
prepareGlobalUserMessage,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
|
||||
import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { GlobalDraftState } from "../../../../core/validators";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
import {
|
||||
registerBenchmarkWorkspaceRunnables,
|
||||
unregisterBenchmarkWorkspaceRunnables,
|
||||
type BenchmarkWorkspaceRunnables,
|
||||
} from "../../mockBackend";
|
||||
import { runEval } from "../shared";
|
||||
import type { TokenUsage, ToolCallDetail } from "../shared/types";
|
||||
|
||||
const MUTATING_GLOBAL_TOOLS = new Set([
|
||||
"deploy_workspace_item",
|
||||
"delete_workspace_item",
|
||||
]);
|
||||
|
||||
export interface GlobalEvalResult {
|
||||
success: boolean;
|
||||
state: GlobalDraftState;
|
||||
error?: string;
|
||||
assistantMessageCount: number;
|
||||
toolCallCount: number;
|
||||
toolsUsed: string[];
|
||||
toolCallDetails: ToolCallDetail[];
|
||||
tokenUsage: TokenUsage;
|
||||
}
|
||||
|
||||
export interface GlobalEvalOptions {
|
||||
workspaceFixtures?: BenchmarkWorkspaceRunnables;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
|
||||
export async function runGlobalEval(
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
options: GlobalEvalOptions,
|
||||
): Promise<GlobalEvalResult> {
|
||||
const workspaceRoot =
|
||||
options.workspaceRoot ??
|
||||
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
|
||||
|
||||
globalDraftStore.clearDrafts(workspaceRoot);
|
||||
registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {});
|
||||
|
||||
try {
|
||||
const model = options.model ?? "claude-haiku-4-5-20251001";
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
systemMessage: prepareGlobalSystemMessage(),
|
||||
userMessage: prepareGlobalUserMessage(userPrompt),
|
||||
tools: getGlobalEvalTools(),
|
||||
helpers: {},
|
||||
apiKey,
|
||||
getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }),
|
||||
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
|
||||
onAssistantToken: options.runContext?.onAssistantChunk,
|
||||
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
|
||||
onToolCall: options.runContext?.onToolCall,
|
||||
options: {
|
||||
maxIterations: options.maxIterations,
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: options.provider,
|
||||
backend: options.backend,
|
||||
caseId: options.runContext?.caseId,
|
||||
attempt: options.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
state: rawResult.output,
|
||||
success: rawResult.success,
|
||||
error: rawResult.error,
|
||||
assistantMessageCount: rawResult.iterations,
|
||||
toolCallCount: rawResult.toolCallsCount,
|
||||
toolsUsed: rawResult.toolsCalled,
|
||||
toolCallDetails: rawResult.toolCallDetails,
|
||||
tokenUsage: rawResult.tokenUsage,
|
||||
};
|
||||
} finally {
|
||||
globalDraftStore.clearDrafts(workspaceRoot);
|
||||
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
|
||||
if (!options.workspaceRoot) {
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalEvalTools(): ProductionTool<{}>[] {
|
||||
return (globalTools as ProductionTool<{}>[]).map((tool) => {
|
||||
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
return {
|
||||
...tool,
|
||||
requiresConfirmation: false,
|
||||
validateBeforeConfirmation: undefined,
|
||||
fn: async () =>
|
||||
JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
"This mutating workspace tool is disabled during ai_evals global mode.",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import type { ScriptLang } from '../../../../../frontend/src/lib/gen/types.gen'
|
||||
import type { ScriptChatHelpers } from '../../../../../frontend/src/lib/components/copilot/chat/script/core'
|
||||
import type { WorkspaceMutationTarget } from '../../../../../frontend/src/lib/components/copilot/chat/workspaceTools'
|
||||
import { buildScriptLintResult } from './preview'
|
||||
import { registerBenchmarkWorkspace, unregisterBenchmarkWorkspace } from '../../mockBackend'
|
||||
|
||||
export interface ScriptEvalState {
|
||||
code: string
|
||||
lang: ScriptLang | 'bunnative'
|
||||
path: string
|
||||
args: Record<string, any>
|
||||
}
|
||||
|
||||
function toRunnablePath(filePath: string): string {
|
||||
return filePath.replace(/\.[^/.]+$/, '')
|
||||
}
|
||||
|
||||
export async function createScriptFileHelpers(
|
||||
initialScript: ScriptEvalState,
|
||||
workspaceRoot?: string
|
||||
): Promise<{
|
||||
helpers: ScriptChatHelpers
|
||||
getScript: () => ScriptEvalState
|
||||
cleanup: () => Promise<void>
|
||||
workspaceDir: string | null
|
||||
}> {
|
||||
let script = structuredClone(initialScript)
|
||||
const scriptFilePath = workspaceRoot ? join(workspaceRoot, script.path) : null
|
||||
|
||||
async function persistScript(): Promise<void> {
|
||||
if (!scriptFilePath) {
|
||||
return
|
||||
}
|
||||
await mkdir(dirname(scriptFilePath), { recursive: true })
|
||||
await writeFile(scriptFilePath, script.code, 'utf8')
|
||||
}
|
||||
|
||||
await persistScript()
|
||||
|
||||
if (workspaceRoot) {
|
||||
registerBenchmarkWorkspace(workspaceRoot)
|
||||
}
|
||||
|
||||
const applyCode: NonNullable<ScriptChatHelpers['applyCode']> = async (
|
||||
code,
|
||||
opts
|
||||
) => {
|
||||
if (opts?.mode === 'revert') {
|
||||
return
|
||||
}
|
||||
script = {
|
||||
...script,
|
||||
code
|
||||
}
|
||||
await persistScript()
|
||||
}
|
||||
|
||||
const getLintErrors: NonNullable<ScriptChatHelpers['getLintErrors']> = () =>
|
||||
buildScriptLintResult(script.code, script.lang)
|
||||
|
||||
const helpers: ScriptChatHelpers & {
|
||||
getWorkspaceMutationTarget: () => WorkspaceMutationTarget
|
||||
} = {
|
||||
getScriptOptions: () => ({
|
||||
code: script.code,
|
||||
lang: script.lang,
|
||||
path: script.path,
|
||||
args: structuredClone(script.args)
|
||||
}),
|
||||
getWorkspaceMutationTarget: () => ({
|
||||
kind: 'script',
|
||||
path: script.path ? toRunnablePath(script.path) : undefined,
|
||||
deployed: Boolean(script.path)
|
||||
}),
|
||||
applyCode,
|
||||
getLintErrors
|
||||
}
|
||||
|
||||
return {
|
||||
helpers,
|
||||
getScript: () => structuredClone(script),
|
||||
cleanup: async () => {
|
||||
if (workspaceRoot) {
|
||||
unregisterBenchmarkWorkspace(workspaceRoot)
|
||||
await rm(workspaceRoot, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
workspaceDir: workspaceRoot ?? null
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import ts from 'typescript'
|
||||
import type { ScriptLang } from '../../../../../frontend/src/lib/gen/types.gen'
|
||||
import type { ScriptLintResult } from '../../../../../frontend/src/lib/components/copilot/chat/shared'
|
||||
|
||||
export type ScriptPreviewLanguage = ScriptLang | 'bunnative'
|
||||
|
||||
const TS_LIKE_LANGUAGES = new Set<ScriptPreviewLanguage>(['bun', 'deno', 'nativets', 'bunnative'])
|
||||
const JS_LIKE_LANGUAGES = new Set<ScriptPreviewLanguage>(['bun', 'deno', 'nativets', 'bunnative'])
|
||||
|
||||
function hasSupportedEntrypoint(code: string): boolean {
|
||||
return (
|
||||
/export\s+(async\s+)?function\s+main\s*\(/.test(code) ||
|
||||
/export\s+(async\s+)?function\s+preprocessor\s*\(/.test(code)
|
||||
)
|
||||
}
|
||||
|
||||
function compilerOptionsForLanguage(lang: ScriptPreviewLanguage): ts.CompilerOptions | null {
|
||||
if (!TS_LIKE_LANGUAGES.has(lang)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
noEmit: true,
|
||||
allowJs: true,
|
||||
checkJs: false,
|
||||
strict: false,
|
||||
skipLibCheck: true
|
||||
}
|
||||
}
|
||||
|
||||
function getLineAndColumn(sourceText: string, start: number): { line: number; column: number } {
|
||||
const prefix = sourceText.slice(0, Math.max(0, start))
|
||||
const line = prefix.split('\n').length
|
||||
const lastNewline = prefix.lastIndexOf('\n')
|
||||
const column = lastNewline === -1 ? prefix.length + 1 : prefix.length - lastNewline
|
||||
return { line, column }
|
||||
}
|
||||
|
||||
export function buildScriptLintResult(
|
||||
code: string,
|
||||
lang: ScriptPreviewLanguage
|
||||
): ScriptLintResult {
|
||||
const diagnostics: ScriptLintResult['errors'] = []
|
||||
const compilerOptions = compilerOptionsForLanguage(lang)
|
||||
|
||||
if (compilerOptions) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
'script.ts',
|
||||
code,
|
||||
ts.ScriptTarget.ES2022,
|
||||
true,
|
||||
JS_LIKE_LANGUAGES.has(lang) ? ts.ScriptKind.TS : ts.ScriptKind.JS
|
||||
)
|
||||
const output = ts.transpileModule(code, {
|
||||
compilerOptions,
|
||||
fileName: sourceFile.fileName,
|
||||
reportDiagnostics: true
|
||||
})
|
||||
|
||||
for (const diagnostic of output.diagnostics ?? []) {
|
||||
const start = diagnostic.start ?? 0
|
||||
const length = diagnostic.length ?? 1
|
||||
const { line, column } = getLineAndColumn(code, start)
|
||||
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
|
||||
diagnostics.push({
|
||||
startLineNumber: line,
|
||||
startColumn: column,
|
||||
endLineNumber: line,
|
||||
endColumn: column + Math.max(1, length),
|
||||
message,
|
||||
severity: 8
|
||||
} as ScriptLintResult['errors'][number])
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSupportedEntrypoint(code)) {
|
||||
diagnostics.push({
|
||||
startLineNumber: 1,
|
||||
startColumn: 1,
|
||||
endLineNumber: 1,
|
||||
endColumn: 1,
|
||||
message: 'Script must export a main or preprocessor function.',
|
||||
severity: 8
|
||||
} as ScriptLintResult['errors'][number])
|
||||
}
|
||||
|
||||
return {
|
||||
errorCount: diagnostics.length,
|
||||
warningCount: 0,
|
||||
errors: diagnostics,
|
||||
warnings: []
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { mkdtemp } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import type { AIProvider, AIProviderModel } from "$lib/gen/types.gen";
|
||||
import type { ContextElement } from "../../../../../frontend/src/lib/components/copilot/chat/context";
|
||||
import {
|
||||
prepareScriptSystemMessage,
|
||||
prepareScriptTools,
|
||||
prepareScriptUserMessage,
|
||||
type ScriptChatHelpers,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/script/core";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import { createScriptFileHelpers, type ScriptEvalState } from "./fileHelpers";
|
||||
import { runEval } from "../shared";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { TokenUsage, ToolCallDetail } from "../shared/types";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
|
||||
export interface ScriptEvalResult {
|
||||
success: boolean;
|
||||
script: ScriptEvalState;
|
||||
error?: string;
|
||||
assistantMessageCount: number;
|
||||
toolCallCount: number;
|
||||
toolsUsed: string[];
|
||||
toolCallDetails: ToolCallDetail[];
|
||||
tokenUsage: TokenUsage;
|
||||
}
|
||||
|
||||
export interface ScriptEvalOptions {
|
||||
initialScript: ScriptEvalState;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
|
||||
function resolveModelProvider(
|
||||
model: string,
|
||||
provider?: AIProvider,
|
||||
): AIProviderModel {
|
||||
if (provider) {
|
||||
return { provider, model };
|
||||
}
|
||||
if (model.startsWith("claude")) {
|
||||
return { provider: "anthropic", model };
|
||||
}
|
||||
return { provider: "openai", model };
|
||||
}
|
||||
|
||||
export async function runScriptEval(
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
options: ScriptEvalOptions,
|
||||
): Promise<ScriptEvalResult> {
|
||||
const workspaceRoot =
|
||||
options.workspaceRoot ??
|
||||
(await mkdtemp(join(tmpdir(), "wmill-frontend-script-benchmark-")));
|
||||
const { helpers, getScript, cleanup } = await createScriptFileHelpers(
|
||||
options.initialScript,
|
||||
workspaceRoot,
|
||||
);
|
||||
|
||||
try {
|
||||
const model = options.model ?? "claude-haiku-4-5-20251001";
|
||||
const modelProvider = resolveModelProvider(model, options.provider);
|
||||
const selectedContext: ContextElement[] = [];
|
||||
const systemMessage = prepareScriptSystemMessage(
|
||||
modelProvider,
|
||||
options.initialScript.lang,
|
||||
{},
|
||||
);
|
||||
const tools = prepareScriptTools(
|
||||
modelProvider,
|
||||
options.initialScript.lang,
|
||||
selectedContext,
|
||||
) as ProductionTool<ScriptChatHelpers>[];
|
||||
const userMessage = prepareScriptUserMessage(userPrompt, selectedContext);
|
||||
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
tools,
|
||||
helpers,
|
||||
apiKey,
|
||||
getOutput: getScript,
|
||||
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
|
||||
onAssistantToken: options.runContext?.onAssistantChunk,
|
||||
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
|
||||
onToolCall: options.runContext?.onToolCall,
|
||||
options: {
|
||||
maxIterations: options.maxIterations,
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: modelProvider.provider,
|
||||
backend: options.backend,
|
||||
caseId: options.runContext?.caseId,
|
||||
attempt: options.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
script: rawResult.output,
|
||||
success: rawResult.success,
|
||||
error: rawResult.error,
|
||||
assistantMessageCount: rawResult.iterations,
|
||||
toolCallCount: rawResult.toolCallsCount,
|
||||
toolsUsed: rawResult.toolsCalled,
|
||||
toolCallDetails: rawResult.toolCallDetails,
|
||||
tokenUsage: rawResult.tokenUsage,
|
||||
};
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import type {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
} from "openai/resources/chat/completions.mjs";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import type { ToolCallDetail, EvalRunnerOptions, RawEvalResult } from "./types";
|
||||
import {
|
||||
runChatLoop,
|
||||
type ChatClients,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/chatLoop";
|
||||
import type {
|
||||
Tool as ProductionTool,
|
||||
ToolCallbacks,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import {
|
||||
buildProxyResourcePath,
|
||||
createEvalClients,
|
||||
type FrontendEvalProvider,
|
||||
resolveEvalModelProvider,
|
||||
} from "./providerConfig";
|
||||
import { WindmillBackendClient } from "../../windmillBackend";
|
||||
|
||||
/**
|
||||
* Parameters for running a base evaluation.
|
||||
*/
|
||||
export interface RunEvalParams<THelpers, TOutput> {
|
||||
/** The user's prompt/instruction */
|
||||
userPrompt: string;
|
||||
/** System message for the LLM */
|
||||
systemMessage: ChatCompletionSystemMessageParam;
|
||||
/** User message for the LLM */
|
||||
userMessage: ChatCompletionMessageParam;
|
||||
/** Tool definitions for the LLM API (unused — derived from tools) */
|
||||
toolDefs?: unknown;
|
||||
/** Full tool implementations for execution */
|
||||
tools: ProductionTool<THelpers>[];
|
||||
/** Domain-specific helpers for tool execution */
|
||||
helpers: THelpers;
|
||||
/** API key for the provider */
|
||||
apiKey: string;
|
||||
/** Function to get the current output state */
|
||||
getOutput: () => TOutput;
|
||||
/** Model and Windmill backend configuration */
|
||||
options: EvalRunnerOptions;
|
||||
onAssistantMessageStart?: () => void;
|
||||
onAssistantToken?: (token: string) => void;
|
||||
onAssistantMessageEnd?: () => void;
|
||||
onToolCall?: (input: { toolName: string; argumentsText: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a generic evaluation using the shared chat loop (same code path as production).
|
||||
* Uses streaming via real provider SDKs instead of OpenRouter non-streaming.
|
||||
*/
|
||||
export async function runEval<THelpers, TOutput>(
|
||||
params: RunEvalParams<THelpers, TOutput>,
|
||||
): Promise<RawEvalResult<TOutput>> {
|
||||
const {
|
||||
systemMessage,
|
||||
userMessage,
|
||||
tools,
|
||||
helpers,
|
||||
apiKey,
|
||||
getOutput,
|
||||
options,
|
||||
onAssistantMessageStart,
|
||||
onAssistantToken,
|
||||
onAssistantMessageEnd,
|
||||
onToolCall,
|
||||
} = params;
|
||||
let shouldEmitMessageStart = true;
|
||||
|
||||
const model = options.model ?? "gpt-4o";
|
||||
const maxIterations = options.maxIterations ?? 20;
|
||||
const workspace = options.workspace ?? "test-workspace";
|
||||
const provider = toFrontendEvalProvider(options.provider);
|
||||
|
||||
const modelProvider = resolveEvalModelProvider(model, provider);
|
||||
|
||||
const messages: ChatCompletionMessageParam[] = [userMessage];
|
||||
let toolCallsCount = 0;
|
||||
const toolsCalled: string[] = [];
|
||||
const toolCallDetails: ToolCallDetail[] = [];
|
||||
|
||||
// Wrap tools to intercept fn calls for tracking.
|
||||
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
|
||||
// but the actual callbacks passed at runtime will satisfy both interfaces.
|
||||
const wrappedTools = tools.map((tool) => ({
|
||||
...tool,
|
||||
fn: async (p: any) => {
|
||||
toolCallsCount++;
|
||||
toolsCalled.push(tool.def.function.name);
|
||||
let argumentsText = "";
|
||||
try {
|
||||
const args = typeof p.args === "string" ? JSON.parse(p.args) : p.args;
|
||||
toolCallDetails.push({ name: tool.def.function.name, arguments: args });
|
||||
argumentsText = JSON.stringify(args);
|
||||
} catch {
|
||||
toolCallDetails.push({
|
||||
name: tool.def.function.name,
|
||||
arguments: p.args,
|
||||
});
|
||||
argumentsText =
|
||||
typeof p.args === "string" ? p.args : JSON.stringify(p.args);
|
||||
}
|
||||
onToolCall?.({
|
||||
toolName: tool.def.function.name,
|
||||
argumentsText,
|
||||
});
|
||||
return tool.fn(p);
|
||||
},
|
||||
}));
|
||||
|
||||
// No-op callbacks for eval
|
||||
const callbacks: ToolCallbacks & {
|
||||
onNewToken: (token: string) => void;
|
||||
onMessageEnd: () => void;
|
||||
} = {
|
||||
setToolStatus: () => {},
|
||||
removeToolStatus: () => {},
|
||||
onNewToken: (token: string) => {
|
||||
if (shouldEmitMessageStart) {
|
||||
onAssistantMessageStart?.();
|
||||
shouldEmitMessageStart = false;
|
||||
}
|
||||
onAssistantToken?.(token);
|
||||
},
|
||||
onMessageEnd: () => {
|
||||
if (!shouldEmitMessageStart) {
|
||||
onAssistantMessageEnd?.();
|
||||
}
|
||||
shouldEmitMessageStart = true;
|
||||
},
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
const executeChatLoop = async (clients: ChatClients) => {
|
||||
try {
|
||||
const result = await runChatLoop({
|
||||
messages,
|
||||
systemMessage,
|
||||
tools: wrappedTools,
|
||||
helpers,
|
||||
abortController,
|
||||
callbacks,
|
||||
modelProvider,
|
||||
clients,
|
||||
workspace,
|
||||
maxIterations,
|
||||
skipResponsesApi: modelProvider.provider !== "openai",
|
||||
});
|
||||
|
||||
if (result.hitMaxIterations) {
|
||||
return {
|
||||
success: false,
|
||||
output: getOutput(),
|
||||
error: `Reached max turns (${maxIterations})`,
|
||||
tokenUsage: result.tokenUsage,
|
||||
toolCallsCount,
|
||||
toolsCalled,
|
||||
toolCallDetails,
|
||||
iterations: Math.max(
|
||||
1,
|
||||
result.addedMessages.filter((m) => m.role === "assistant").length,
|
||||
),
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: getOutput(),
|
||||
tokenUsage: result.tokenUsage,
|
||||
toolCallsCount,
|
||||
toolsCalled,
|
||||
toolCallDetails,
|
||||
iterations: Math.max(
|
||||
1,
|
||||
result.addedMessages.filter((m) => m.role === "assistant").length,
|
||||
),
|
||||
messages,
|
||||
};
|
||||
} catch (err) {
|
||||
let errorMessage: string;
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.stack ?? err.message;
|
||||
} else {
|
||||
errorMessage = String(err);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: getOutput(),
|
||||
error: errorMessage,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
toolCallsCount,
|
||||
toolsCalled,
|
||||
toolCallDetails,
|
||||
iterations: 0,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const backendSettings = options.backend;
|
||||
const backendClient = new WindmillBackendClient(backendSettings);
|
||||
return await backendClient.withWorkspace(
|
||||
options.caseId ?? "eval",
|
||||
options.attempt ?? 1,
|
||||
async (proxyWorkspaceId) => {
|
||||
const resourcePath = buildProxyResourcePath(modelProvider.provider);
|
||||
await backendClient.upsertResource({
|
||||
workspaceId: proxyWorkspaceId,
|
||||
path: resourcePath,
|
||||
resourceType: modelProvider.provider,
|
||||
value: { api_key: apiKey },
|
||||
});
|
||||
const token = await backendClient.getToken();
|
||||
const clients = createEvalClients({
|
||||
provider: modelProvider.provider,
|
||||
proxy: {
|
||||
baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`,
|
||||
bearerToken: token,
|
||||
resourcePath,
|
||||
},
|
||||
}) as unknown as ChatClients;
|
||||
return await executeChatLoop(clients);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function toFrontendEvalProvider(
|
||||
provider?: AIProvider,
|
||||
): FrontendEvalProvider | undefined {
|
||||
if (
|
||||
provider === "anthropic" ||
|
||||
provider === "openai" ||
|
||||
provider === "googleai" ||
|
||||
provider === "deepseek"
|
||||
) {
|
||||
return provider;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export type { TokenUsage, ToolCallDetail, EvalRunnerOptions, RawEvalResult } from './types'
|
||||
export type { RunEvalParams } from './baseEvalRunner'
|
||||
export { runEval } from './baseEvalRunner'
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
buildProxyHeaders,
|
||||
buildProxyResourcePath,
|
||||
resolveEvalModelProvider,
|
||||
} from "./providerConfig";
|
||||
|
||||
describe("proxy helpers", () => {
|
||||
it("builds provider-scoped proxy resource paths", () => {
|
||||
expect(buildProxyResourcePath("googleai")).toBe("f/evals/ai/googleai");
|
||||
expect(buildProxyResourcePath("anthropic")).toBe("f/evals/ai/anthropic");
|
||||
});
|
||||
|
||||
it("adds auth and resource headers for workspace proxy requests", () => {
|
||||
expect(buildProxyHeaders("token-123", "f/evals/ai/googleai")).toEqual({
|
||||
Authorization: "Bearer token-123",
|
||||
"X-Resource-Path": "f/evals/ai/googleai",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEvalModelProvider", () => {
|
||||
it("infers googleai from Gemini model ids", () => {
|
||||
expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-flash",
|
||||
});
|
||||
});
|
||||
|
||||
it("infers deepseek from DeepSeek model ids", () => {
|
||||
expect(resolveEvalModelProvider("deepseek-v4-flash")).toEqual({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an explicit provider", () => {
|
||||
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-pro",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import OpenAI from "openai";
|
||||
import type { FrontendEvalModelConfig } from "../../../../core/models";
|
||||
|
||||
export type FrontendEvalProvider = FrontendEvalModelConfig["provider"];
|
||||
|
||||
export interface EvalClients {
|
||||
openai: OpenAI;
|
||||
anthropic: Anthropic;
|
||||
}
|
||||
|
||||
export interface ResolvedEvalModelProvider {
|
||||
provider: FrontendEvalProvider;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface WindmillAiProxyClientConfig {
|
||||
baseURL: string;
|
||||
bearerToken: string;
|
||||
resourcePath: string;
|
||||
}
|
||||
|
||||
const EVAL_PROXY_RESOURCE_PREFIX = "f/evals/ai";
|
||||
|
||||
export function buildProxyHeaders(
|
||||
bearerToken: string,
|
||||
resourcePath: string,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
"X-Resource-Path": resourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProxyResourcePath(provider: FrontendEvalProvider): string {
|
||||
return `${EVAL_PROXY_RESOURCE_PREFIX}/${provider}`;
|
||||
}
|
||||
|
||||
function buildProxyOpenAIClientOptions(
|
||||
proxy: WindmillAiProxyClientConfig,
|
||||
): ConstructorParameters<typeof OpenAI>[0] {
|
||||
return {
|
||||
apiKey: "unused",
|
||||
baseURL: proxy.baseURL,
|
||||
defaultHeaders: buildProxyHeaders(proxy.bearerToken, proxy.resourcePath),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEvalClients(input: {
|
||||
provider: FrontendEvalProvider;
|
||||
proxy: WindmillAiProxyClientConfig;
|
||||
}): EvalClients {
|
||||
if (input.provider === "anthropic") {
|
||||
return {
|
||||
openai: new OpenAI({ apiKey: "unused" }),
|
||||
anthropic: new Anthropic({
|
||||
apiKey: "unused",
|
||||
baseURL: input.proxy.baseURL,
|
||||
defaultHeaders: buildProxyHeaders(
|
||||
input.proxy.bearerToken,
|
||||
input.proxy.resourcePath,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)),
|
||||
anthropic: new Anthropic({ apiKey: "unused" }),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveEvalModelProvider(
|
||||
model: string,
|
||||
provider?: FrontendEvalProvider,
|
||||
): ResolvedEvalModelProvider {
|
||||
if (provider) {
|
||||
return { provider, model };
|
||||
}
|
||||
if (model.startsWith("claude")) {
|
||||
return { provider: "anthropic", model };
|
||||
}
|
||||
if (model.startsWith("gemini")) {
|
||||
return { provider: "googleai", model };
|
||||
}
|
||||
if (model.startsWith("deepseek")) {
|
||||
return { provider: "deepseek", model };
|
||||
}
|
||||
if (model.startsWith("gpt") || model.startsWith("o")) {
|
||||
return { provider: "openai", model };
|
||||
}
|
||||
return { provider: "openai", model };
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
|
||||
export interface TokenUsage {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ToolCallDetail {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EvalRunnerOptions {
|
||||
backend: WindmillBackendSettings;
|
||||
maxIterations?: number;
|
||||
model?: string;
|
||||
workspace?: string;
|
||||
provider?: AIProvider;
|
||||
caseId?: string;
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
export interface RawEvalResult<TOutput> {
|
||||
success: boolean;
|
||||
output: TOutput;
|
||||
error?: string;
|
||||
tokenUsage: TokenUsage;
|
||||
toolCallsCount: number;
|
||||
toolsCalled: string[];
|
||||
toolCallDetails: ToolCallDetail[];
|
||||
iterations: number;
|
||||
messages: ChatCompletionMessageParam[];
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { CompletedJob, Flow, Script } from '../../../frontend/src/lib/gen'
|
||||
import type { ScriptLang } from '../../../frontend/src/lib/gen/types.gen'
|
||||
import { buildScriptLintResult } from './core/script/preview'
|
||||
|
||||
const BENCHMARK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
|
||||
|
||||
export interface BenchmarkWorkspaceScript {
|
||||
path: string
|
||||
summary: string
|
||||
description?: string
|
||||
language: Script['language']
|
||||
schema?: Record<string, unknown>
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface BenchmarkWorkspaceFlow {
|
||||
path: string
|
||||
summary: string
|
||||
description?: string
|
||||
schema?: Record<string, unknown>
|
||||
value: Flow['value']
|
||||
}
|
||||
|
||||
export interface BenchmarkWorkspaceRunnables {
|
||||
scripts?: BenchmarkWorkspaceScript[]
|
||||
flows?: BenchmarkWorkspaceFlow[]
|
||||
}
|
||||
|
||||
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
|
||||
|
||||
const benchmarkWorkspaces = new Set<string>()
|
||||
const benchmarkWorkspaceRunnables = new Map<string, BenchmarkWorkspaceRunnables>()
|
||||
const benchmarkJobs = new Map<string, { workspace: string; job: BenchmarkCompletedJob }>()
|
||||
|
||||
export function resetBenchmarkMockBackend(): void {
|
||||
benchmarkWorkspaces.clear()
|
||||
benchmarkWorkspaceRunnables.clear()
|
||||
benchmarkJobs.clear()
|
||||
}
|
||||
|
||||
export function registerBenchmarkWorkspace(workspace: string): void {
|
||||
benchmarkWorkspaces.add(workspace)
|
||||
}
|
||||
|
||||
export function registerBenchmarkWorkspaceRunnables(
|
||||
workspace: string,
|
||||
runnables: BenchmarkWorkspaceRunnables
|
||||
): void {
|
||||
benchmarkWorkspaces.add(workspace)
|
||||
benchmarkWorkspaceRunnables.set(workspace, runnables)
|
||||
}
|
||||
|
||||
export function unregisterBenchmarkWorkspace(workspace: string): void {
|
||||
benchmarkWorkspaces.delete(workspace)
|
||||
benchmarkWorkspaceRunnables.delete(workspace)
|
||||
for (const [jobId, entry] of benchmarkJobs.entries()) {
|
||||
if (entry.workspace === workspace) {
|
||||
benchmarkJobs.delete(jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterBenchmarkWorkspaceRunnables(workspace: string): void {
|
||||
unregisterBenchmarkWorkspace(workspace)
|
||||
}
|
||||
|
||||
export function hasBenchmarkWorkspace(workspace: string): boolean {
|
||||
return benchmarkWorkspaces.has(workspace)
|
||||
}
|
||||
|
||||
export function listBenchmarkScripts(workspace: string): Script[] | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
if (!runnables) {
|
||||
return null
|
||||
}
|
||||
return (runnables.scripts ?? []).map(buildBenchmarkScript)
|
||||
}
|
||||
|
||||
export function listBenchmarkFlows(workspace: string): Flow[] | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
if (!runnables) {
|
||||
return null
|
||||
}
|
||||
return (runnables.flows ?? []).map(buildBenchmarkFlow)
|
||||
}
|
||||
|
||||
export function getBenchmarkScriptByPath(workspace: string, path: string): Script | null {
|
||||
const script = benchmarkWorkspaceRunnables
|
||||
.get(workspace)
|
||||
?.scripts?.find((entry) => entry.path === path)
|
||||
|
||||
return script ? buildBenchmarkScript(script) : null
|
||||
}
|
||||
|
||||
export function getBenchmarkScriptByHash(workspace: string, hash: string): Script | null {
|
||||
const script = benchmarkWorkspaceRunnables
|
||||
.get(workspace)
|
||||
?.scripts?.find((entry) => buildBenchmarkScriptHash(entry.path) === hash)
|
||||
|
||||
return script ? buildBenchmarkScript(script) : null
|
||||
}
|
||||
|
||||
export function getBenchmarkFlowByPath(workspace: string, path: string): Flow | null {
|
||||
const flow = benchmarkWorkspaceRunnables
|
||||
.get(workspace)
|
||||
?.flows?.find((entry) => entry.path === path)
|
||||
|
||||
return flow ? buildBenchmarkFlow(flow) : null
|
||||
}
|
||||
|
||||
export function createBenchmarkCompletedJob(input: {
|
||||
workspace: string
|
||||
jobKind: CompletedJob['job_kind']
|
||||
success?: boolean
|
||||
result?: unknown
|
||||
logs?: string
|
||||
scriptPath?: string
|
||||
scriptHash?: string
|
||||
args?: Record<string, unknown>
|
||||
}): string {
|
||||
const jobId = `benchmark-job-${randomUUID()}`
|
||||
const now = new Date().toISOString()
|
||||
const job: BenchmarkCompletedJob = {
|
||||
type: 'CompletedJob',
|
||||
id: jobId,
|
||||
workspace_id: input.workspace,
|
||||
created_by: 'ai-evals',
|
||||
created_at: now,
|
||||
started_at: now,
|
||||
completed_at: now,
|
||||
duration_ms: 0,
|
||||
success: input.success ?? true,
|
||||
script_path: input.scriptPath,
|
||||
script_hash: input.scriptHash,
|
||||
args: input.args,
|
||||
result: input.result,
|
||||
logs: input.logs,
|
||||
canceled: false,
|
||||
job_kind: input.jobKind,
|
||||
permissioned_as: 'u/ai-evals',
|
||||
is_flow_step: false,
|
||||
is_skipped: false,
|
||||
email: 'ai-evals@local',
|
||||
visible_to_owner: true,
|
||||
tag: 'benchmark'
|
||||
}
|
||||
|
||||
benchmarkJobs.set(jobId, { workspace: input.workspace, job })
|
||||
return jobId
|
||||
}
|
||||
|
||||
export function getBenchmarkCompletedJob(
|
||||
workspace: string,
|
||||
jobId: string
|
||||
): BenchmarkCompletedJob | null {
|
||||
const entry = benchmarkJobs.get(jobId)
|
||||
if (!entry || entry.workspace !== workspace) {
|
||||
return null
|
||||
}
|
||||
return structuredClone(entry.job)
|
||||
}
|
||||
|
||||
export function runBenchmarkScriptPreview(input: {
|
||||
workspace: string
|
||||
requestBody: {
|
||||
content?: string
|
||||
language?: ScriptLang | 'bunnative'
|
||||
args?: Record<string, unknown>
|
||||
path?: string
|
||||
}
|
||||
}): string {
|
||||
const content = input.requestBody.content ?? ''
|
||||
const language = input.requestBody.language ?? 'bun'
|
||||
const lintResult = buildScriptLintResult(content, language)
|
||||
const success = lintResult.errorCount === 0
|
||||
|
||||
return createBenchmarkCompletedJob({
|
||||
workspace: input.workspace,
|
||||
jobKind: 'preview',
|
||||
success,
|
||||
scriptPath: input.requestBody.path,
|
||||
args: input.requestBody.args,
|
||||
result: success
|
||||
? {
|
||||
path: input.requestBody.path,
|
||||
args: input.requestBody.args ?? {},
|
||||
validated: true
|
||||
}
|
||||
: {
|
||||
path: input.requestBody.path,
|
||||
args: input.requestBody.args ?? {},
|
||||
errorCount: lintResult.errorCount,
|
||||
errors: lintResult.errors.map((entry) => ({
|
||||
line: entry.startLineNumber,
|
||||
message: entry.message
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function runBenchmarkFlowByPath(input: {
|
||||
workspace: string
|
||||
path: string
|
||||
args?: Record<string, unknown>
|
||||
}): string {
|
||||
const flow = getBenchmarkFlowByPath(input.workspace, input.path)
|
||||
return createBenchmarkCompletedJob({
|
||||
workspace: input.workspace,
|
||||
jobKind: 'flowpreview',
|
||||
success: flow !== null,
|
||||
args: input.args,
|
||||
result:
|
||||
flow !== null
|
||||
? {
|
||||
path: input.path,
|
||||
args: input.args ?? {},
|
||||
mocked: true
|
||||
}
|
||||
: {
|
||||
error: `Flow "${input.path}" not found in benchmark workspace`
|
||||
},
|
||||
logs:
|
||||
flow !== null
|
||||
? 'Mock benchmark flow run completed successfully.'
|
||||
: `Flow "${input.path}" not found in benchmark workspace.`
|
||||
})
|
||||
}
|
||||
|
||||
export function previewBenchmarkSchedule(input: {
|
||||
requestBody?: Record<string, unknown>
|
||||
}): Record<string, unknown> {
|
||||
const schedule = input.requestBody?.schedule
|
||||
if (typeof schedule !== 'string' || schedule.trim().split(/\s+/).length !== 6) {
|
||||
throw new Error(`schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
next_runs: ['1970-01-02T00:00:00.000Z']
|
||||
}
|
||||
}
|
||||
|
||||
export function createBenchmarkSchedule(input: {
|
||||
workspace: string
|
||||
requestBody: Record<string, unknown>
|
||||
}): Record<string, unknown> {
|
||||
assertBenchmarkWorkspacePath('schedule', input.requestBody.path)
|
||||
assertBenchmarkWorkspacePath('target', input.requestBody.script_path)
|
||||
return {
|
||||
path: input.requestBody.path,
|
||||
target_path: input.requestBody.script_path,
|
||||
is_flow: input.requestBody.is_flow,
|
||||
mocked: true
|
||||
}
|
||||
}
|
||||
|
||||
export function createBenchmarkHttpTrigger(input: {
|
||||
workspace: string
|
||||
requestBody: Record<string, unknown>
|
||||
}): Record<string, unknown> {
|
||||
assertBenchmarkWorkspacePath('trigger', input.requestBody.path)
|
||||
assertBenchmarkWorkspacePath('target', input.requestBody.script_path)
|
||||
if (
|
||||
typeof input.requestBody.route_path === 'string' &&
|
||||
input.requestBody.route_path.startsWith('/')
|
||||
) {
|
||||
throw new Error(`HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"`)
|
||||
}
|
||||
return {
|
||||
path: input.requestBody.path,
|
||||
target_path: input.requestBody.script_path,
|
||||
route_path: input.requestBody.route_path,
|
||||
is_flow: input.requestBody.is_flow,
|
||||
mocked: true
|
||||
}
|
||||
}
|
||||
|
||||
function assertBenchmarkWorkspacePath(label: string, value: unknown): void {
|
||||
if (typeof value !== 'string' || (!value.startsWith('f/') && !value.startsWith('u/'))) {
|
||||
throw new Error(`${label} path must start with f/ or u/, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function buildBenchmarkScriptHash(path: string): string {
|
||||
return `benchmark:${path}`
|
||||
}
|
||||
|
||||
function buildBenchmarkScript(script: BenchmarkWorkspaceScript): Script {
|
||||
return {
|
||||
workspace_id: 'benchmark',
|
||||
hash: buildBenchmarkScriptHash(script.path),
|
||||
path: script.path,
|
||||
parent_hashes: [],
|
||||
summary: script.summary,
|
||||
description: script.description ?? '',
|
||||
content: script.content,
|
||||
created_by: 'benchmark',
|
||||
created_at: BENCHMARK_TIMESTAMP,
|
||||
archived: false,
|
||||
schema: script.schema ?? {},
|
||||
deleted: false,
|
||||
is_template: false,
|
||||
extra_perms: {},
|
||||
language: script.language,
|
||||
kind: 'script',
|
||||
starred: false,
|
||||
has_preprocessor: false,
|
||||
modules: null
|
||||
}
|
||||
}
|
||||
|
||||
function buildBenchmarkFlow(flow: BenchmarkWorkspaceFlow): Flow {
|
||||
return {
|
||||
path: flow.path,
|
||||
summary: flow.summary,
|
||||
description: flow.description ?? '',
|
||||
value: flow.value,
|
||||
schema: flow.schema ?? {},
|
||||
edited_by: 'benchmark',
|
||||
edited_at: BENCHMARK_TIMESTAMP,
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
} as Flow
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global'
|
||||
|
||||
export type FrontendBenchmarkProgressEvent =
|
||||
| {
|
||||
type: 'run-start'
|
||||
surface: FrontendBenchmarkProgressSurface
|
||||
totalCases: number
|
||||
runs: number
|
||||
concurrency: number
|
||||
}
|
||||
| {
|
||||
type: 'attempt-start'
|
||||
surface: FrontendBenchmarkProgressSurface
|
||||
caseId: string
|
||||
caseNumber: number
|
||||
totalCases: number
|
||||
attempt: number
|
||||
runs: number
|
||||
}
|
||||
| {
|
||||
type: 'attempt-finish'
|
||||
surface: FrontendBenchmarkProgressSurface
|
||||
caseId: string
|
||||
caseNumber: number
|
||||
totalCases: number
|
||||
attempt: number
|
||||
runs: number
|
||||
passed: boolean
|
||||
durationMs: number
|
||||
judgeScore: number | null
|
||||
error: string | null
|
||||
}
|
||||
| {
|
||||
type: 'assistant-message-start'
|
||||
surface: FrontendBenchmarkProgressSurface
|
||||
caseId: string
|
||||
caseNumber: number
|
||||
totalCases: number
|
||||
attempt: number
|
||||
runs: number
|
||||
}
|
||||
| {
|
||||
type: 'assistant-chunk'
|
||||
surface: FrontendBenchmarkProgressSurface
|
||||
caseId: string
|
||||
caseNumber: number
|
||||
totalCases: number
|
||||
attempt: number
|
||||
runs: number
|
||||
chunk: string
|
||||
}
|
||||
| {
|
||||
type: 'assistant-message-end'
|
||||
surface: FrontendBenchmarkProgressSurface
|
||||
caseId: string
|
||||
caseNumber: number
|
||||
totalCases: number
|
||||
attempt: number
|
||||
runs: number
|
||||
}
|
||||
| {
|
||||
type: 'tool-call'
|
||||
surface: FrontendBenchmarkProgressSurface
|
||||
caseId: string
|
||||
caseNumber: number
|
||||
totalCases: number
|
||||
attempt: number
|
||||
runs: number
|
||||
toolName: string
|
||||
argumentsText: string
|
||||
}
|
||||
|
||||
export const FRONTEND_BENCHMARK_PROGRESS_PREFIX = 'WMILL_FRONTEND_AI_EVAL_PROGRESS '
|
||||
|
||||
export function emitFrontendBenchmarkProgress(event: FrontendBenchmarkProgressEvent): void {
|
||||
process.stderr.write(
|
||||
`${FRONTEND_BENCHMARK_PROGRESS_PREFIX}${JSON.stringify(event)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
export function parseFrontendBenchmarkProgressLine(
|
||||
line: string
|
||||
): FrontendBenchmarkProgressEvent | null {
|
||||
if (!line.startsWith(FRONTEND_BENCHMARK_PROGRESS_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
line.slice(FRONTEND_BENCHMARK_PROGRESS_PREFIX.length)
|
||||
) as FrontendBenchmarkProgressEvent
|
||||
return parsed?.type ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFrontendBenchmarkProgressEvent(
|
||||
event: FrontendBenchmarkProgressEvent
|
||||
): string {
|
||||
switch (event.type) {
|
||||
case 'run-start':
|
||||
return `Running ${event.surface}: ${event.totalCases} cases x ${event.runs} run${event.runs === 1 ? '' : 's'}, concurrency ${event.concurrency}`
|
||||
case 'attempt-start':
|
||||
return `${formatCasePrefix(event.caseNumber, event.totalCases)} ${event.caseId} attempt ${event.attempt}/${event.runs}...`
|
||||
case 'attempt-finish': {
|
||||
const parts = [
|
||||
`${formatCasePrefix(event.caseNumber, event.totalCases)} ${event.caseId} attempt ${event.attempt}/${event.runs} ${event.passed ? 'pass' : 'fail'}`,
|
||||
formatDuration(event.durationMs)
|
||||
]
|
||||
if (event.judgeScore !== null) {
|
||||
parts.push(`judge ${formatNumber(event.judgeScore)}`)
|
||||
}
|
||||
if (event.error) {
|
||||
parts.push(truncateSingleLine(event.error, 120))
|
||||
}
|
||||
return parts.join(' | ')
|
||||
}
|
||||
case 'assistant-message-start':
|
||||
case 'assistant-chunk':
|
||||
case 'assistant-message-end':
|
||||
return ''
|
||||
case 'tool-call':
|
||||
return `${formatCasePrefix(event.caseNumber, event.totalCases)} ${event.caseId} attempt ${event.attempt}/${event.runs} tool ${event.toolName} ${truncateSingleLine(event.argumentsText, 200)}`
|
||||
}
|
||||
}
|
||||
|
||||
function formatCasePrefix(caseNumber: number, totalCases: number): string {
|
||||
return `[${caseNumber}/${totalCases}]`
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
return `${formatNumber(durationMs / 1000)}s`
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
|
||||
function truncateSingleLine(value: string, maxLength: number): string {
|
||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
formatFrontendBenchmarkProgressEvent,
|
||||
parseFrontendBenchmarkProgressLine,
|
||||
} from "./progress";
|
||||
import type { BenchmarkRunResult } from "../../core/types";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const FRONTEND_DIR = path.join(REPO_ROOT, "frontend");
|
||||
const FRONTEND_BENCHMARK_TEST =
|
||||
"../ai_evals/adapters/frontend/vitestAdapter.test.ts";
|
||||
const FRONTEND_BENCHMARK_CONFIG =
|
||||
"../ai_evals/adapters/frontend/vitest.config.ts";
|
||||
|
||||
export type FrontendMode = "flow" | "app" | "script" | "global";
|
||||
|
||||
export async function runFrontendBenchmarkAdapter(input: {
|
||||
mode: FrontendMode;
|
||||
caseIds: string[];
|
||||
runs: number;
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
backendValidation?: string;
|
||||
}): Promise<BenchmarkRunResult> {
|
||||
const tempDir = await mkdtemp(
|
||||
path.join(tmpdir(), "wmill-frontend-benchmark-"),
|
||||
);
|
||||
const outputPath = path.join(tempDir, "result.json");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
BROWSERSLIST_IGNORE_OLD_DATA: "1",
|
||||
WMILL_FRONTEND_AI_EVAL_OUTPUT_PATH: outputPath,
|
||||
WMILL_FRONTEND_AI_EVAL_MODE: input.mode,
|
||||
WMILL_FRONTEND_AI_EVAL_CASE_IDS: JSON.stringify(input.caseIds),
|
||||
WMILL_FRONTEND_AI_EVAL_RUNS: String(input.runs),
|
||||
WMILL_FRONTEND_AI_EVAL_MODEL: input.model ?? "",
|
||||
WMILL_FRONTEND_AI_EVAL_PROGRESS: "1",
|
||||
WMILL_FRONTEND_AI_EVAL_VERBOSE: input.verbose ? "1" : "0",
|
||||
WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "",
|
||||
};
|
||||
|
||||
try {
|
||||
await runVitestBenchmark(
|
||||
path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"),
|
||||
[
|
||||
"run",
|
||||
FRONTEND_BENCHMARK_TEST,
|
||||
"--project",
|
||||
"server",
|
||||
"--config",
|
||||
FRONTEND_BENCHMARK_CONFIG,
|
||||
],
|
||||
{
|
||||
cwd: FRONTEND_DIR,
|
||||
env,
|
||||
},
|
||||
);
|
||||
|
||||
const raw = await readFile(outputPath, "utf8");
|
||||
return JSON.parse(raw) as BenchmarkRunResult;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Frontend benchmark adapter failed:\n${toErrorMessage(error)}`,
|
||||
);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runVitestBenchmark(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
},
|
||||
): Promise<void> {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stderrLineBuffer = "";
|
||||
let assistantStreamOpen = false;
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
stderrLineBuffer += chunk;
|
||||
const { remainder, passthrough, nextAssistantStreamOpen } =
|
||||
drainProgressLines(stderrLineBuffer, assistantStreamOpen);
|
||||
stderrLineBuffer = remainder;
|
||||
stderr += passthrough;
|
||||
assistantStreamOpen = nextAssistantStreamOpen;
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (stderrLineBuffer.length > 0) {
|
||||
const { remainder, passthrough, nextAssistantStreamOpen } =
|
||||
drainProgressLines(`${stderrLineBuffer}\n`, assistantStreamOpen);
|
||||
stderrLineBuffer = remainder;
|
||||
stderr += passthrough;
|
||||
assistantStreamOpen = nextAssistantStreamOpen;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
if (assistantStreamOpen) {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const details = [`vitest exited with code ${code}`, stdout, stderr]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
reject(new Error(details));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function drainProgressLines(
|
||||
buffer: string,
|
||||
initialAssistantStreamOpen: boolean,
|
||||
): {
|
||||
remainder: string;
|
||||
passthrough: string;
|
||||
nextAssistantStreamOpen: boolean;
|
||||
} {
|
||||
let remainder = buffer;
|
||||
let passthrough = "";
|
||||
let assistantStreamOpen = initialAssistantStreamOpen;
|
||||
|
||||
while (true) {
|
||||
const newlineIndex = remainder.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
return {
|
||||
remainder,
|
||||
passthrough,
|
||||
nextAssistantStreamOpen: assistantStreamOpen,
|
||||
};
|
||||
}
|
||||
|
||||
const line = remainder.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
remainder = remainder.slice(newlineIndex + 1);
|
||||
|
||||
const progressEvent = parseFrontendBenchmarkProgressLine(line);
|
||||
if (progressEvent) {
|
||||
if (progressEvent.type === "assistant-message-start") {
|
||||
if (assistantStreamOpen) {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
process.stderr.write(
|
||||
`${formatCasePrefix(progressEvent.caseNumber, progressEvent.totalCases)} ${progressEvent.caseId} attempt ${progressEvent.attempt}/${progressEvent.runs} assistant:\n`,
|
||||
);
|
||||
assistantStreamOpen = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (progressEvent.type === "assistant-chunk") {
|
||||
process.stderr.write(progressEvent.chunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (progressEvent.type === "assistant-message-end") {
|
||||
if (assistantStreamOpen) {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
assistantStreamOpen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assistantStreamOpen) {
|
||||
process.stderr.write("\n");
|
||||
assistantStreamOpen = false;
|
||||
}
|
||||
process.stderr.write(
|
||||
`${formatFrontendBenchmarkProgressEvent(progressEvent)}\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldSuppressFrontendStderrLine(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
passthrough += `${line}\n`;
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatCasePrefix(caseNumber: number, totalCases: number): string {
|
||||
return `[${caseNumber}/${totalCases}]`;
|
||||
}
|
||||
|
||||
function shouldSuppressFrontendStderrLine(line: string): boolean {
|
||||
return (
|
||||
line.startsWith("[baseline-browser-mapping] ") ||
|
||||
line.startsWith("Browserslist: browsers data (caniuse-lite) is ") ||
|
||||
line.includes("update-browserslist-db@latest") ||
|
||||
line.includes("update-db#readme")
|
||||
);
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import frontendConfig from '../../../frontend/vite.config.js'
|
||||
|
||||
const FRONTEND_VITE_CONFIG_PATH = fileURLToPath(new URL('../../../frontend/vite.config.js', import.meta.url))
|
||||
const FRONTEND_TEST_SETUP_PATH = fileURLToPath(
|
||||
new URL('../../../frontend/src/lib/test-setup.ts', import.meta.url)
|
||||
)
|
||||
const ADAPTER_TEST_PATH = fileURLToPath(new URL('./vitestAdapter.test.ts', import.meta.url))
|
||||
|
||||
const config = {
|
||||
...frontendConfig,
|
||||
test: {
|
||||
...frontendConfig.test,
|
||||
projects: [
|
||||
{
|
||||
extends: FRONTEND_VITE_CONFIG_PATH,
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: [ADAPTER_TEST_PATH],
|
||||
setupFiles: [FRONTEND_TEST_SETUP_PATH]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -1,356 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
// @ts-ignore - Node.js fs/promises
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
// @ts-ignore - Node.js path
|
||||
import { dirname, resolve } from 'path'
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {},
|
||||
languages: {},
|
||||
KeyCode: {},
|
||||
Uri: {
|
||||
parse: (value: string) => ({ toString: () => value })
|
||||
},
|
||||
MarkerSeverity: {
|
||||
Error: 8,
|
||||
Warning: 4,
|
||||
Info: 2,
|
||||
Hint: 1
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
|
||||
getTypeScriptWorker: async () => async () => ({}),
|
||||
typescriptVersion: 'test'
|
||||
}))
|
||||
|
||||
vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
|
||||
default: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('$lib/components/vscode', () => ({}))
|
||||
|
||||
vi.mock('$lib/gen', async () => {
|
||||
const actual = await vi.importActual<any>('$lib/gen')
|
||||
const {
|
||||
getBenchmarkCompletedJob,
|
||||
getBenchmarkFlowByPath,
|
||||
getBenchmarkScriptByHash,
|
||||
getBenchmarkScriptByPath,
|
||||
hasBenchmarkWorkspace,
|
||||
listBenchmarkFlows,
|
||||
listBenchmarkScripts,
|
||||
createBenchmarkHttpTrigger,
|
||||
createBenchmarkSchedule,
|
||||
previewBenchmarkSchedule,
|
||||
runBenchmarkFlowByPath,
|
||||
runBenchmarkScriptPreview
|
||||
} = await import('./mockBackend')
|
||||
|
||||
function wrapService<T extends object>(target: T, overrides: Record<string, unknown>): T {
|
||||
return new Proxy(target, {
|
||||
get(source, property, receiver) {
|
||||
if (typeof property === 'string' && property in overrides) {
|
||||
return overrides[property]
|
||||
}
|
||||
return Reflect.get(source, property, receiver)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...actual,
|
||||
ScriptService: wrapService(actual.ScriptService, {
|
||||
listScripts: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? (listBenchmarkScripts(data.workspace) ?? [])
|
||||
: actual.ScriptService.listScripts(data),
|
||||
existsScriptByPath: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? Boolean(getBenchmarkScriptByPath(data.workspace, data.path))
|
||||
: actual.ScriptService.existsScriptByPath(data),
|
||||
getScriptByPath: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
const script = getBenchmarkScriptByPath(data.workspace, data.path)
|
||||
if (!script) {
|
||||
throw new Error(`Script "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return script
|
||||
}
|
||||
return actual.ScriptService.getScriptByPath(data)
|
||||
},
|
||||
getScriptByHash: async (data: { workspace: string; hash: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
const script = getBenchmarkScriptByHash(data.workspace, data.hash)
|
||||
if (!script) {
|
||||
throw new Error(`Script hash "${data.hash}" not found in benchmark workspace`)
|
||||
}
|
||||
return script
|
||||
}
|
||||
return actual.ScriptService.getScriptByHash(data)
|
||||
}
|
||||
}),
|
||||
FlowService: wrapService(actual.FlowService, {
|
||||
listFlows: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? (listBenchmarkFlows(data.workspace) ?? [])
|
||||
: actual.FlowService.listFlows(data),
|
||||
existsFlowByPath: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? Boolean(getBenchmarkFlowByPath(data.workspace, data.path))
|
||||
: actual.FlowService.existsFlowByPath(data),
|
||||
getFlowByPath: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
|
||||
if (!flow) {
|
||||
throw new Error(`Flow "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return flow
|
||||
}
|
||||
return actual.FlowService.getFlowByPath(data)
|
||||
}
|
||||
}),
|
||||
JobService: wrapService(actual.JobService, {
|
||||
runScriptPreview: async (data: {
|
||||
workspace: string
|
||||
requestBody?: {
|
||||
content?: string
|
||||
language?: string
|
||||
args?: Record<string, unknown>
|
||||
path?: string
|
||||
}
|
||||
}) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? runBenchmarkScriptPreview({
|
||||
workspace: data.workspace,
|
||||
requestBody: data.requestBody ?? {}
|
||||
})
|
||||
: actual.JobService.runScriptPreview(data),
|
||||
runFlowByPath: async (data: {
|
||||
workspace: string
|
||||
path: string
|
||||
requestBody?: Record<string, unknown>
|
||||
}) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? runBenchmarkFlowByPath({
|
||||
workspace: data.workspace,
|
||||
path: data.path,
|
||||
args: data.requestBody
|
||||
})
|
||||
: actual.JobService.runFlowByPath(data),
|
||||
getJob: async (data: { workspace: string; id: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
const job = getBenchmarkCompletedJob(data.workspace, data.id)
|
||||
if (!job) {
|
||||
throw new Error(`Job "${data.id}" not found in benchmark workspace`)
|
||||
}
|
||||
return job
|
||||
}
|
||||
return actual.JobService.getJob(data)
|
||||
}
|
||||
}),
|
||||
ScheduleService: wrapService(actual.ScheduleService, {
|
||||
existsSchedule: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data),
|
||||
listSchedules: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data),
|
||||
getSchedule: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Schedule "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.ScheduleService.getSchedule(data)
|
||||
},
|
||||
previewSchedule: async (data: { requestBody?: Record<string, unknown> }) =>
|
||||
previewBenchmarkSchedule(data),
|
||||
createSchedule: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? createBenchmarkSchedule(data)
|
||||
: actual.ScheduleService.createSchedule(data)
|
||||
}),
|
||||
ResourceService: wrapService(actual.ResourceService, {
|
||||
existsResource: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.ResourceService.existsResource(data),
|
||||
listResource: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.listResource(data),
|
||||
getResource: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.ResourceService.getResource(data)
|
||||
},
|
||||
queryResourceTypes: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
|
||||
}),
|
||||
VariableService: wrapService(actual.VariableService, {
|
||||
existsVariable: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data),
|
||||
listVariable: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data),
|
||||
getVariable: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Variable "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.VariableService.getVariable(data)
|
||||
}
|
||||
}),
|
||||
AppService: wrapService(actual.AppService, {
|
||||
existsApp: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data),
|
||||
listApps: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data),
|
||||
getAppByPath: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`App "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.AppService.getAppByPath(data)
|
||||
}
|
||||
}),
|
||||
HttpTriggerService: wrapService(actual.HttpTriggerService, {
|
||||
existsHttpTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.HttpTriggerService.existsHttpTrigger(data),
|
||||
listHttpTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.HttpTriggerService.listHttpTriggers(data),
|
||||
getHttpTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`HTTP trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.HttpTriggerService.getHttpTrigger(data)
|
||||
},
|
||||
createHttpTrigger: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? createBenchmarkHttpTrigger(data)
|
||||
: actual.HttpTriggerService.createHttpTrigger(data)
|
||||
}),
|
||||
WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, {
|
||||
existsWebsocketTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.WebsocketTriggerService.existsWebsocketTrigger(data),
|
||||
listWebsocketTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? []
|
||||
: actual.WebsocketTriggerService.listWebsocketTriggers(data),
|
||||
getWebsocketTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Websocket trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.WebsocketTriggerService.getWebsocketTrigger(data)
|
||||
}
|
||||
}),
|
||||
KafkaTriggerService: wrapService(actual.KafkaTriggerService, {
|
||||
existsKafkaTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.KafkaTriggerService.existsKafkaTrigger(data),
|
||||
listKafkaTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.KafkaTriggerService.listKafkaTriggers(data),
|
||||
getKafkaTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Kafka trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.KafkaTriggerService.getKafkaTrigger(data)
|
||||
}
|
||||
}),
|
||||
NatsTriggerService: wrapService(actual.NatsTriggerService, {
|
||||
existsNatsTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.NatsTriggerService.existsNatsTrigger(data),
|
||||
listNatsTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.NatsTriggerService.listNatsTriggers(data),
|
||||
getNatsTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`NATS trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.NatsTriggerService.getNatsTrigger(data)
|
||||
}
|
||||
}),
|
||||
PostgresTriggerService: wrapService(actual.PostgresTriggerService, {
|
||||
existsPostgresTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.PostgresTriggerService.existsPostgresTrigger(data),
|
||||
listPostgresTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? []
|
||||
: actual.PostgresTriggerService.listPostgresTriggers(data),
|
||||
getPostgresTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Postgres trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.PostgresTriggerService.getPostgresTrigger(data)
|
||||
}
|
||||
}),
|
||||
MqttTriggerService: wrapService(actual.MqttTriggerService, {
|
||||
existsMqttTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.MqttTriggerService.existsMqttTrigger(data),
|
||||
listMqttTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.MqttTriggerService.listMqttTriggers(data),
|
||||
getMqttTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`MQTT trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.MqttTriggerService.getMqttTrigger(data)
|
||||
}
|
||||
}),
|
||||
SqsTriggerService: wrapService(actual.SqsTriggerService, {
|
||||
existsSqsTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.SqsTriggerService.existsSqsTrigger(data),
|
||||
listSqsTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.SqsTriggerService.listSqsTriggers(data),
|
||||
getSqsTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`SQS trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.SqsTriggerService.getSqsTrigger(data)
|
||||
}
|
||||
}),
|
||||
GcpTriggerService: wrapService(actual.GcpTriggerService, {
|
||||
existsGcpTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.GcpTriggerService.existsGcpTrigger(data),
|
||||
listGcpTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.GcpTriggerService.listGcpTriggers(data),
|
||||
getGcpTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`GCP trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.GcpTriggerService.getGcpTrigger(data)
|
||||
}
|
||||
}),
|
||||
AzureTriggerService: wrapService(actual.AzureTriggerService, {
|
||||
existsAzureTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.AzureTriggerService.existsAzureTrigger(data),
|
||||
listAzureTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AzureTriggerService.listAzureTriggers(data),
|
||||
getAzureTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Azure trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.AzureTriggerService.getAzureTrigger(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const benchmarkOutputPath = process.env.WMILL_FRONTEND_AI_EVAL_OUTPUT_PATH
|
||||
const benchmarkIt = benchmarkOutputPath ? it : it.skip
|
||||
|
||||
benchmarkIt(
|
||||
'runs the frontend benchmark adapter from environment input',
|
||||
async () => {
|
||||
const { resetBenchmarkMockBackend } = await import('./mockBackend')
|
||||
resetBenchmarkMockBackend()
|
||||
const { runFrontendBenchmarkFromEnv } = await import('./benchmarkRunner')
|
||||
try {
|
||||
const payload = await runFrontendBenchmarkFromEnv()
|
||||
const absoluteOutputPath = resolve(benchmarkOutputPath!)
|
||||
await mkdir(dirname(absoluteOutputPath), { recursive: true })
|
||||
await writeFile(absoluteOutputPath, JSON.stringify(payload, null, 2) + '\n', 'utf8')
|
||||
|
||||
expect(payload.cases.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
resetBenchmarkMockBackend()
|
||||
}
|
||||
},
|
||||
600_000
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
|
||||
import {
|
||||
WindmillBackendClient,
|
||||
assertWindmillBackendReachable,
|
||||
} from "./windmillBackend";
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
describe("assertWindmillBackendReachable", () => {
|
||||
it("logs in to verify backend reachability", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = mockFetch(requests, textResponse(200, "token"));
|
||||
|
||||
await expect(
|
||||
assertWindmillBackendReachable(
|
||||
buildSettings({ baseUrl: "http://backend.test/reachable" }),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(requests.map((entry) => entry.url)).toEqual([
|
||||
"http://backend.test/reachable/api/auth/login",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds setup guidance when the backend cannot be initialized", async () => {
|
||||
globalThis.fetch = mockFetch(
|
||||
[],
|
||||
textResponse(401, "invalid password"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
assertWindmillBackendReachable(
|
||||
buildSettings({ baseUrl: "http://backend.test/auth-failure" }),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=<url>.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WindmillBackendClient", () => {
|
||||
it("creates or reuses the specified backend workspace without deleting it", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = mockFetch(
|
||||
requests,
|
||||
textResponse(200, "token"),
|
||||
textResponse(200, "false"),
|
||||
textResponse(200, ""),
|
||||
);
|
||||
|
||||
const client = new WindmillBackendClient(
|
||||
buildSettings({
|
||||
baseUrl: "http://backend.test/shared-workspace",
|
||||
workspaceOverride: "shared-evals",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.withWorkspace("case-a", 1, async (workspaceId) => workspaceId),
|
||||
).resolves.toBe("shared-evals");
|
||||
|
||||
expect(requests.map((entry) => entry.url)).toEqual([
|
||||
"http://backend.test/shared-workspace/api/auth/login",
|
||||
"http://backend.test/shared-workspace/api/workspaces/exists",
|
||||
"http://backend.test/shared-workspace/api/workspaces/create",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function buildSettings(
|
||||
overrides: Partial<WindmillBackendSettings> = {},
|
||||
): WindmillBackendSettings {
|
||||
return {
|
||||
baseUrl: "http://backend.test/default",
|
||||
email: "admin@windmill.dev",
|
||||
password: "changeme",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(
|
||||
requests: Array<{ url: string; init?: RequestInit }>,
|
||||
...responses: Response[]
|
||||
): typeof fetch {
|
||||
const queue = [...responses];
|
||||
return async (input, init) => {
|
||||
const url = String(input);
|
||||
requests.push({ url, init });
|
||||
const next = queue.shift();
|
||||
if (!next) {
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
function textResponse(status: number, body: string): Response {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
|
||||
|
||||
const tokenCache = new Map<string, Promise<string>>();
|
||||
const sharedWorkspaceQueue = new Map<string, Promise<void>>();
|
||||
const DEFAULT_WORKSPACE_PREFIX = "ai-evals";
|
||||
|
||||
export class WindmillBackendClient {
|
||||
constructor(private readonly settings: WindmillBackendSettings) {}
|
||||
|
||||
async withWorkspace<T>(
|
||||
caseId: string,
|
||||
attempt: number,
|
||||
body: (workspaceId: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const workspaceId =
|
||||
this.settings.workspaceOverride ??
|
||||
buildWorkspaceId(caseId, attempt);
|
||||
|
||||
const run = async () => {
|
||||
await this.ensureWorkspace(workspaceId);
|
||||
|
||||
try {
|
||||
return await body(workspaceId);
|
||||
} finally {
|
||||
if (!this.settings.workspaceOverride) {
|
||||
await this.deleteWorkspace(workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (this.settings.workspaceOverride) {
|
||||
return await withSharedWorkspaceLock(workspaceId, run);
|
||||
}
|
||||
|
||||
return await run();
|
||||
}
|
||||
|
||||
async request(path: string, init?: RequestInit): Promise<Response> {
|
||||
const token = await this.getToken();
|
||||
return await fetch(`${this.settings.baseUrl}/api${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
const cacheKey = `${this.settings.baseUrl}|${this.settings.email}`;
|
||||
let tokenPromise = tokenCache.get(cacheKey);
|
||||
if (!tokenPromise) {
|
||||
tokenPromise = this.login().catch((error) => {
|
||||
if (tokenCache.get(cacheKey) === tokenPromise) {
|
||||
tokenCache.delete(cacheKey);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
tokenCache.set(cacheKey, tokenPromise);
|
||||
}
|
||||
return await tokenPromise;
|
||||
}
|
||||
|
||||
async upsertResource(input: {
|
||||
workspaceId: string;
|
||||
path: string;
|
||||
resourceType: string;
|
||||
value: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const response = await this.request(
|
||||
`/w/${encodeURIComponent(input.workspaceId)}/resources/create?update_if_exists=true`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: input.path,
|
||||
resource_type: input.resourceType,
|
||||
value: input.value,
|
||||
}),
|
||||
},
|
||||
);
|
||||
await expectOk(response, `upsert resource ${input.path}`);
|
||||
}
|
||||
|
||||
private async ensureWorkspace(workspaceId: string): Promise<void> {
|
||||
const existsResponse = await this.request("/workspaces/exists", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: workspaceId }),
|
||||
});
|
||||
await expectOk(existsResponse, `check workspace ${workspaceId}`);
|
||||
|
||||
if ((await existsResponse.text()).trim() === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const createResponse = await this.request("/workspaces/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: workspaceId, name: workspaceId }),
|
||||
});
|
||||
try {
|
||||
await expectOk(createResponse, `create workspace ${workspaceId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("maximum number of workspaces")) {
|
||||
throw new Error(
|
||||
`${message}. Reuse an existing workspace with WMILL_AI_EVAL_BACKEND_WORKSPACE=<workspace-id>.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteWorkspace(workspaceId: string): Promise<void> {
|
||||
const response = await this.request(
|
||||
`/workspaces/delete/${encodeURIComponent(workspaceId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
await expectOk(response, `delete workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
private async login(): Promise<string> {
|
||||
const response = await fetch(`${this.settings.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: this.settings.email,
|
||||
password: this.settings.password,
|
||||
}),
|
||||
});
|
||||
await expectOk(response, "login to Windmill backend");
|
||||
return (await response.text()).trim();
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertWindmillBackendReachable(
|
||||
settings: WindmillBackendSettings,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await new WindmillBackendClient(settings).getToken();
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
[
|
||||
`Could not initialize the Windmill backend for AI eval proxy at ${settings.baseUrl}.`,
|
||||
"Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=<url>.",
|
||||
`Using login ${settings.email}; if authentication failed, set WMILL_AI_EVAL_BACKEND_EMAIL and WMILL_AI_EVAL_BACKEND_PASSWORD.`,
|
||||
`Details: ${details}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function withSharedWorkspaceLock<T>(
|
||||
workspaceId: string,
|
||||
body: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = sharedWorkspaceQueue.get(workspaceId) ?? Promise.resolve();
|
||||
let releaseCurrent: (() => void) | undefined;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
releaseCurrent = resolve;
|
||||
});
|
||||
const tail = previous.catch(() => undefined).then(() => current);
|
||||
sharedWorkspaceQueue.set(workspaceId, tail);
|
||||
|
||||
await previous.catch(() => undefined);
|
||||
|
||||
try {
|
||||
return await body();
|
||||
} finally {
|
||||
releaseCurrent?.();
|
||||
if (sharedWorkspaceQueue.get(workspaceId) === tail) {
|
||||
sharedWorkspaceQueue.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkspaceId(caseId: string, attempt: number): string {
|
||||
const caseSlug = caseId
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 30);
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}-a${attempt}-${suffix}`;
|
||||
}
|
||||
|
||||
async function expectOk(response: Response, context: string): Promise<void> {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`${context} failed: ${response.status} ${response.statusText} - ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "windmill-ai-evals",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.25",
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"commander": "^14.0.3",
|
||||
"openai": "^6.9.1",
|
||||
"yaml": "^2.8.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.87", "", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-WWmgBPxPhBOvNT0ujI8vPTI2lK+w5YEkEZ/y1mH0EDkK/0kBnxVJNhCtG5vnueiAViwLoUOFn66pbkDiivijdA=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||
|
||||
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openai": ["openai@6.34.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="],
|
||||
|
||||
"@types/node-fetch/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
- id: app-test1-counter-create
|
||||
prompt: |-
|
||||
Create a simple counter app with increment and decrement buttons.
|
||||
judgeChecklist:
|
||||
- shows the current count in the UI
|
||||
- includes an increment button
|
||||
- includes a decrement button
|
||||
- clicking the buttons updates the count correctly
|
||||
|
||||
- id: app-test2-counter-reset
|
||||
prompt: |-
|
||||
Add a reset button that sets the counter back to 0
|
||||
initial: ai_evals/fixtures/frontend/app/initial/test1_counter_app
|
||||
judgeChecklist:
|
||||
- adds a reset control to the existing counter app
|
||||
- clicking reset sets the count back to 0
|
||||
- keeps the existing increment and decrement behavior working
|
||||
|
||||
- id: app-test3-shopping-cart-quantity
|
||||
prompt: |-
|
||||
Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items
|
||||
initial: ai_evals/fixtures/frontend/app/initial/shopping_cart
|
||||
judgeChecklist:
|
||||
- each cart item has visible plus and minus quantity controls
|
||||
- users can increase quantity without re-adding the product
|
||||
- users can decrease quantity from the cart UI
|
||||
- cart totals stay in sync with quantity changes
|
||||
|
||||
- id: app-test4-shopping-cart-discount
|
||||
prompt: |-
|
||||
Add a discount code input field in the cart.
|
||||
When the code "SAVE10" is entered, apply a 10% discount to the total
|
||||
initial: ai_evals/fixtures/frontend/app/initial/shopping_cart
|
||||
judgeChecklist:
|
||||
- adds a discount code input to the cart
|
||||
- recognizes the code SAVE10
|
||||
- applies a 10 percent discount to the displayed total
|
||||
- keeps the rest of the cart behavior intact
|
||||
|
||||
- id: app-test5-file-manager-search
|
||||
prompt: |-
|
||||
Add a search bar in the toolbar that filters files and folders by name as the user types
|
||||
initial: ai_evals/fixtures/frontend/app/initial/file_manager
|
||||
judgeChecklist:
|
||||
- adds a search input in the toolbar
|
||||
- filters files and folders by name as the user types
|
||||
- updates the visible file list from the search query
|
||||
- keeps the rest of the file manager usable
|
||||
|
||||
- id: app-test6-file-manager-rename-save-cancel
|
||||
prompt: |-
|
||||
Improve the existing inline rename flow for files and folders.
|
||||
When renaming, show explicit Save and Cancel buttons next to the name input.
|
||||
Pressing Enter should save, pressing Escape should cancel, and Cancel should restore the original name without calling rename.
|
||||
Keep the existing backend rename behavior for successful saves.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/file_manager
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
- /components/FileItem.tsx
|
||||
requiredFrontendFileContent:
|
||||
- path: /components/FileItem.tsx
|
||||
includes:
|
||||
- Save
|
||||
- Cancel
|
||||
- Escape
|
||||
forbiddenAppContent:
|
||||
- onBlur={handleRename}
|
||||
judgeChecklist:
|
||||
- keeps the existing visible rename action in the file list
|
||||
- shows explicit Save and Cancel controls while editing a name
|
||||
- pressing Enter saves the new name through the existing rename behavior
|
||||
- pressing Escape or Cancel exits rename mode and restores the original name without saving
|
||||
|
||||
- id: app-test7-file-manager-select-all
|
||||
prompt: |-
|
||||
Add a "Select All" checkbox in the file list header and individual checkboxes for each file.
|
||||
Add a "Delete Selected" button that appears when items are selected
|
||||
initial: ai_evals/fixtures/frontend/app/initial/file_manager
|
||||
judgeChecklist:
|
||||
- adds a select-all control in the file list header
|
||||
- adds per-item selection controls
|
||||
- shows a delete-selected action only when there is a selection
|
||||
- deleting selected items updates the visible list
|
||||
|
||||
- id: app-test8-inventory-tracker-search-delete
|
||||
prompt: |-
|
||||
Update this inventory tracker app so users can search items by name or sku and delete existing items.
|
||||
Keep the existing add-item flow and datatable-backed persistence working.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/inventory_tracker
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
requiredBackendRunnableKeys:
|
||||
- listInventory
|
||||
- addInventory
|
||||
- deleteInventory
|
||||
requiredBackendRunnableTypes:
|
||||
- key: listInventory
|
||||
type: inline
|
||||
- key: addInventory
|
||||
type: inline
|
||||
- key: deleteInventory
|
||||
type: inline
|
||||
requiredDatatables:
|
||||
- datatableName: main
|
||||
schema: public
|
||||
table: inventory_items
|
||||
judgeChecklist:
|
||||
- keeps the existing add-item form working
|
||||
- adds a search input that filters inventory by name or sku
|
||||
- adds a delete action for existing inventory items
|
||||
- deleting an inventory item updates the visible list
|
||||
- keeps inventory persistence working through the existing datatable-backed app setup
|
||||
|
||||
- id: app-test9-recipe-book-search-delete
|
||||
prompt: |-
|
||||
Update this recipe book app so users can search recipes by name and delete existing recipes.
|
||||
Keep the existing add-recipe flow and datatable-backed persistence working.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/recipe_book
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
requiredBackendRunnableKeys:
|
||||
- listRecipes
|
||||
- addRecipe
|
||||
- deleteRecipe
|
||||
requiredBackendRunnableTypes:
|
||||
- key: listRecipes
|
||||
type: inline
|
||||
- key: addRecipe
|
||||
type: inline
|
||||
- key: deleteRecipe
|
||||
type: inline
|
||||
requiredDatatables:
|
||||
- datatableName: main
|
||||
schema: public
|
||||
table: recipes
|
||||
judgeChecklist:
|
||||
- keeps the existing add-recipe form working
|
||||
- adds a search input that filters recipes by name
|
||||
- adds a delete action for existing recipes
|
||||
- deleting a recipe updates the visible list
|
||||
- keeps recipe persistence working through the existing datatable-backed app setup
|
||||
|
||||
- id: app-datatable-persistent-notes
|
||||
prompt: |-
|
||||
Build a notes app that persists notes in the existing datatable table.
|
||||
Inspect the existing datatable and table schema before writing code.
|
||||
Use the existing main/public.notes table, and do not create any new tables.
|
||||
Create backend runnables named exactly listNotes, addNote, and deleteNote.
|
||||
The UI should list notes, add a note with title/body, and delete notes.
|
||||
Do not use localStorage, sessionStorage, IndexedDB, or in-memory-only persistence.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/notes_datatable
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
requiredFrontendFileContent:
|
||||
- path: /index.tsx
|
||||
includes:
|
||||
- backend.listNotes
|
||||
- backend.addNote
|
||||
- backend.deleteNote
|
||||
requiredBackendRunnableKeys:
|
||||
- listNotes
|
||||
- addNote
|
||||
- deleteNote
|
||||
requiredBackendRunnableTypes:
|
||||
- key: listNotes
|
||||
type: inline
|
||||
- key: addNote
|
||||
type: inline
|
||||
- key: deleteNote
|
||||
type: inline
|
||||
requiredBackendRunnableContent:
|
||||
- key: listNotes
|
||||
includes:
|
||||
- wmill.datatable
|
||||
- select
|
||||
- notes
|
||||
- key: addNote
|
||||
includes:
|
||||
- wmill.datatable
|
||||
- insert
|
||||
- notes
|
||||
- key: deleteNote
|
||||
includes:
|
||||
- wmill.datatable
|
||||
- delete
|
||||
- notes
|
||||
datatableTableCountExactly: 1
|
||||
requiredDatatables:
|
||||
- datatableName: main
|
||||
schema: public
|
||||
table: notes
|
||||
requiredToolsUsed:
|
||||
- list_datatables
|
||||
- get_datatable_table_schema
|
||||
forbiddenAppContent:
|
||||
- localStorage
|
||||
- sessionStorage
|
||||
- indexedDB
|
||||
judgeChecklist:
|
||||
- creates a notes UI that lists notes from the backend
|
||||
- adds notes through backend datatable persistence
|
||||
- deletes notes through backend datatable persistence
|
||||
- reuses the existing main/public.notes table without creating new tables
|
||||
- does not use browser storage or in-memory-only persistence as the source of truth
|
||||
|
||||
- id: app-test10-session-id-no-crypto
|
||||
prompt: |-
|
||||
Update `generateSessionId` so it no longer uses `crypto.randomUUID()`.
|
||||
Make it return a handmade string id built from the current time and random characters.
|
||||
Keep the existing sessionStorage-based chat session behavior unchanged.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/session_id_chat
|
||||
runtime:
|
||||
maxTurns: 4
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
requiredBackendRunnableKeys:
|
||||
- a
|
||||
requiredBackendRunnableTypes:
|
||||
- key: a
|
||||
type: inline
|
||||
judgeChecklist:
|
||||
- generateSessionId no longer calls crypto.randomUUID
|
||||
- generateSessionId returns a handmade string id without using crypto
|
||||
- getSessionId still stores and reuses chat_session_id in sessionStorage
|
||||
- the existing new chat and send message session behavior remains wired up
|
||||
|
||||
- id: app-token-baseline-large-app-small-edit
|
||||
prompt: |-
|
||||
Change the main heading from "Analytics Console" to "Operations Console".
|
||||
Keep the existing filtering, summary loading, and backend calls unchanged.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/token_heavy_context
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
requiredBackendRunnableKeys:
|
||||
- loadAnalytics
|
||||
- refreshSummary
|
||||
judgeChecklist:
|
||||
- changes the visible main heading to Operations Console
|
||||
- keeps the existing summary loading behavior wired to loadAnalytics
|
||||
- keeps the existing filter input and metric list behavior intact
|
||||
|
||||
- id: app-token-many-datatable-context
|
||||
prompt: |-
|
||||
Add a short note under the dashboard heading that says "Using existing analytics tables".
|
||||
Do not create any new tables.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/token_heavy_datatables
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
appContext:
|
||||
additional:
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_01
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_02
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_03
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_04
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_05
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_06
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_07
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: analytics
|
||||
table: event_log_08
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: operations
|
||||
table: ops_record_13
|
||||
- type: datatable
|
||||
datatableName: main
|
||||
schema: operations
|
||||
table: ops_record_14
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
datatableCountAtLeast: 1
|
||||
datatableTableCountAtLeast: 18
|
||||
judgeChecklist:
|
||||
- adds the note Using existing analytics tables under or near the heading
|
||||
- does not create new datatable tables
|
||||
- keeps the configured datatable references available in the app artifact
|
||||
|
||||
- id: app-token-large-datatable-discovery
|
||||
prompt: |-
|
||||
Build a read-only dashboard page that reuses the existing analytics datatable tables.
|
||||
Show a simple summary of which existing tables are available, and do not create any new tables.
|
||||
initial: ai_evals/fixtures/frontend/app/initial/token_heavy_datatables
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
requiredFrontendPaths:
|
||||
- /index.tsx
|
||||
datatableCountAtLeast: 1
|
||||
datatableTableCountAtLeast: 18
|
||||
judgeChecklist:
|
||||
- reuses the existing datatable configuration rather than creating new tables
|
||||
- presents a read-only dashboard or summary of available analytics data
|
||||
- keeps the configured datatable references available in the app artifact
|
||||
@@ -1,263 +0,0 @@
|
||||
- id: bun-hello-script
|
||||
prompt: |-
|
||||
Create a Windmill Bun script at `f/evals/hello.ts`.
|
||||
It should take a `name` input and return a greeting object like `{ greeting: "Hello, Alice!" }`.
|
||||
expected: ai_evals/fixtures/cli/expected/bun-hello-script
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- write-script-bun
|
||||
requiredSkillsBeforeFirstMutation:
|
||||
- write-script-bun
|
||||
forbiddenSkills:
|
||||
- write-script-python3
|
||||
- write-flow
|
||||
orderedAssistantMentions:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
orderedProposedCommands:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
forbiddenExecutedCommands:
|
||||
- ^wmill generate-metadata
|
||||
- ^wmill sync push
|
||||
judgeChecklist:
|
||||
- creates the requested Bun script at f/evals/hello.ts
|
||||
- takes a name input
|
||||
- returns an object containing the greeting
|
||||
|
||||
- id: bun-hello-flow
|
||||
prompt: |-
|
||||
Create a Windmill flow at `f/evals/hello__flow`.
|
||||
It should take a `name` input and return a greeting object like `{ greeting: "Hello, Alice!" }`.
|
||||
Put the step code in `hello.ts`.
|
||||
expected: ai_evals/fixtures/cli/expected/bun-hello-flow
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- write-flow
|
||||
requiredSkillsBeforeFirstMutation:
|
||||
- write-flow
|
||||
forbiddenSkills:
|
||||
- write-script-python3
|
||||
orderedAssistantMentions:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
orderedProposedCommands:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
forbiddenExecutedCommands:
|
||||
- ^wmill generate-metadata
|
||||
- ^wmill sync push
|
||||
judgeChecklist:
|
||||
- creates the requested flow folder with flow.yaml and hello.ts
|
||||
- wires the name input into the flow step
|
||||
- returns the greeting object
|
||||
|
||||
- id: python-add-numbers-script
|
||||
prompt: |-
|
||||
Add a Windmill Python script at `f/evals/add_numbers.py`.
|
||||
It should take `a` and `b` as inputs and return `{ "total": a + b }`.
|
||||
expected: ai_evals/fixtures/cli/expected/python-add-numbers-script
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- write-script-python3
|
||||
requiredSkillsBeforeFirstMutation:
|
||||
- write-script-python3
|
||||
forbiddenSkills:
|
||||
- write-script-bun
|
||||
- write-flow
|
||||
orderedAssistantMentions:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
orderedProposedCommands:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
forbiddenExecutedCommands:
|
||||
- ^wmill generate-metadata
|
||||
- ^wmill sync push
|
||||
judgeChecklist:
|
||||
- creates the requested Python script at f/evals/add_numbers.py
|
||||
- takes `a` and `b` as inputs
|
||||
- returns an object with total equal to a plus b
|
||||
|
||||
- id: bun-hello-script-uppercase
|
||||
prompt: |-
|
||||
Update `f/evals/hello.ts` so it accepts an optional `uppercase` boolean.
|
||||
Keep returning `{ greeting: ... }`, but when `uppercase` is true the greeting should be uppercased before returning it.
|
||||
initial: ai_evals/fixtures/cli/initial/bun-hello-script-uppercase
|
||||
expected: ai_evals/fixtures/cli/expected/bun-hello-script-uppercase
|
||||
judgeChecklist:
|
||||
- updates the existing hello.ts file rather than creating a new script
|
||||
- accepts an optional uppercase boolean input
|
||||
- keeps returning an object with greeting
|
||||
- uppercases the greeting when uppercase is true
|
||||
|
||||
- id: bun-hello-flow-punctuation
|
||||
prompt: |-
|
||||
Update the existing flow in `f/evals/hello__flow` so it also accepts an optional `punctuation` input.
|
||||
The greeting should use that punctuation and default to `!` when it is missing.
|
||||
initial: ai_evals/fixtures/cli/initial/bun-hello-flow-punctuation
|
||||
expected: ai_evals/fixtures/cli/expected/bun-hello-flow-punctuation
|
||||
judgeChecklist:
|
||||
- updates the existing hello flow instead of creating a new one
|
||||
- adds an optional punctuation input to the flow
|
||||
- updates the step code so the returned greeting uses punctuation
|
||||
- defaults punctuation to an exclamation mark when omitted
|
||||
|
||||
- id: flow-reuse-existing-script
|
||||
prompt: |-
|
||||
There is already a reusable greeting script at `f/lib/format_greeting.ts`.
|
||||
Create a flow at `f/evals/reuse_greeting__flow` that takes a `name` input and reuses that existing script instead of duplicating the logic inline.
|
||||
initial: ai_evals/fixtures/cli/initial/flow-reuse-existing-script
|
||||
expected: ai_evals/fixtures/cli/expected/flow-reuse-existing-script
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- write-flow
|
||||
requiredSkillsBeforeFirstMutation:
|
||||
- write-flow
|
||||
orderedAssistantMentions:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
orderedProposedCommands:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
forbiddenExecutedCommands:
|
||||
- ^wmill generate-metadata
|
||||
- ^wmill sync push
|
||||
judgeChecklist:
|
||||
- creates the requested flow at f/evals/reuse_greeting__flow
|
||||
- reuses the existing script from f/lib by path
|
||||
- does not duplicate the greeting logic in a new inline script
|
||||
- wires the name input into the reused script
|
||||
|
||||
- id: wac-typescript-order-workflow
|
||||
prompt: |-
|
||||
Create a Windmill Workflow-as-Code TypeScript script at `f/evals/order_workflow.ts`.
|
||||
It should take an `orderId` string, load the order in a durable task, checkpoint a processing timestamp with `step`, and return `{ orderId, processedAt, status }`.
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- write-workflow-as-code
|
||||
requiredSkillsBeforeFirstMutation:
|
||||
- write-workflow-as-code
|
||||
forbiddenSkills:
|
||||
- write-flow
|
||||
- write-script-bun
|
||||
- write-script-python3
|
||||
judgeChecklist:
|
||||
- creates the requested TypeScript WAC script at f/evals/order_workflow.ts
|
||||
- uses the Workflow-as-Code SDK from windmill-client
|
||||
- wraps the entrypoint with workflow
|
||||
- uses a durable task for loading the order
|
||||
- uses step to checkpoint the processing timestamp
|
||||
- does not create an OpenFlow flow.yaml or flow folder
|
||||
|
||||
- id: wac-python-approval-workflow
|
||||
prompt: |-
|
||||
Create a Windmill Workflow-as-Code Python script at `f/evals/approval_workflow.py`.
|
||||
It should take a `request_id` string, prepare an approval summary in a task, create resume URLs inside a durable step, wait for approval, and return the approval result.
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- write-workflow-as-code
|
||||
requiredSkillsBeforeFirstMutation:
|
||||
- write-workflow-as-code
|
||||
forbiddenSkills:
|
||||
- write-flow
|
||||
- write-script-bun
|
||||
- write-script-python3
|
||||
judgeChecklist:
|
||||
- creates the requested Python WAC script at f/evals/approval_workflow.py
|
||||
- imports Workflow-as-Code helpers from wmill
|
||||
- decorates an async entrypoint with @workflow
|
||||
- uses @task for the approval summary work
|
||||
- gets resume URLs inside step before waiting for approval
|
||||
- uses wait_for_approval
|
||||
- does not create an OpenFlow flow.yaml or flow folder
|
||||
|
||||
- id: wac-not-openflow-disambiguation
|
||||
prompt: |-
|
||||
Create this as Workflow-as-Code, not an OpenFlow YAML flow: a TypeScript script at `f/evals/fanout_workflow.ts`.
|
||||
It should take an array of customer IDs, process each customer with a WAC task, run the independent customer tasks in parallel, and return the collected results.
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- write-workflow-as-code
|
||||
requiredSkillsBeforeFirstMutation:
|
||||
- write-workflow-as-code
|
||||
forbiddenSkills:
|
||||
- write-flow
|
||||
- write-script-bun
|
||||
- write-script-python3
|
||||
judgeChecklist:
|
||||
- creates the requested TypeScript script at f/evals/fanout_workflow.ts
|
||||
- treats the request as Workflow-as-Code rather than an OpenFlow flow
|
||||
- uses workflow for the script entrypoint
|
||||
- uses task for each customer processing unit
|
||||
- runs independent customer tasks in parallel
|
||||
- does not create a flow folder or flow.yaml
|
||||
|
||||
- id: cli-job-debug-guidance
|
||||
prompt: |-
|
||||
A Windmill job failed.
|
||||
Tell me exactly which `wmill` commands to run to inspect the job details, logs, and final result for job ID `123`.
|
||||
Do not modify any files.
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- cli-commands
|
||||
workspaceUnchanged: true
|
||||
orderedProposedCommands:
|
||||
- wmill job get 123
|
||||
- wmill job logs 123
|
||||
- wmill job result 123
|
||||
forbiddenProposedCommands:
|
||||
- wmill sync push
|
||||
forbiddenExecutedCommands:
|
||||
- ^wmill job get
|
||||
- ^wmill job logs
|
||||
- ^wmill job result
|
||||
judgeChecklist:
|
||||
- does not modify the workspace
|
||||
- recommends commands to inspect the job details
|
||||
- recommends commands to inspect the job logs
|
||||
- recommends commands to inspect the final result
|
||||
|
||||
- id: cli-sync-pull-guidance
|
||||
prompt: |-
|
||||
I want to review remote workspace changes before editing locally.
|
||||
Tell me the first `wmill` command I should run.
|
||||
Do not modify any files.
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- cli-commands
|
||||
workspaceUnchanged: true
|
||||
requiredProposedCommands:
|
||||
- wmill sync pull
|
||||
forbiddenProposedCommands:
|
||||
- wmill sync push
|
||||
forbiddenExecutedCommands:
|
||||
- ^wmill sync pull
|
||||
- ^wmill sync push
|
||||
judgeChecklist:
|
||||
- does not modify the workspace
|
||||
- recommends using sync pull before making local edits
|
||||
- does not recommend pushing first
|
||||
|
||||
- id: cli-script-deploy-guidance
|
||||
prompt: |-
|
||||
I already modified a Windmill script locally and now want the next CLI commands to prepare it and deploy it.
|
||||
Tell me the commands to run, in order.
|
||||
Do not modify any files.
|
||||
cliExpect:
|
||||
requiredSkills:
|
||||
- cli-commands
|
||||
workspaceUnchanged: true
|
||||
orderedAssistantMentions:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
orderedProposedCommands:
|
||||
- wmill generate-metadata
|
||||
- wmill sync push
|
||||
forbiddenExecutedCommands:
|
||||
- ^wmill generate-metadata
|
||||
- ^wmill sync push
|
||||
judgeChecklist:
|
||||
- does not modify the workspace
|
||||
- recommends generate-metadata before sync push
|
||||
- presents the commands in order
|
||||
@@ -1,468 +0,0 @@
|
||||
- id: flow-test0-sum-two-numbers
|
||||
prompt: |-
|
||||
Create a flow that takes two numbers, `a` and `b`, and returns their sum.
|
||||
Keep it simple and use a single step named `sum_numbers`.
|
||||
expected: ai_evals/fixtures/frontend/flow/expected/test0_sum_two_numbers.json
|
||||
runtime:
|
||||
backendPreview:
|
||||
args:
|
||||
a: 4
|
||||
b: 5
|
||||
judgeChecklist:
|
||||
- "the flow takes `a` and `b` as inputs"
|
||||
- "the main step is named `sum_numbers`"
|
||||
- the flow returns the sum of the two numbers
|
||||
|
||||
- id: flow-test1-reuse-existing-script
|
||||
prompt: |-
|
||||
I need a flow that adds two numbers.
|
||||
If there is already a script in the workspace that does that, reuse it instead of rewriting the logic.
|
||||
The flow should take `a` and `b` as inputs and use a single step named `sum_numbers`.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test1_reuse_existing_script_initial.json
|
||||
expected: ai_evals/fixtures/frontend/flow/expected/test1_reuse_existing_script.json
|
||||
runtime:
|
||||
backendPreview:
|
||||
args:
|
||||
a: 2
|
||||
b: 3
|
||||
judgeChecklist:
|
||||
- "the flow takes `a` and `b` as inputs"
|
||||
- "the main step is named `sum_numbers`"
|
||||
- the flow reuses the existing workspace script instead of rewriting the addition logic
|
||||
|
||||
- id: flow-test2-call-existing-subflow
|
||||
prompt: |-
|
||||
Create a parent flow that adds two numbers by reusing an existing flow in the workspace if one already exists.
|
||||
The parent flow should take `a` and `b` as inputs and delegate the calculation instead of inlining it.
|
||||
Use a single step named `call_add_numbers`.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test2_call_existing_subflow_initial.json
|
||||
expected: ai_evals/fixtures/frontend/flow/expected/test2_call_existing_subflow.json
|
||||
runtime:
|
||||
backendPreview:
|
||||
args:
|
||||
a: 7
|
||||
b: 8
|
||||
judgeChecklist:
|
||||
- "the parent flow takes `a` and `b` as inputs"
|
||||
- "the main step is named `call_add_numbers`"
|
||||
- the parent flow delegates to an existing workspace subflow instead of inlining the addition logic
|
||||
|
||||
- id: flow-test13-prefer-existing-workspace-flow
|
||||
prompt: |-
|
||||
Create a parent flow that adds two numbers by reusing an existing flow from the workspace if one fits.
|
||||
A reusable script may also be available, but for this task prefer the existing flow rather than calling a script directly or rewriting the logic inline.
|
||||
The parent flow should take `a` and `b` as inputs and use a single top-level step named `call_add_numbers_flow`.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test13_prefer_existing_workspace_flow_initial.json
|
||||
expected: ai_evals/fixtures/frontend/flow/expected/test13_prefer_existing_workspace_flow.json
|
||||
validate:
|
||||
exactTopLevelStepIds:
|
||||
- call_add_numbers_flow
|
||||
topLevelStepTypes:
|
||||
- id: call_add_numbers_flow
|
||||
type: flow
|
||||
moduleRules:
|
||||
- id: call_add_numbers_flow
|
||||
requiredInputTransforms:
|
||||
- type: javascript
|
||||
expr: flow_input.a
|
||||
- type: javascript
|
||||
expr: flow_input.b
|
||||
runtime:
|
||||
backendPreview:
|
||||
args:
|
||||
a: 10
|
||||
b: 5
|
||||
judgeChecklist:
|
||||
- "the parent flow takes `a` and `b` as inputs"
|
||||
- "the main step is named `call_add_numbers_flow`"
|
||||
- the parent flow reuses the existing workspace flow as a subflow
|
||||
- the parent flow does not call the standalone workspace script directly
|
||||
- the parent flow does not inline the addition logic
|
||||
|
||||
- id: flow-test3-branchone-routing
|
||||
prompt: |-
|
||||
Create a flow that routes incoming support requests based on the customer's tier.
|
||||
The input should contain a string field named `tier`.
|
||||
Free, pro, and enterprise requests should go to different queues, and unknown tiers should fall back to a default queue.
|
||||
Name the main routing step `route_by_tier`.
|
||||
expected: ai_evals/fixtures/frontend/flow/expected/test3_branchone_routing.json
|
||||
judgeChecklist:
|
||||
- "the input schema includes a string field named `tier`"
|
||||
- "the main routing step is named `route_by_tier`"
|
||||
- free requests go to a free queue
|
||||
- pro requests go to a pro queue
|
||||
- enterprise requests go to an enterprise queue
|
||||
- unknown tiers fall back to a default queue
|
||||
|
||||
- id: flow-test4-order-processing-loop
|
||||
prompt: |-
|
||||
Build an order-processing flow.
|
||||
|
||||
The input should include an order with:
|
||||
- an `items` array containing `name`, `price`, and `quantity`
|
||||
- `customer_email`
|
||||
- `shipping_address`
|
||||
|
||||
The flow should:
|
||||
- validate that every item has a positive price and quantity
|
||||
- calculate the order total with 8% tax
|
||||
- check inventory for each item using placeholder availability data
|
||||
- create a shipment if everything is in stock, otherwise create a backorder
|
||||
- send a confirmation using placeholder email logic
|
||||
- return a final order summary with the status
|
||||
validate:
|
||||
schemaAnyOf:
|
||||
- requiredPaths:
|
||||
- order
|
||||
- order.items
|
||||
- order.customer_email
|
||||
- order.shipping_address
|
||||
- requiredPaths:
|
||||
- items
|
||||
- customer_email
|
||||
- shipping_address
|
||||
resolveResultsRefs: true
|
||||
judgeChecklist:
|
||||
- the flow validates that every item has a positive price and quantity
|
||||
- the flow calculates the order total with 8% tax
|
||||
- the flow checks inventory for each item using placeholder availability data
|
||||
- the flow creates a shipment if everything is in stock, otherwise a backorder
|
||||
- the flow sends a confirmation using placeholder email logic
|
||||
- the flow returns a final order summary with the resulting status
|
||||
|
||||
- id: flow-test5-parallel-data-pipeline
|
||||
prompt: |-
|
||||
Create a data-processing flow for three external data sources.
|
||||
|
||||
It should:
|
||||
- load a small placeholder configuration listing the three sources
|
||||
- fetch placeholder records from each source
|
||||
- clean and validate each source's records
|
||||
- combine everything into one dataset
|
||||
- compute an overall quality score
|
||||
- store the result differently depending on the score:
|
||||
- 90 or above goes to the primary database
|
||||
- 70 to 89 goes to a secondary database with a warning
|
||||
- below 70 goes to quarantine and triggers an alert
|
||||
- return a processing report with total records, quality score, and destination
|
||||
judgeChecklist:
|
||||
- the flow loads a placeholder configuration listing three external sources
|
||||
- the flow fetches placeholder records from each source
|
||||
- the flow cleans and validates each source's records
|
||||
- the flow combines everything into one dataset
|
||||
- the flow computes an overall quality score
|
||||
- scores of 90 or above go to the primary database
|
||||
- scores from 70 to 89 go to a secondary database with a warning
|
||||
- scores below 70 go to quarantine and trigger an alert
|
||||
- the final report includes total records, quality score, and destination
|
||||
|
||||
- id: flow-test6-ai-agent-tools
|
||||
prompt: |-
|
||||
Create a customer support flow.
|
||||
|
||||
The input should include `customer_id` and `query_text`.
|
||||
The flow should load the customer's profile and order history, then use an AI assistant to help with the request.
|
||||
The assistant should be able to:
|
||||
- look up orders
|
||||
- check refund eligibility
|
||||
- search FAQs
|
||||
- open a support ticket when needed
|
||||
|
||||
After that, log the interaction and return the assistant's response.
|
||||
judgeChecklist:
|
||||
- "the input schema includes `customer_id` and `query_text`"
|
||||
- the flow loads the customer's profile and order history
|
||||
- the flow uses an AI assistant step
|
||||
- the assistant can look up orders
|
||||
- the assistant can check refund eligibility
|
||||
- the assistant can search FAQs
|
||||
- the assistant can open a support ticket
|
||||
- the flow logs the interaction
|
||||
- the final output returns the assistant response
|
||||
|
||||
- id: flow-test7-simple-modification
|
||||
prompt: |-
|
||||
Update this flow so it validates processed data before saving it.
|
||||
|
||||
After `process_data`, add a `validate_data` step that checks the data array is not empty.
|
||||
If the array is empty, the flow should surface the message `No data to save` and prevent saving.
|
||||
If validation passes, let the save continue normally.
|
||||
Update `save_results` so it uses the validation outcome instead of bypassing it.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test5_initial.json
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
topLevelStepIds:
|
||||
- fetch_data
|
||||
- process_data
|
||||
- validate_data
|
||||
topLevelStepOrder:
|
||||
- fetch_data
|
||||
- process_data
|
||||
- validate_data
|
||||
topLevelStepTypes:
|
||||
- id: fetch_data
|
||||
type: rawscript
|
||||
- id: process_data
|
||||
type: rawscript
|
||||
- id: validate_data
|
||||
type: rawscript
|
||||
judgeChecklist:
|
||||
- the updated flow keeps the original fetch and process steps intact
|
||||
- "a `validate_data` step is added after `process_data`"
|
||||
- "`validate_data` checks that the processed data array is not empty"
|
||||
- "when processed data is empty, the flow surfaces the message `No data to save` and does not save results"
|
||||
- "`save_results` uses the validation outcome instead of reading `results.process_data` directly"
|
||||
- "exact field names or wrapper object shape for the validation result are not important"
|
||||
|
||||
- id: flow-test8-branching-in-loop
|
||||
prompt: |-
|
||||
Update the order-processing logic inside `loop_orders` so different order types are handled differently.
|
||||
|
||||
For `express`, mark the order as priority and use a shipping cost of $15.99.
|
||||
For `standard`, use a shipping cost of $5.99.
|
||||
For `pickup`, mark it as no shipping required with a cost of $0.
|
||||
Keep the existing processing as a fallback for unknown order types.
|
||||
Each path should return the orderId, shipping cost, and shipping type.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test6_initial.json
|
||||
judgeChecklist:
|
||||
- "the existing `loop_orders` flow still handles per-order processing"
|
||||
- exact branching topology is not required as long as `loop_orders` handles the order types correctly
|
||||
- express orders are marked as priority and use a shipping cost of 15.99
|
||||
- standard orders use a shipping cost of 5.99
|
||||
- pickup orders use a shipping cost of 0 and are treated as no shipping required
|
||||
- unknown order types still follow a fallback path
|
||||
- "each processed order returns `orderId`, `shippingCost`, and `shippingType`"
|
||||
|
||||
- id: flow-test9-parallel-refactor
|
||||
prompt: |-
|
||||
Refactor this flow so the enrichment work no longer runs one step at a time.
|
||||
|
||||
`enrich_price`, `enrich_inventory`, and `enrich_reviews` should run independently.
|
||||
Each one should return a fallback value if it fails.
|
||||
Update `combine_data` so it merges the enrichment results and sets a `hasFallbacks` flag when any fallback was used.
|
||||
Keep `get_item` as the first step and `return_result` as the last step.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test7_initial.json
|
||||
validate:
|
||||
topLevelStepIds:
|
||||
- get_item
|
||||
- combine_data
|
||||
- return_result
|
||||
topLevelStepOrder:
|
||||
- get_item
|
||||
- combine_data
|
||||
- return_result
|
||||
topLevelStepTypeCountsAtLeast:
|
||||
- type: branchall
|
||||
count: 1
|
||||
topLevelStepTypes:
|
||||
- id: get_item
|
||||
type: rawscript
|
||||
- id: combine_data
|
||||
type: rawscript
|
||||
- id: return_result
|
||||
type: rawscript
|
||||
moduleRules:
|
||||
- id: enrich_price
|
||||
- id: enrich_inventory
|
||||
- id: enrich_reviews
|
||||
judgeChecklist:
|
||||
- "the updated flow keeps `get_item` as the first step"
|
||||
- "the updated flow keeps `return_result` as the last step"
|
||||
- "`enrich_price`, `enrich_inventory`, and `enrich_reviews` run independently rather than sequentially"
|
||||
- each enrichment path returns a fallback value if it fails
|
||||
- "`combine_data` merges the enrichment results"
|
||||
- "`combine_data` sets `hasFallbacks` when any fallback was used"
|
||||
|
||||
- id: flow-test10-while-loop-counter
|
||||
prompt: |-
|
||||
Create a flow that keeps incrementing a counter until it reaches a target value.
|
||||
The input should include a number field named `target`.
|
||||
Use a top-level loop step named `count_until_target`.
|
||||
Inside it, use a single step named `increment_counter` that increments the current counter.
|
||||
The loop should stop once the counter reaches `target`.
|
||||
After the loop, add a top-level step named `return_final_counter` that returns the last counter value.
|
||||
validate:
|
||||
exactTopLevelStepIds:
|
||||
- count_until_target
|
||||
- return_final_counter
|
||||
topLevelStepOrder:
|
||||
- count_until_target
|
||||
- return_final_counter
|
||||
topLevelStepTypes:
|
||||
- id: count_until_target
|
||||
type: whileloopflow
|
||||
- id: return_final_counter
|
||||
type: rawscript
|
||||
moduleRules:
|
||||
- id: count_until_target
|
||||
hasStopAfterIf: true
|
||||
hasStopAfterAllItersIf: false
|
||||
exactImmediateChildStepIds:
|
||||
- increment_counter
|
||||
immediateChildStepTypes:
|
||||
- id: increment_counter
|
||||
type: rawscript
|
||||
moduleFieldRules:
|
||||
- id: count_until_target
|
||||
path: stop_after_if.expr
|
||||
equals: result >= flow_input.target
|
||||
judgeChecklist:
|
||||
- "the input schema includes a number field named `target`"
|
||||
- "the top-level while loop step is named `count_until_target`"
|
||||
- "`count_until_target` contains a single increment step named `increment_counter`"
|
||||
- "`count_until_target` uses module-level `stop_after_if` to stop when the counter reaches `target`"
|
||||
- "`increment_counter` uses `flow_input.iter.value` or an equivalent loop-state expression and falls back to `0` on the first iteration"
|
||||
- "`return_final_counter` returns the final counter value"
|
||||
|
||||
- id: flow-test11-preprocessor-and-failure-handler
|
||||
prompt: |-
|
||||
Create an event-processing flow for a string payload.
|
||||
|
||||
Before the main processing runs, trim the payload and reject empty strings.
|
||||
The main step should be named `process_event` and return a simple success object.
|
||||
If anything fails, return a compact error object with the error message and the failing step id.
|
||||
expected: ai_evals/fixtures/frontend/flow/expected/test11_preprocessor_failure.json
|
||||
validate:
|
||||
requireSpecialModules:
|
||||
- preprocessor_module
|
||||
- failure_module
|
||||
judgeChecklist:
|
||||
- the flow trims the payload before the main processing runs
|
||||
- the flow rejects empty payload strings
|
||||
- "the main step is named `process_event`"
|
||||
- "`process_event` returns a simple success object"
|
||||
- failures return a compact error object with the error message and failing step id
|
||||
|
||||
- id: flow-test12-approval-step
|
||||
prompt: |-
|
||||
Create a purchase approval flow.
|
||||
|
||||
The input should include `requester_email` and `amount`.
|
||||
Add an approval step named `request_approval` that pauses the flow and asks the approver for a comment.
|
||||
One approval should be enough to continue.
|
||||
After approval, add a final step named `finalize_purchase` that returns an approved status object.
|
||||
validate:
|
||||
topLevelStepIds:
|
||||
- request_approval
|
||||
- finalize_purchase
|
||||
topLevelStepOrder:
|
||||
- request_approval
|
||||
- finalize_purchase
|
||||
topLevelStepTypes:
|
||||
- id: finalize_purchase
|
||||
type: rawscript
|
||||
schemaRequiredPaths:
|
||||
- requester_email
|
||||
- amount
|
||||
requireSuspendSteps:
|
||||
- id: request_approval
|
||||
requiredEvents: 1
|
||||
resumeRequiredStringFieldAnyOf:
|
||||
- comment
|
||||
- approver_comment
|
||||
judgeChecklist:
|
||||
- "the flow includes an approval step named `request_approval`"
|
||||
- "`request_approval` pauses the flow and asks the approver for a comment"
|
||||
- one approval is enough to continue
|
||||
- "the flow includes a final step named `finalize_purchase`"
|
||||
- "`finalize_purchase` returns an approved status object after approval"
|
||||
|
||||
- id: flow-test13-loop-resilience-toggle
|
||||
prompt: |-
|
||||
Update `loop_orders` so it can process orders in parallel.
|
||||
If one order fails, the rest should still continue.
|
||||
Keep the existing order-fetching and summary steps the same.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test6_initial.json
|
||||
validate:
|
||||
exactTopLevelStepIds:
|
||||
- get_orders
|
||||
- loop_orders
|
||||
- summarize
|
||||
topLevelStepTypes:
|
||||
- id: loop_orders
|
||||
type: forloopflow
|
||||
moduleFieldRules:
|
||||
- id: loop_orders
|
||||
path: value.parallel
|
||||
equals: true
|
||||
- id: loop_orders
|
||||
path: value.skip_failures
|
||||
equals: true
|
||||
judgeChecklist:
|
||||
- "the flow keeps `get_orders` before `loop_orders` and `summarize` after it"
|
||||
- "`loop_orders` processes orders in parallel"
|
||||
- "a failure in one order does not stop the remaining orders from being processed"
|
||||
|
||||
- id: flow-test14-modify-existing-special-modules
|
||||
prompt: |-
|
||||
Update this event-processing flow for a string payload.
|
||||
|
||||
Before `process_event` runs, trim the payload and reject empty strings.
|
||||
If anything fails, return a compact error object with the error message and the failing step id.
|
||||
Keep `process_event` as the main step.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/test11_initial.json
|
||||
expected: ai_evals/fixtures/frontend/flow/expected/test11_preprocessor_failure.json
|
||||
validate:
|
||||
requireSpecialModules:
|
||||
- preprocessor_module
|
||||
- failure_module
|
||||
judgeChecklist:
|
||||
- the updated flow trims the payload before the main processing runs
|
||||
- the updated flow rejects empty payload strings
|
||||
- "the existing `process_event` step remains the main step"
|
||||
- failures return a compact error object with the error message and failing step id
|
||||
|
||||
- id: flow-test15-create-current-flow-schedule
|
||||
prompt: |-
|
||||
Update this flow by adding a final step named `return_schedule_status`.
|
||||
It should return an object with `scheduled: true` and the order summary from `results.summarize_orders`.
|
||||
Also create an enabled daily schedule named `order_processing_daily` for the current flow.
|
||||
It should run every day at 07:30 UTC with empty args.
|
||||
Do not ask me for the flow path.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/scheduled_order_flow.json
|
||||
validate:
|
||||
topLevelStepIds:
|
||||
- return_schedule_status
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- create_schedule
|
||||
toolCallArgs:
|
||||
- tool: create_schedule
|
||||
field: path
|
||||
stringStartsWithAnyOf:
|
||||
- f/
|
||||
- u/
|
||||
stringMustNotStartWithAnyOf:
|
||||
- schedules/
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- "the flow includes a final top-level step named `return_schedule_status`"
|
||||
- "`return_schedule_status` returns `scheduled: true` and the order summary"
|
||||
|
||||
- id: flow-test16-create-current-flow-http-trigger
|
||||
prompt: |-
|
||||
Update this flow by adding a final step named `webhook_response`.
|
||||
It should return an object with `ok: true` and the order summary from `results.summarize_orders`.
|
||||
Also create a public POST HTTP endpoint named `order_processing_webhook` for the current flow.
|
||||
Use route path `ai-evals/order-processing` and no authentication.
|
||||
Do not ask me for the flow path.
|
||||
initial: ai_evals/fixtures/frontend/flow/initial/scheduled_order_flow.json
|
||||
validate:
|
||||
topLevelStepIds:
|
||||
- webhook_response
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- create_trigger
|
||||
toolCallArgs:
|
||||
- tool: create_trigger
|
||||
field: path
|
||||
stringStartsWithAnyOf:
|
||||
- f/
|
||||
- u/
|
||||
stringMustNotStartWithAnyOf:
|
||||
- schedules/
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- "the flow includes a final top-level step named `webhook_response`"
|
||||
- "`webhook_response` returns `ok: true` and the order summary"
|
||||
@@ -1,89 +0,0 @@
|
||||
- id: global-test1-script-create
|
||||
prompt: |-
|
||||
Create a draft Bun script at `f/evals/global/greet_user`.
|
||||
It should take a string `name` input and return `Hello, ${name}!`.
|
||||
Leave it as an AI draft only; do not deploy or save it.
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: script
|
||||
path: f/evals/global/greet_user
|
||||
language: bun
|
||||
valueIncludes:
|
||||
- name
|
||||
- Hello
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_script
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
judgeChecklist:
|
||||
- creates a Bun script draft at f/evals/global/greet_user
|
||||
- the script accepts a name input
|
||||
- the script returns a greeting containing Hello, the provided name, and an exclamation mark
|
||||
- the result stays as an AI draft and is not deployed or saved to the workspace
|
||||
|
||||
- id: global-test2-script-edit-existing
|
||||
prompt: |-
|
||||
Update the existing workspace script at `f/evals/global/format_greeting`.
|
||||
Keep it as a Bun script, but change the greeting so the provided name is uppercased and the returned message ends with an exclamation mark.
|
||||
Leave the result as an AI draft only; do not deploy or save it.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: script
|
||||
path: f/evals/global/format_greeting
|
||||
language: bun
|
||||
valueIncludes:
|
||||
- toUpperCase
|
||||
- "!"
|
||||
toolExpect:
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
judgeChecklist:
|
||||
- creates an AI draft for the existing f/evals/global/format_greeting script
|
||||
- preserves the script as Bun
|
||||
- uppercases the provided name in the greeting
|
||||
- returns a message ending with an exclamation mark
|
||||
- does not deploy or save the draft to the workspace
|
||||
|
||||
- id: global-test3-flow-create
|
||||
prompt: |-
|
||||
Create a draft flow at `f/evals/global/sum_numbers`.
|
||||
It should take two numeric inputs, `a` and `b`, and return their sum.
|
||||
Leave it as an AI draft only; do not deploy or save it.
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: flow
|
||||
path: f/evals/global/sum_numbers
|
||||
valueIncludes:
|
||||
- modules
|
||||
- rawscript
|
||||
- flow_input.a
|
||||
- flow_input.b
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_flow
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: write_flow
|
||||
field: modules
|
||||
stringStartsWithAnyOf:
|
||||
- "["
|
||||
judgeChecklist:
|
||||
- creates a flow draft at f/evals/global/sum_numbers
|
||||
- the flow accepts numeric inputs a and b
|
||||
- the flow returns the sum of a and b
|
||||
- the result stays as an AI draft and is not deployed or saved to the workspace
|
||||
@@ -1,59 +0,0 @@
|
||||
- id: script-test1-greet-user
|
||||
prompt: |-
|
||||
Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`.
|
||||
Do not wrap the result in an object or array.
|
||||
Keep it simple and do not add external dependencies.
|
||||
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
|
||||
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
|
||||
judgeChecklist:
|
||||
- uses the existing `name` input
|
||||
- returns a plain greeting string
|
||||
- does not wrap the result in an object or array
|
||||
|
||||
- id: script-test2-create-current-script-schedule
|
||||
prompt: |-
|
||||
Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`.
|
||||
Also create an enabled daily schedule named `greet_user_daily` for the current script.
|
||||
It should run every day at 09:00 UTC and pass `{ "name": "Alice" }` as args.
|
||||
Do not ask me for the script path.
|
||||
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
|
||||
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- create_schedule
|
||||
toolCallArgs:
|
||||
- tool: create_schedule
|
||||
field: path
|
||||
stringStartsWithAnyOf:
|
||||
- f/
|
||||
- u/
|
||||
stringMustNotStartWithAnyOf:
|
||||
- schedules/
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- uses the existing `name` input
|
||||
- returns a plain greeting string
|
||||
|
||||
- id: script-test3-create-current-script-http-trigger
|
||||
prompt: |-
|
||||
Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`.
|
||||
Also create a public POST HTTP endpoint named `greet_user_webhook` for the current script.
|
||||
Use route path `ai-evals/greet-user` and no authentication.
|
||||
Do not ask me for the script path.
|
||||
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
|
||||
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- create_trigger
|
||||
toolCallArgs:
|
||||
- tool: create_trigger
|
||||
field: path
|
||||
stringStartsWithAnyOf:
|
||||
- f/
|
||||
- u/
|
||||
stringMustNotStartWithAnyOf:
|
||||
- schedules/
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- uses the existing `name` input
|
||||
- returns a plain greeting string
|
||||
@@ -1,358 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
import { loadCases, loadSelectedCases } from "../core/cases";
|
||||
import {
|
||||
BACKEND_VALIDATION_MODES,
|
||||
parseBackendValidationMode,
|
||||
} from "../core/backendValidation";
|
||||
import {
|
||||
EVAL_MODELS,
|
||||
type EvalModelSpec,
|
||||
formatRunModelLabel,
|
||||
getCliEvalModel,
|
||||
getEvalModelHelpText,
|
||||
resolveEvalModel,
|
||||
} from "../core/models";
|
||||
import {
|
||||
appendHistoryRecord,
|
||||
buildRunResult,
|
||||
formatRunSummary,
|
||||
resolveRunOutputPath,
|
||||
writeRunArtifacts,
|
||||
writeRunResult,
|
||||
} from "../core/results";
|
||||
import { runSuite } from "../core/runSuite";
|
||||
import { EVAL_MODES, type EvalMode } from "../core/types";
|
||||
import { DEFAULT_JUDGE_MODEL } from "../core/judge";
|
||||
import { createCliModeRunner } from "../modes/cli";
|
||||
import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime";
|
||||
import { resolveWindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
import { assertWindmillBackendReachable } from "../adapters/frontend/windmillBackend";
|
||||
|
||||
async function main() {
|
||||
const program = new Command()
|
||||
.name("bun run cli --")
|
||||
.description(
|
||||
"Run AI eval cases against the current production prompts and guidance",
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.showSuggestionAfterError()
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Examples:",
|
||||
" bun run cli -- models",
|
||||
" bun run cli -- cases",
|
||||
" bun run cli -- cases flow",
|
||||
" bun run cli -- run flow",
|
||||
" bun run cli -- run flow --model 4o",
|
||||
" bun run cli -- run flow --models haiku,opus,4o",
|
||||
" bun run cli -- run flow flow-test0-sum-two-numbers --verbose",
|
||||
" bun run cli -- run flow --record",
|
||||
" bun run cli -- run flow --backend-validation preview",
|
||||
" bun run cli -- run flow flow-test5-simple-modification --runs 3",
|
||||
" bun run cli -- run global global-test1-script-create",
|
||||
" bun run cli -- run cli bun-hello-script",
|
||||
"",
|
||||
"Models:",
|
||||
getEvalModelHelpText(),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
program
|
||||
.command("models")
|
||||
.description("List available model aliases")
|
||||
.action(() => {
|
||||
handleModels();
|
||||
});
|
||||
|
||||
program
|
||||
.command("cases")
|
||||
.description("List available cases")
|
||||
.argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode)
|
||||
.action(async (mode?: EvalMode) => {
|
||||
await handleCases(mode);
|
||||
});
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Run one benchmark mode")
|
||||
.argument("<mode>", "cli, flow, script, app, or global", parseMode)
|
||||
.argument("[caseIds...]", "specific case ids to run")
|
||||
.option(
|
||||
"--runs <n>",
|
||||
"number of attempts per case",
|
||||
parsePositiveInteger,
|
||||
1,
|
||||
)
|
||||
.option("--output <path>", "write the result JSON to this path")
|
||||
.option(
|
||||
"--model <name>",
|
||||
`model alias (${EVAL_MODELS.map((entry) => entry.id).join(", ")})`,
|
||||
)
|
||||
.option(
|
||||
"--models <names>",
|
||||
"comma-separated model aliases to run sequentially",
|
||||
)
|
||||
.option("--verbose", "stream assistant output during frontend runs")
|
||||
.option(
|
||||
"--record",
|
||||
"append a compact summary line to ai_evals/history/<mode>.jsonl",
|
||||
)
|
||||
.option(
|
||||
"--backend-validation <mode>",
|
||||
`backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`,
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
mode: EvalMode,
|
||||
caseIds: string[],
|
||||
options: {
|
||||
runs: number;
|
||||
output?: string;
|
||||
model?: string;
|
||||
models?: string;
|
||||
verbose?: boolean;
|
||||
record?: boolean;
|
||||
backendValidation?: string;
|
||||
},
|
||||
) => {
|
||||
await handleRun({
|
||||
mode,
|
||||
caseIds,
|
||||
runs: options.runs,
|
||||
outputPath: options.output,
|
||||
model: options.model,
|
||||
models: options.models,
|
||||
verbose: options.verbose ?? false,
|
||||
record: options.record ?? false,
|
||||
backendValidation: options.backendValidation,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await program.parseAsync(process.argv);
|
||||
}
|
||||
|
||||
async function handleCases(mode?: EvalMode) {
|
||||
const modes = mode ? [mode] : [...EVAL_MODES];
|
||||
|
||||
for (const entry of modes) {
|
||||
const cases = await loadCases(entry);
|
||||
process.stdout.write(`${entry} (${cases.length})\n`);
|
||||
for (const evalCase of cases) {
|
||||
process.stdout.write(`- ${evalCase.id}\n`);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
|
||||
function handleModels() {
|
||||
process.stdout.write("Available models\n");
|
||||
for (const model of EVAL_MODELS) {
|
||||
const supports = [
|
||||
...(model.frontend ? ["flow", "script", "app", "global"] : []),
|
||||
...(model.cli ? ["cli"] : []),
|
||||
];
|
||||
const aliases = [
|
||||
model.id,
|
||||
...model.aliases.filter((alias) => alias !== model.id),
|
||||
];
|
||||
process.stdout.write(`- ${model.id}: ${model.label}\n`);
|
||||
process.stdout.write(` aliases: ${aliases.join(", ")}\n`);
|
||||
process.stdout.write(` modes: ${supports.join(", ")}\n`);
|
||||
}
|
||||
process.stdout.write(`\nJudge model: ${DEFAULT_JUDGE_MODEL}\n`);
|
||||
}
|
||||
|
||||
async function handleRun(input: {
|
||||
mode: EvalMode;
|
||||
caseIds: string[];
|
||||
runs: number;
|
||||
outputPath?: string;
|
||||
model?: string;
|
||||
models?: string;
|
||||
verbose: boolean;
|
||||
record: boolean;
|
||||
backendValidation?: string;
|
||||
}) {
|
||||
if (input.record && input.caseIds.length > 0) {
|
||||
throw new Error(
|
||||
"--record only supports full-suite runs; omit case ids to record history",
|
||||
);
|
||||
}
|
||||
if (input.model && input.models) {
|
||||
throw new Error("Use either --model or --models, not both");
|
||||
}
|
||||
|
||||
const selectedCases = await loadSelectedCases(input.mode, input.caseIds);
|
||||
const models = resolveRequestedModels(input.mode, input.model, input.models);
|
||||
const backendValidation = parseBackendValidationMode(
|
||||
input.backendValidation ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION,
|
||||
);
|
||||
if (input.outputPath && models.length > 1) {
|
||||
throw new Error("--output only supports a single model run");
|
||||
}
|
||||
if (
|
||||
backendValidation !== "off" &&
|
||||
input.mode !== "flow" &&
|
||||
input.mode !== "script"
|
||||
) {
|
||||
throw new Error(
|
||||
"--backend-validation currently supports only flow and script modes",
|
||||
);
|
||||
}
|
||||
if (input.mode !== "cli") {
|
||||
await assertWindmillBackendReachable(resolveWindmillBackendSettings());
|
||||
}
|
||||
|
||||
const summaries: Array<{
|
||||
label: string;
|
||||
passRate: number;
|
||||
averageDurationMs: number;
|
||||
}> = [];
|
||||
|
||||
for (const [index, model] of models.entries()) {
|
||||
const runModel = formatRunModelLabel(input.mode, model);
|
||||
if (models.length > 1) {
|
||||
process.stdout.write(
|
||||
`${index > 0 ? "\n" : ""}=== ${input.mode} ${model.id} (${runModel}) ===\n`,
|
||||
);
|
||||
}
|
||||
process.stderr.write(`Starting ${input.mode} benchmark...\n`);
|
||||
|
||||
const result =
|
||||
input.mode === "cli"
|
||||
? await runCliBenchmark(
|
||||
selectedCases,
|
||||
input.runs,
|
||||
getCliEvalModel(model),
|
||||
runModel,
|
||||
)
|
||||
: await runFrontendBenchmarkAdapter({
|
||||
mode: input.mode,
|
||||
caseIds: input.caseIds,
|
||||
runs: input.runs,
|
||||
model: model.id,
|
||||
verbose: input.verbose,
|
||||
backendValidation,
|
||||
});
|
||||
|
||||
const resolvedOutputPath =
|
||||
models.length === 1
|
||||
? resolveRunOutputPath(input.mode, input.outputPath)
|
||||
: resolveRunOutputPath(input.mode);
|
||||
const artifactsPath = await writeRunArtifacts(result, resolvedOutputPath);
|
||||
const resultPath = await writeRunResult(result, resolvedOutputPath);
|
||||
const historyPath = input.record ? await appendHistoryRecord(result) : null;
|
||||
process.stdout.write(`${formatRunSummary(result)}\n`);
|
||||
process.stdout.write(`Saved: ${resultPath}\n`);
|
||||
if (artifactsPath) {
|
||||
process.stdout.write(`Artifacts: ${artifactsPath}\n`);
|
||||
}
|
||||
if (historyPath) {
|
||||
process.stdout.write(`Recorded: ${historyPath}\n`);
|
||||
}
|
||||
|
||||
summaries.push({
|
||||
label: `${model.id} (${runModel})`,
|
||||
passRate: result.passRate,
|
||||
averageDurationMs: result.averageDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (summaries.length > 1) {
|
||||
process.stdout.write("\nModel summary\n");
|
||||
for (const summary of summaries) {
|
||||
process.stdout.write(
|
||||
`- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runCliBenchmark(
|
||||
cases: Awaited<ReturnType<typeof loadSelectedCases>>,
|
||||
runs: number,
|
||||
model: ReturnType<typeof getCliEvalModel>,
|
||||
runModel: string,
|
||||
) {
|
||||
const caseResults = await runSuite({
|
||||
modeRunner: createCliModeRunner(model),
|
||||
cases,
|
||||
runs,
|
||||
runModel,
|
||||
judgeModel: DEFAULT_JUDGE_MODEL,
|
||||
});
|
||||
|
||||
return buildRunResult({
|
||||
mode: "cli",
|
||||
runs,
|
||||
runModel,
|
||||
judgeModel: DEFAULT_JUDGE_MODEL,
|
||||
caseResults,
|
||||
});
|
||||
}
|
||||
|
||||
function parseMode(value: string): EvalMode {
|
||||
if (EVAL_MODES.includes(value as EvalMode)) {
|
||||
return value as EvalMode;
|
||||
}
|
||||
throw new InvalidArgumentError(
|
||||
`mode must be one of: ${EVAL_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseOptionalMode(value: string | undefined): EvalMode | undefined {
|
||||
return value ? parseMode(value) : undefined;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new InvalidArgumentError("must be a positive integer");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveRequestedModels(
|
||||
mode: EvalMode,
|
||||
singleModel?: string,
|
||||
multipleModels?: string,
|
||||
): EvalModelSpec[] {
|
||||
if (!multipleModels) {
|
||||
return [resolveEvalModel(mode, singleModel)];
|
||||
}
|
||||
|
||||
const aliases = multipleModels
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (aliases.length === 0) {
|
||||
throw new Error("--models requires at least one model alias");
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const models: EvalModelSpec[] = [];
|
||||
for (const alias of aliases) {
|
||||
const model = resolveEvalModel(mode, alias);
|
||||
if (seen.has(model.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(model.id);
|
||||
models.push(model);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { buildAppArtifacts } from "./appArtifacts";
|
||||
|
||||
describe("buildAppArtifacts", () => {
|
||||
it("emits lint diagnostics as an artifact alongside app files", () => {
|
||||
const artifacts = buildAppArtifacts({
|
||||
frontend: {
|
||||
"/index.tsx":
|
||||
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n",
|
||||
},
|
||||
backend: {},
|
||||
datatables: [],
|
||||
});
|
||||
|
||||
const lintArtifact = artifacts.find((artifact) => artifact.path === "lint.json");
|
||||
expect(lintArtifact).toBeDefined();
|
||||
expect(lintArtifact?.content).toContain('"errorCount": 1');
|
||||
expect(lintArtifact?.content).toContain("deleteRecipe");
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { collectAppDiagnostics } from "./appDiagnostics";
|
||||
import type { BenchmarkArtifactFile } from "./types";
|
||||
import type { AppFilesState } from "./validators";
|
||||
|
||||
export function buildAppArtifacts(actual: AppFilesState): BenchmarkArtifactFile[] {
|
||||
const diagnostics = collectAppDiagnostics({
|
||||
frontend: actual.frontend,
|
||||
backend: actual.backend,
|
||||
});
|
||||
const artifacts: BenchmarkArtifactFile[] = [
|
||||
{
|
||||
path: "app.json",
|
||||
content: JSON.stringify(actual, null, 2) + "\n",
|
||||
},
|
||||
{
|
||||
path: "lint.json",
|
||||
content: JSON.stringify(diagnostics, null, 2) + "\n",
|
||||
},
|
||||
];
|
||||
|
||||
for (const [filePath, content] of Object.entries(actual.frontend)) {
|
||||
artifacts.push({
|
||||
path: `frontend${filePath.startsWith("/") ? filePath : `/${filePath}`}`,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, runnable] of Object.entries(actual.backend)) {
|
||||
artifacts.push({
|
||||
path: `backend/${key}/meta.json`,
|
||||
content: JSON.stringify(runnable, null, 2) + "\n",
|
||||
});
|
||||
|
||||
const inlineContent = runnable.inlineScript?.content;
|
||||
if (inlineContent) {
|
||||
const extension = runnable.inlineScript?.language === "python3" ? "py" : "ts";
|
||||
artifacts.push({
|
||||
path: `backend/${key}/main.${extension}`,
|
||||
content: inlineContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (actual.datatables.length > 0) {
|
||||
artifacts.push({
|
||||
path: "datatables.json",
|
||||
content: JSON.stringify(actual.datatables, null, 2) + "\n",
|
||||
});
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadAppFixture } from "../adapters/frontend/core/app/appFixtureLoader";
|
||||
import { buildAppWmillTypes, collectAppDiagnostics } from "./appDiagnostics";
|
||||
|
||||
const FILE_MANAGER_FIXTURE = fileURLToPath(
|
||||
new URL("../fixtures/frontend/app/initial/file_manager", import.meta.url)
|
||||
);
|
||||
|
||||
describe("collectAppDiagnostics", () => {
|
||||
it("accepts seeded multi-file apps without static analysis errors", async () => {
|
||||
const fixture = await loadAppFixture(FILE_MANAGER_FIXTURE);
|
||||
const diagnostics = collectAppDiagnostics({
|
||||
frontend: fixture.frontend,
|
||||
backend: fixture.backend,
|
||||
});
|
||||
|
||||
expect(diagnostics.lintResult.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("reports missing backend references through the generated wmill typings", () => {
|
||||
const diagnostics = collectAppDiagnostics({
|
||||
frontend: {
|
||||
"/index.tsx":
|
||||
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n",
|
||||
},
|
||||
backend: {
|
||||
listRecipes: {
|
||||
name: "List recipes",
|
||||
type: "inline",
|
||||
inlineScript: {
|
||||
language: "bun",
|
||||
content: "export async function main() { return [] }\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(diagnostics.lintResult.errorCount).toBeGreaterThan(0);
|
||||
expect(diagnostics.lintResult.errors.frontend["/index.tsx"]?.join("\n")).toContain(
|
||||
"Property 'deleteRecipe' does not exist"
|
||||
);
|
||||
});
|
||||
|
||||
it("reports backend argument shape mismatches when the inline main signature is portable", () => {
|
||||
const diagnostics = collectAppDiagnostics({
|
||||
frontend: {
|
||||
"/index.tsx":
|
||||
"import { backend } from 'wmill'\nexport default function App() { void backend.addRecipe({ name: 'Soup' }); return <div /> }\n",
|
||||
},
|
||||
backend: {
|
||||
addRecipe: {
|
||||
name: "Add recipe",
|
||||
type: "inline",
|
||||
inlineScript: {
|
||||
language: "bun",
|
||||
content:
|
||||
"export async function main({ name, ingredients }: { name: string; ingredients: string }) { return { name, ingredients } }\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(diagnostics.lintResult.errorCount).toBeGreaterThan(0);
|
||||
expect(diagnostics.lintResult.errors.frontend["/index.tsx"]?.join("\n")).toContain(
|
||||
"Property 'ingredients' is missing"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAppWmillTypes", () => {
|
||||
it("generates callable signatures for zero-arg and typed runnables", () => {
|
||||
const wmillTypes = buildAppWmillTypes({
|
||||
listRecipes: {
|
||||
name: "List recipes",
|
||||
type: "inline",
|
||||
inlineScript: {
|
||||
language: "bun",
|
||||
content: "export async function main() { return [] }\n",
|
||||
},
|
||||
},
|
||||
addRecipe: {
|
||||
name: "Add recipe",
|
||||
type: "inline",
|
||||
inlineScript: {
|
||||
language: "bun",
|
||||
content:
|
||||
"export async function main({ name }: { name: string }) { return { name } }\n",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wmillTypes).toContain('"listRecipes": () => Promise<any>;');
|
||||
expect(wmillTypes).toContain('"addRecipe": (args: { name: string }) => Promise<any>;');
|
||||
});
|
||||
});
|
||||
@@ -1,758 +0,0 @@
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import type { LintResult } from "../../frontend/src/lib/components/copilot/chat/app/core";
|
||||
|
||||
const FRONTEND_ROOT = "/__ai_evals__/frontend";
|
||||
const BACKEND_ROOT = "/__ai_evals__/backend";
|
||||
const FRONTEND_REACT_SHIM_PATH = `${FRONTEND_ROOT}/__react_shim__.d.ts`;
|
||||
const FRONTEND_WMILL_TYPES_PATH = `${FRONTEND_ROOT}/wmill.d.ts`;
|
||||
const BACKEND_WINDMILL_CLIENT_SHIM_PATH = `${BACKEND_ROOT}/__windmill_client__.d.ts`;
|
||||
const TS_LIKE_LANGUAGES = new Set([
|
||||
"bun",
|
||||
"deno",
|
||||
"nativets",
|
||||
"bunnative",
|
||||
"ts",
|
||||
"typescript",
|
||||
]);
|
||||
const JS_LIKE_LANGUAGES = new Set(["javascript", "js", "nodejs"]);
|
||||
const SAFE_TYPE_REFERENCE_NAMES = new Set([
|
||||
"Array",
|
||||
"Date",
|
||||
"Exclude",
|
||||
"Extract",
|
||||
"NonNullable",
|
||||
"Omit",
|
||||
"Partial",
|
||||
"Pick",
|
||||
"Promise",
|
||||
"Readonly",
|
||||
"ReadonlyArray",
|
||||
"Record",
|
||||
"Required",
|
||||
"ReturnType",
|
||||
"Uppercase",
|
||||
"Lowercase",
|
||||
"Capitalize",
|
||||
"Uncapitalize",
|
||||
]);
|
||||
|
||||
const FRONTEND_REACT_SHIM = `declare namespace React {
|
||||
type SetStateAction<S> = S | ((prevState: S) => S);
|
||||
type Dispatch<A> = (value: A) => void;
|
||||
type FC<P = {}> = (props: P) => any;
|
||||
type ReactNode = any;
|
||||
interface FormEvent<T = EventTarget> {
|
||||
preventDefault(): void;
|
||||
target: T;
|
||||
currentTarget: T;
|
||||
}
|
||||
interface ChangeEvent<T = EventTarget> {
|
||||
target: T;
|
||||
currentTarget: T;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace JSX {
|
||||
interface IntrinsicAttributes {
|
||||
key?: any;
|
||||
}
|
||||
interface IntrinsicElements {
|
||||
[elementName: string]: any;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "react" {
|
||||
export type SetStateAction<S> = React.SetStateAction<S>;
|
||||
export type Dispatch<A> = React.Dispatch<A>;
|
||||
export type FC<P = {}> = React.FC<P>;
|
||||
export type ReactNode = React.ReactNode;
|
||||
export type FormEvent<T = EventTarget> = React.FormEvent<T>;
|
||||
export type ChangeEvent<T = EventTarget> = React.ChangeEvent<T>;
|
||||
export function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
|
||||
export function useEffect(effect: () => void | (() => void), deps?: readonly unknown[]): void;
|
||||
const React: any;
|
||||
export default React;
|
||||
}
|
||||
`;
|
||||
|
||||
const BACKEND_WINDMILL_CLIENT_SHIM = `declare const console: {
|
||||
log: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
};
|
||||
|
||||
declare module "windmill-client" {
|
||||
interface SqlQueryResult {
|
||||
fetch(): Promise<any>;
|
||||
fetchOne(): Promise<any>;
|
||||
}
|
||||
|
||||
interface SqlTemplateFunction {
|
||||
(strings: TemplateStringsArray, ...values: any[]): SqlQueryResult;
|
||||
}
|
||||
|
||||
interface WindmillClient {
|
||||
datatable(name?: string): SqlTemplateFunction;
|
||||
ducklake(name?: string): SqlTemplateFunction;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const wmill: WindmillClient;
|
||||
export = wmill;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface AppDiagnosticRunnable {
|
||||
name?: string;
|
||||
type?: string;
|
||||
path?: string;
|
||||
inlineScript?: {
|
||||
language?: string;
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppStaticDiagnostic {
|
||||
source: "frontend" | "backend";
|
||||
target: string;
|
||||
message: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
export interface AppDiagnosticsResult {
|
||||
lintResult: LintResult;
|
||||
diagnostics: AppStaticDiagnostic[];
|
||||
}
|
||||
|
||||
export function buildAppWmillTypes(
|
||||
backend: Record<string, AppDiagnosticRunnable> = {},
|
||||
): string {
|
||||
return `// THIS FILE IS READ-ONLY
|
||||
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
|
||||
|
||||
export declare const backend: {
|
||||
${Object.entries(backend)
|
||||
.map(
|
||||
([key, runnable]) =>
|
||||
` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, false)};`,
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
|
||||
export declare const backendAsync: {
|
||||
${Object.entries(backend)
|
||||
.map(
|
||||
([key, runnable]) =>
|
||||
` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, true)};`,
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
type: "QueuedJob" | "CompletedJob";
|
||||
id: string;
|
||||
created_at: number;
|
||||
started_at: number | undefined;
|
||||
duration_ms: number;
|
||||
success: boolean;
|
||||
args: any;
|
||||
result: any;
|
||||
};
|
||||
|
||||
export declare function waitJob(id: string): Promise<Job>;
|
||||
export declare function getJob(id: string): Promise<Job>;
|
||||
|
||||
export type StreamUpdate = {
|
||||
new_result_stream?: string;
|
||||
stream_offset?: number;
|
||||
};
|
||||
|
||||
export declare function streamJob(id: string, onUpdate?: (data: StreamUpdate) => void): Promise<any>;
|
||||
`;
|
||||
}
|
||||
|
||||
export function collectAppDiagnostics(input: {
|
||||
frontend: Record<string, string>;
|
||||
backend: Record<string, AppDiagnosticRunnable>;
|
||||
}): AppDiagnosticsResult {
|
||||
const frontendDiagnostics = collectFrontendDiagnostics(
|
||||
input.frontend,
|
||||
input.backend,
|
||||
);
|
||||
const backendDiagnostics = collectBackendDiagnostics(input.backend);
|
||||
const diagnostics = dedupeDiagnostics([
|
||||
...frontendDiagnostics,
|
||||
...backendDiagnostics,
|
||||
]).sort(compareDiagnostics);
|
||||
|
||||
return {
|
||||
diagnostics,
|
||||
lintResult: {
|
||||
errors: {
|
||||
frontend: groupMessages(
|
||||
diagnostics.filter((diagnostic) => diagnostic.source === "frontend"),
|
||||
),
|
||||
backend: groupMessages(
|
||||
diagnostics.filter((diagnostic) => diagnostic.source === "backend"),
|
||||
),
|
||||
},
|
||||
warnings: {
|
||||
frontend: {},
|
||||
backend: {},
|
||||
},
|
||||
errorCount: diagnostics.length,
|
||||
warningCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectFrontendDiagnostics(
|
||||
frontend: Record<string, string>,
|
||||
backend: Record<string, AppDiagnosticRunnable>,
|
||||
): AppStaticDiagnostic[] {
|
||||
const frontendFiles = Object.entries(frontend)
|
||||
.filter(([filePath]) => isFrontendCodeFile(filePath))
|
||||
.map(
|
||||
([filePath, content]) =>
|
||||
[toFrontendVirtualPath(filePath), content] as const,
|
||||
);
|
||||
|
||||
const virtualFiles = new Map<string, string>([
|
||||
[FRONTEND_REACT_SHIM_PATH, FRONTEND_REACT_SHIM],
|
||||
[
|
||||
FRONTEND_WMILL_TYPES_PATH,
|
||||
wrapModuleDeclaration("wmill", buildAppWmillTypes(backend)),
|
||||
],
|
||||
...frontendFiles,
|
||||
]);
|
||||
const host = createVirtualCompilerHost(
|
||||
virtualFiles,
|
||||
getFrontendCompilerOptions(),
|
||||
);
|
||||
const rootNames = [...virtualFiles.keys()];
|
||||
const program = ts.createProgram({
|
||||
rootNames,
|
||||
options: getFrontendCompilerOptions(),
|
||||
host,
|
||||
});
|
||||
|
||||
return ts.getPreEmitDiagnostics(program).flatMap((diagnostic) =>
|
||||
mapTypeScriptDiagnostic({
|
||||
diagnostic,
|
||||
source: "frontend",
|
||||
toTarget(fileName) {
|
||||
const normalized = normalizeFileName(fileName);
|
||||
if (normalized === FRONTEND_WMILL_TYPES_PATH) {
|
||||
return "/wmill.d.ts";
|
||||
}
|
||||
if (!normalized.startsWith(`${FRONTEND_ROOT}/`)) {
|
||||
return null;
|
||||
}
|
||||
if (normalized === FRONTEND_REACT_SHIM_PATH) {
|
||||
return null;
|
||||
}
|
||||
return normalized.slice(FRONTEND_ROOT.length);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function collectBackendDiagnostics(
|
||||
backend: Record<string, AppDiagnosticRunnable>,
|
||||
): AppStaticDiagnostic[] {
|
||||
const backendFiles = Object.entries(backend)
|
||||
.filter(([, runnable]) => isTypeCheckableBackendRunnable(runnable))
|
||||
.map(
|
||||
([key, runnable]) =>
|
||||
[
|
||||
`${BACKEND_ROOT}/${key}/main.${getBackendFileExtension(runnable.inlineScript?.language)}`,
|
||||
runnable.inlineScript?.content ?? "",
|
||||
] as const,
|
||||
);
|
||||
|
||||
if (backendFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const virtualFiles = new Map<string, string>([
|
||||
[BACKEND_WINDMILL_CLIENT_SHIM_PATH, BACKEND_WINDMILL_CLIENT_SHIM],
|
||||
...backendFiles,
|
||||
]);
|
||||
const host = createVirtualCompilerHost(
|
||||
virtualFiles,
|
||||
getBackendCompilerOptions(),
|
||||
);
|
||||
const rootNames = [...virtualFiles.keys()];
|
||||
const program = ts.createProgram({
|
||||
rootNames,
|
||||
options: getBackendCompilerOptions(),
|
||||
host,
|
||||
});
|
||||
|
||||
return ts.getPreEmitDiagnostics(program).flatMap((diagnostic) =>
|
||||
mapTypeScriptDiagnostic({
|
||||
diagnostic,
|
||||
source: "backend",
|
||||
toTarget(fileName) {
|
||||
const normalized = normalizeFileName(fileName);
|
||||
if (normalized === BACKEND_WINDMILL_CLIENT_SHIM_PATH) {
|
||||
return null;
|
||||
}
|
||||
if (!normalized.startsWith(`${BACKEND_ROOT}/`)) {
|
||||
return null;
|
||||
}
|
||||
const relativePath = normalized.slice(BACKEND_ROOT.length + 1);
|
||||
const runnableKey = relativePath.split("/")[0];
|
||||
return runnableKey || null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getFrontendCompilerOptions(): ts.CompilerOptions {
|
||||
return {
|
||||
allowJs: true,
|
||||
checkJs: true,
|
||||
esModuleInterop: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
jsx: ts.JsxEmit.Preserve,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Node10,
|
||||
noEmit: true,
|
||||
noImplicitAny: false,
|
||||
skipLibCheck: true,
|
||||
strict: false,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
lib: ["lib.es2022.d.ts", "lib.dom.d.ts"],
|
||||
};
|
||||
}
|
||||
|
||||
function getBackendCompilerOptions(): ts.CompilerOptions {
|
||||
return {
|
||||
allowJs: true,
|
||||
checkJs: true,
|
||||
esModuleInterop: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Node10,
|
||||
noEmit: true,
|
||||
noImplicitAny: false,
|
||||
skipLibCheck: true,
|
||||
strict: false,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
lib: ["lib.es2022.d.ts"],
|
||||
};
|
||||
}
|
||||
|
||||
function createVirtualCompilerHost(
|
||||
files: Map<string, string>,
|
||||
options: ts.CompilerOptions,
|
||||
): ts.CompilerHost {
|
||||
const originalHost = ts.createCompilerHost(options, true);
|
||||
const originalGetSourceFile = originalHost.getSourceFile.bind(originalHost);
|
||||
const originalReadFile = originalHost.readFile.bind(originalHost);
|
||||
const originalFileExists = originalHost.fileExists.bind(originalHost);
|
||||
const originalDirectoryExists =
|
||||
originalHost.directoryExists?.bind(originalHost);
|
||||
const originalGetDirectories =
|
||||
originalHost.getDirectories?.bind(originalHost);
|
||||
|
||||
return {
|
||||
...originalHost,
|
||||
getCurrentDirectory: () => "/",
|
||||
getSourceFile(
|
||||
fileName,
|
||||
languageVersion,
|
||||
onError,
|
||||
shouldCreateNewSourceFile,
|
||||
) {
|
||||
const normalized = normalizeFileName(fileName);
|
||||
const content = files.get(normalized);
|
||||
if (content !== undefined) {
|
||||
return ts.createSourceFile(fileName, content, languageVersion, true);
|
||||
}
|
||||
return originalGetSourceFile(
|
||||
fileName,
|
||||
languageVersion,
|
||||
onError,
|
||||
shouldCreateNewSourceFile,
|
||||
);
|
||||
},
|
||||
readFile(fileName) {
|
||||
const normalized = normalizeFileName(fileName);
|
||||
return files.get(normalized) ?? originalReadFile(fileName);
|
||||
},
|
||||
fileExists(fileName) {
|
||||
const normalized = normalizeFileName(fileName);
|
||||
return files.has(normalized) || originalFileExists(fileName);
|
||||
},
|
||||
directoryExists(dirName) {
|
||||
const normalized = normalizeFileName(dirName);
|
||||
return (
|
||||
hasVirtualDirectory(files, normalized) ||
|
||||
originalDirectoryExists?.(dirName) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
getDirectories(dirName) {
|
||||
const normalized = normalizeFileName(dirName);
|
||||
const virtualDirectories = listVirtualDirectories(files, normalized);
|
||||
const diskDirectories = originalGetDirectories?.(dirName) ?? [];
|
||||
return [...new Set([...diskDirectories, ...virtualDirectories])];
|
||||
},
|
||||
realpath(fileName) {
|
||||
return normalizeFileName(fileName);
|
||||
},
|
||||
writeFile() {},
|
||||
};
|
||||
}
|
||||
|
||||
function mapTypeScriptDiagnostic(input: {
|
||||
diagnostic: ts.Diagnostic;
|
||||
source: "frontend" | "backend";
|
||||
toTarget: (fileName: string) => string | null;
|
||||
}): AppStaticDiagnostic[] {
|
||||
const { diagnostic, source, toTarget } = input;
|
||||
if (!diagnostic.file) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const target = toTarget(diagnostic.file.fileName);
|
||||
if (!target) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const position =
|
||||
diagnostic.start === undefined
|
||||
? undefined
|
||||
: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
|
||||
return [
|
||||
{
|
||||
source,
|
||||
target,
|
||||
message: ts
|
||||
.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
|
||||
.trim(),
|
||||
line: position ? position.line + 1 : undefined,
|
||||
column: position ? position.character + 1 : undefined,
|
||||
code: diagnostic.code,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function groupMessages(
|
||||
diagnostics: AppStaticDiagnostic[],
|
||||
): Record<string, string[]> {
|
||||
const grouped: Record<string, string[]> = {};
|
||||
|
||||
for (const diagnostic of diagnostics) {
|
||||
grouped[diagnostic.target] ??= [];
|
||||
grouped[diagnostic.target].push(formatLintMessage(diagnostic));
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function formatLintMessage(diagnostic: AppStaticDiagnostic): string {
|
||||
if (diagnostic.line !== undefined) {
|
||||
return `Line ${diagnostic.line}: ${diagnostic.message}`;
|
||||
}
|
||||
return diagnostic.message;
|
||||
}
|
||||
|
||||
function dedupeDiagnostics(
|
||||
diagnostics: AppStaticDiagnostic[],
|
||||
): AppStaticDiagnostic[] {
|
||||
const uniqueDiagnostics = new Map<string, AppStaticDiagnostic>();
|
||||
|
||||
for (const diagnostic of diagnostics) {
|
||||
const key = [
|
||||
diagnostic.source,
|
||||
diagnostic.target,
|
||||
diagnostic.code ?? "",
|
||||
diagnostic.line ?? "",
|
||||
diagnostic.column ?? "",
|
||||
diagnostic.message,
|
||||
].join("::");
|
||||
if (!uniqueDiagnostics.has(key)) {
|
||||
uniqueDiagnostics.set(key, diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
return [...uniqueDiagnostics.values()];
|
||||
}
|
||||
|
||||
function compareDiagnostics(
|
||||
a: AppStaticDiagnostic,
|
||||
b: AppStaticDiagnostic,
|
||||
): number {
|
||||
if (a.source !== b.source) {
|
||||
return a.source.localeCompare(b.source);
|
||||
}
|
||||
if (a.target !== b.target) {
|
||||
return a.target.localeCompare(b.target);
|
||||
}
|
||||
if ((a.line ?? 0) !== (b.line ?? 0)) {
|
||||
return (a.line ?? 0) - (b.line ?? 0);
|
||||
}
|
||||
if ((a.column ?? 0) !== (b.column ?? 0)) {
|
||||
return (a.column ?? 0) - (b.column ?? 0);
|
||||
}
|
||||
return a.message.localeCompare(b.message);
|
||||
}
|
||||
|
||||
function toFrontendVirtualPath(filePath: string): string {
|
||||
const normalizedPath = normalizeAppFilePath(filePath);
|
||||
return `${FRONTEND_ROOT}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function normalizeAppFilePath(filePath: string): string {
|
||||
const normalizedPath = normalizeFileName(filePath);
|
||||
return normalizedPath.startsWith("/") ? normalizedPath : `/${normalizedPath}`;
|
||||
}
|
||||
|
||||
function normalizeFileName(fileName: string): string {
|
||||
return path.posix.normalize(fileName.replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function hasVirtualDirectory(
|
||||
files: Map<string, string>,
|
||||
dirName: string,
|
||||
): boolean {
|
||||
const normalizedDirectory = dirName.endsWith("/") ? dirName : `${dirName}/`;
|
||||
for (const fileName of files.keys()) {
|
||||
if (fileName === dirName || fileName.startsWith(normalizedDirectory)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function listVirtualDirectories(
|
||||
files: Map<string, string>,
|
||||
dirName: string,
|
||||
): string[] {
|
||||
const normalizedDirectory = dirName.endsWith("/") ? dirName : `${dirName}/`;
|
||||
const directories = new Set<string>();
|
||||
|
||||
for (const fileName of files.keys()) {
|
||||
if (!fileName.startsWith(normalizedDirectory)) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = fileName.slice(normalizedDirectory.length);
|
||||
const [segment] = relativePath.split("/");
|
||||
if (segment && relativePath.includes("/")) {
|
||||
directories.add(path.posix.join(dirName, segment));
|
||||
}
|
||||
}
|
||||
|
||||
return [...directories];
|
||||
}
|
||||
|
||||
function wrapModuleDeclaration(moduleName: string, content: string): string {
|
||||
const indentedContent = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
|
||||
return `declare module "${moduleName}" {\n${indentedContent}\n}\n`;
|
||||
}
|
||||
|
||||
function getRunnableSignature(
|
||||
runnable: AppDiagnosticRunnable | undefined,
|
||||
asyncMode: boolean,
|
||||
): string {
|
||||
const returnType = asyncMode ? "Promise<string>" : "Promise<any>";
|
||||
const parameter = getRunnableParameterSignature(runnable);
|
||||
return `${parameter} => ${returnType}`;
|
||||
}
|
||||
|
||||
function getRunnableParameterSignature(
|
||||
runnable: AppDiagnosticRunnable | undefined,
|
||||
): string {
|
||||
const parameterInfo = getRunnableParameterInfo(runnable);
|
||||
if (!parameterInfo) {
|
||||
return "()";
|
||||
}
|
||||
|
||||
const parameterType = parameterInfo.typeText ?? "any";
|
||||
if (parameterInfo.optional) {
|
||||
return `(args?: ${parameterType})`;
|
||||
}
|
||||
return `(args: ${parameterType})`;
|
||||
}
|
||||
|
||||
function getRunnableParameterInfo(
|
||||
runnable: AppDiagnosticRunnable | undefined,
|
||||
): { typeText?: string; optional: boolean } | null {
|
||||
if (
|
||||
!runnable?.inlineScript?.content ||
|
||||
!isTypeCheckableBackendRunnable(runnable)
|
||||
) {
|
||||
return { typeText: "any", optional: true };
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
"main.ts",
|
||||
runnable.inlineScript.content,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
getScriptKindForLanguage(runnable.inlineScript.language),
|
||||
);
|
||||
const mainDeclaration = findExportedMainDeclaration(sourceFile);
|
||||
|
||||
if (!mainDeclaration || mainDeclaration.parameters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [parameter] = mainDeclaration.parameters;
|
||||
const optional =
|
||||
Boolean(parameter.questionToken) || Boolean(parameter.initializer);
|
||||
|
||||
if (!parameter.type || !isPortableTypeNode(parameter.type)) {
|
||||
return { typeText: "any", optional: true };
|
||||
}
|
||||
|
||||
return {
|
||||
typeText: parameter.type.getText(sourceFile).trim(),
|
||||
optional,
|
||||
};
|
||||
}
|
||||
|
||||
function findExportedMainDeclaration(
|
||||
sourceFile: ts.SourceFile,
|
||||
): ts.SignatureDeclarationBase | null {
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
ts.isFunctionDeclaration(statement) &&
|
||||
statement.name?.text === "main" &&
|
||||
hasExportModifier(statement)
|
||||
) {
|
||||
return statement;
|
||||
}
|
||||
|
||||
if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (
|
||||
!ts.isIdentifier(declaration.name) ||
|
||||
declaration.name.text !== "main"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const initializer = declaration.initializer;
|
||||
if (
|
||||
initializer &&
|
||||
(ts.isArrowFunction(initializer) ||
|
||||
ts.isFunctionExpression(initializer))
|
||||
) {
|
||||
return initializer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasExportModifier(node: ts.Node): boolean {
|
||||
const modifiers = ts.canHaveModifiers(node)
|
||||
? ts.getModifiers(node)
|
||||
: undefined;
|
||||
return Boolean(
|
||||
modifiers?.some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isPortableTypeNode(node: ts.TypeNode): boolean {
|
||||
if (
|
||||
isKeywordTypeNode(node) ||
|
||||
ts.isArrayTypeNode(node) ||
|
||||
ts.isTupleTypeNode(node) ||
|
||||
ts.isLiteralTypeNode(node) ||
|
||||
ts.isTypeLiteralNode(node)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ts.isParenthesizedTypeNode(node) || ts.isTypeOperatorNode(node)) {
|
||||
return isPortableTypeNode(node.type);
|
||||
}
|
||||
|
||||
if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) {
|
||||
return node.types.every((typeNode) => isPortableTypeNode(typeNode));
|
||||
}
|
||||
|
||||
if (ts.isTypeReferenceNode(node)) {
|
||||
if (
|
||||
!ts.isIdentifier(node.typeName) ||
|
||||
!SAFE_TYPE_REFERENCE_NAMES.has(node.typeName.text)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (node.typeArguments ?? []).every((typeArgument) =>
|
||||
isPortableTypeNode(typeArgument),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isKeywordTypeNode(node: ts.TypeNode): boolean {
|
||||
switch (node.kind) {
|
||||
case ts.SyntaxKind.AnyKeyword:
|
||||
case ts.SyntaxKind.BigIntKeyword:
|
||||
case ts.SyntaxKind.BooleanKeyword:
|
||||
case ts.SyntaxKind.NeverKeyword:
|
||||
case ts.SyntaxKind.NumberKeyword:
|
||||
case ts.SyntaxKind.ObjectKeyword:
|
||||
case ts.SyntaxKind.StringKeyword:
|
||||
case ts.SyntaxKind.SymbolKeyword:
|
||||
case ts.SyntaxKind.UndefinedKeyword:
|
||||
case ts.SyntaxKind.UnknownKeyword:
|
||||
case ts.SyntaxKind.VoidKeyword:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isFrontendCodeFile(filePath: string): boolean {
|
||||
const extension = path.posix.extname(filePath).toLowerCase();
|
||||
return (
|
||||
extension === ".js" ||
|
||||
extension === ".jsx" ||
|
||||
extension === ".ts" ||
|
||||
extension === ".tsx"
|
||||
);
|
||||
}
|
||||
|
||||
function isTypeCheckableBackendRunnable(
|
||||
runnable: AppDiagnosticRunnable | undefined,
|
||||
): boolean {
|
||||
if (!runnable || runnable.type !== "inline") {
|
||||
return false;
|
||||
}
|
||||
const language = runnable.inlineScript?.language?.toLowerCase() ?? "";
|
||||
return TS_LIKE_LANGUAGES.has(language) || JS_LIKE_LANGUAGES.has(language);
|
||||
}
|
||||
|
||||
function getBackendFileExtension(language: string | undefined): string {
|
||||
const normalizedLanguage = language?.toLowerCase() ?? "";
|
||||
return JS_LIKE_LANGUAGES.has(normalizedLanguage) ? "js" : "ts";
|
||||
}
|
||||
|
||||
function getScriptKindForLanguage(language: string | undefined): ts.ScriptKind {
|
||||
const normalizedLanguage = language?.toLowerCase() ?? "";
|
||||
return JS_LIKE_LANGUAGES.has(normalizedLanguage)
|
||||
? ts.ScriptKind.JS
|
||||
: ts.ScriptKind.TS;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
parseBackendValidationMode,
|
||||
resolveBackendValidationSettings,
|
||||
} from "./backendValidation";
|
||||
|
||||
describe("parseBackendValidationMode", () => {
|
||||
it("defaults to off", () => {
|
||||
expect(parseBackendValidationMode(undefined)).toBe("off");
|
||||
expect(parseBackendValidationMode("0")).toBe("off");
|
||||
expect(parseBackendValidationMode("false")).toBe("off");
|
||||
});
|
||||
|
||||
it("accepts preview aliases", () => {
|
||||
expect(parseBackendValidationMode("preview")).toBe("preview");
|
||||
expect(parseBackendValidationMode("1")).toBe("preview");
|
||||
expect(parseBackendValidationMode("true")).toBe("preview");
|
||||
});
|
||||
|
||||
it("rejects unknown modes", () => {
|
||||
expect(() => parseBackendValidationMode("maybe")).toThrow(
|
||||
"Unsupported backend validation mode: maybe"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBackendValidationSettings", () => {
|
||||
it("rejects unsupported eval modes", () => {
|
||||
expect(() =>
|
||||
resolveBackendValidationSettings({
|
||||
evalMode: "app",
|
||||
requestedMode: "preview",
|
||||
})
|
||||
).toThrow('Backend validation mode "preview" is only supported for flow and script evals');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user