Merge remote-tracking branch 'origin/main' into hub-integrations-prominence

# Conflicts:
#	ai_evals/adapters/frontend/core/global/globalEvalRunner.ts
#	ai_evals/adapters/frontend/mockBackend.ts
#	ai_evals/cases/global.yaml
#	frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
#	frontend/src/lib/components/copilot/chat/global/core.ts
This commit is contained in:
hugocasa
2026-08-25 14:59:40 +02:00
1348 changed files with 88685 additions and 13907 deletions
+63
View File
@@ -0,0 +1,63 @@
# Vendored skills
These five skills are copied from an external repository, not written here:
- `grill-me`, `grilling`
- `improve-codebase-architecture`, `codebase-design`, `domain-modeling`
Source: https://github.com/mattpocock/skills
Pinned at commit `84fdeffd12f2ee307994d1eb6feb48173b6e0502`.
They form one dependency closure — `grill-me` is a stub that runs `grilling`, and
`improve-codebase-architecture` draws its vocabulary from `codebase-design` and its
CONTEXT.md upkeep from `domain-modeling`. Removing any one breaks the others.
Local changes on top of upstream, kept to the minimum so a refresh stays a diff:
- Flattened the upstream `skills/engineering/` and `skills/productivity/` split, since this
repo's skills are flat.
- Replaced each SKILL.md's markdown links to its own bundled files with plain repo-root paths
in prose (`.agents/skills/<skill>/FILE.md`). Upstream's sibling-relative links break when the
file is read through the `.claude/skills/<skill>/SKILL.md` symlink, which mirrors only
SKILL.md — and a repo-root *link* is equally wrong, since a markdown target resolves relative
to the file containing it. Companion files keep their sibling-relative links; they are only
ever read at their real path, never through the symlink.
- Dropped the upstream `agents/openai.yaml` files — Codex packaging metadata for that repo's
own plugin distribution, unused here.
- **Removed every ADR path.** Upstream, `domain-modeling` offers to write Architecture Decision
Records into `docs/adr/` and `improve-codebase-architecture` reads and cites them. This repo has
not adopted ADRs, and a skill that offers to create them is how the practice arrives by side
effect rather than by decision. Deleted `domain-modeling/ADR-FORMAT.md`, its "Offer ADRs
sparingly" section, and the `docs/adr/` entries in its file-structure diagrams; dropped the ADR
clauses from `improve-codebase-architecture` (intro, explore step, "ADR conflicts", the
offer-an-ADR bullet in the grilling loop) and the ADR callout row in `HTML-REPORT.md`. Also cut
"record an architectural decision" from `domain-modeling`'s description, since that phrase is an
invocation trigger. What remains is CONTEXT.md and ubiquitous-language work only.
To refresh, diff against the same paths at a newer commit and re-apply these four changes. The
ADR removal is the one that needs judgement: if the team later adopts ADRs, take upstream's
version of those sections back rather than rewriting them here.
## License
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,37 @@
# Deepening
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [SKILL.md](SKILL.md) — **module**, **interface**, **seam**, **adapter**.
## Dependency categories
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
### 1. In-process
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
### 2. Local-substitutable
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
### 3. Remote but owned (Ports & Adapters)
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."*
### 4. True external (Mock)
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
## Seam discipline
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
## Testing strategy: replace, don't layer
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
- Write new tests at the deepened module's interface. The **interface is the test surface**.
- Tests assert on observable outcomes through the interface, not internal state.
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
@@ -0,0 +1,44 @@
# Design It Twice
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
Uses the vocabulary in [SKILL.md](SKILL.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
## Process
### 1. Frame the problem space
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
- The constraints any new interface would need to satisfy
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
### 2. Spawn sub-agents
Spawn 3+ sub-agents in parallel. Each must produce a **radically different** interface for the deepened module.
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
- Agent 1: "Minimize the interface — aim for 13 entry points max. Maximise leverage per entry point."
- Agent 2: "Maximise flexibility — support many use cases and extension."
- Agent 3: "Optimise for the most common caller — make the default case trivial."
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
Include both [SKILL.md](SKILL.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
Each sub-agent outputs:
1. Interface (types, methods, params — plus invariants, ordering, error modes)
2. Usage example showing how callers use it
3. What the implementation hides behind the seam
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
5. Trade-offs — where leverage is high, where it's thin
### 3. Present and compare
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
+114
View File
@@ -0,0 +1,114 @@
---
name: codebase-design
description: Shared vocabulary for designing deep modules. Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable, or when another skill needs the deep-module vocabulary.
---
# Codebase Design
Design **deep modules**: a lot of behaviour behind a small interface, placed at a clean seam, testable through that interface. Use this language and these principles wherever code is being designed or restructured. The aim is leverage for callers, locality for maintainers, and testability for everyone.
## Glossary
Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
**Module** — anything with an interface and an implementation. Deliberately scale-agnostic: a function, class, package, or tier-spanning slice. _Avoid_: unit, component, service.
**Interface** — everything a caller must know to use the module correctly: the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. _Avoid_: API, signature (too narrow — they refer only to the type-level surface).
**Implementation** — what's inside a module, its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
**Depth** — leverage at the interface: the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface, **shallow** when the interface is nearly as complex as the implementation.
**Seam** _(Michael Feathers)_ — a place where you can alter behaviour without editing in that place; the *location* at which a module's interface lives. Where to put the seam is its own design decision, distinct from what goes behind it. _Avoid_: boundary (overloaded with DDD's bounded context).
**Adapter** — a concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside).
**Leverage** — what callers get from depth: more capability per unit of interface they learn. One implementation pays back across N call sites and M tests.
**Locality** — what maintainers get from depth: change, bugs, knowledge, and verification concentrate in one place rather than spreading across callers. Fix once, fixed everywhere.
## Deep vs shallow
**Deep module** = small interface + lots of implementation:
```
┌─────────────────────┐
│ Small Interface │ ← Few methods, simple params
├─────────────────────┤
│ │
│ Deep Implementation│ ← Complex logic hidden
│ │
└─────────────────────┘
```
**Shallow module** = large interface + little implementation (avoid):
```
┌─────────────────────────────────┐
│ Large Interface │ ← Many methods, complex params
├─────────────────────────────────┤
│ Thin Implementation │ ← Just passes through
└─────────────────────────────────┘
```
When designing an interface, ask:
- Can I reduce the number of methods?
- Can I simplify the parameters?
- Can I hide more complexity inside?
## Principles
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
- **The deletion test.** Imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape.
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
## Designing for testability
Good interfaces make testing natural:
1. **Accept dependencies, don't create them.**
```typescript
// Testable
function processOrder(order, paymentGateway) {}
// Hard to test
function processOrder(order) {
const gateway = new StripeGateway();
}
```
2. **Return results, don't produce side effects.**
```typescript
// Testable
function calculateDiscount(cart): Discount {}
// Hard to test
function applyDiscount(cart): void {
cart.total -= discount;
}
```
3. **Small surface area.** Fewer methods = fewer tests needed. Fewer params = simpler test setup.
## Relationships
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
- **Depth** is a property of a **Module**, measured against its **Interface**.
- A **Seam** is where a **Module**'s **Interface** lives.
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
## Rejected framings
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
## Going deeper
- **Deepening a cluster given its dependencies** — see `.agents/skills/codebase-design/DEEPENING.md` (path from the repo root): dependency categories, seam discipline, and replace-don't-layer testing.
- **Exploring alternative interfaces** — see `.agents/skills/codebase-design/DESIGN-IT-TWICE.md` (path from the repo root): spin up parallel sub-agents to design the interface several radically different ways, then compare on depth, locality, and seam placement.
@@ -0,0 +1,60 @@
# CONTEXT.md Format
## Structure
```md
# {Context Name}
{One or two sentence description of what this context is and why it exists.}
## Language
**Order**:
{A one or two sentence description of the term}
_Avoid_: Purchase, transaction
**Invoice**:
A request for payment sent to a customer after delivery.
_Avoid_: Bill, payment request
**Customer**:
A person or organization that places orders.
_Avoid_: Client, buyer, account
```
## Rules
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
## Single vs multi-context repos
**Single context (most repos):** One `CONTEXT.md` at the repo root.
**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:
```md
# Context Map
## Contexts
- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping
## Relationships
- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
```
The skill infers which structure applies:
- If `CONTEXT-MAP.md` exists, read it to find contexts
- If only a root `CONTEXT.md` exists, single context
- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved
When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.
+57
View File
@@ -0,0 +1,57 @@
---
name: domain-modeling
description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, or when another skill needs to maintain the domain model.
---
# Domain Modeling
Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.)
## File structure
Most repos have a single context:
```
/
├── CONTEXT.md
└── src/
```
If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:
```
/
├── CONTEXT-MAP.md
└── src/
├── ordering/
│ └── CONTEXT.md
└── billing/
└── CONTEXT.md
```
Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved.
## During the session
### Challenge against the glossary
When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
### Sharpen fuzzy language
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
### Discuss concrete scenarios
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
### Cross-reference with code
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
### Update CONTEXT.md inline
When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in `.agents/skills/domain-modeling/CONTEXT-FORMAT.md` (path from the repo root).
`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
+7
View File
@@ -0,0 +1,7 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Run a `/grilling` session.
+22
View File
@@ -0,0 +1,22 @@
---
name: grilling
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
---
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled — the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
Each question should be formatted like so:
```
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
➡️ <your recommended answer>
```
Each round the user answers reshapes the tree — settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it — don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report — ask the rest of the frontier now. The _decisions_ are the user's — put each to them and wait.
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
@@ -0,0 +1,122 @@
# HTML Report Format
The architectural review is rendered as a single self-contained HTML file in the OS temp directory. Tailwind and Mermaid both come from CDNs. Mermaid handles graph-shaped diagrams reliably; hand-built divs and inline SVG handle the more editorial visuals (mass diagrams, cross-sections). Mix the two — don't lean on Mermaid for everything, it'll start to look generic.
## Scaffold
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Architecture review — {{repo name}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: "neutral", securityLevel: "loose" });
</script>
<style>
/* small custom layer for things Tailwind doesn't cover cleanly:
dashed seam lines, hand-drawn-feeling arrow heads, etc. */
.seam { stroke-dasharray: 4 4; }
.leak { stroke: #dc2626; }
.deep { background: linear-gradient(135deg, #0f172a, #1e293b); }
</style>
</head>
<body class="bg-stone-50 text-slate-900 font-sans">
<main class="max-w-5xl mx-auto px-6 py-12 space-y-12">
<header>...</header>
<section id="candidates" class="space-y-10">...</section>
<section id="top-recommendation">...</section>
</main>
</body>
</html>
```
## Header
Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates.
## Candidate card
The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms (from the `/codebase-design` skill) without ceremony.
Each candidate is one `<article>`:
- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline").
- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`).
- **Files** — monospaced list, `font-mono text-sm`.
- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below.
- **Problem** — one sentence. What hurts.
- **Solution** — one sentence. What changes.
- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers".
No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram.
## Diagram patterns
Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point.
### Mermaid graph (the workhorse for dependencies / call flow)
Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1."
```html
<div class="rounded-lg border border-slate-200 bg-white p-4">
<pre class="mermaid">
flowchart LR
A[OrderHandler] --> B[OrderValidator]
B --> C[OrderRepo]
C -.leak.-> D[PricingClient]
classDef leak stroke:#dc2626,stroke-width:2px;
class C,D leak
</pre>
</div>
```
### Hand-built boxes-and-arrows (when Mermaid's layout fights you)
Modules as `<div>`s with borders and labels. Arrows as inline SVG `<line>` or `<path>` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight.
### Cross-section (good for layered shallowness)
Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility.
### Mass diagram (good for "interface as wide as implementation")
Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep).
### Call-graph collapse
Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it.
## Style guidance
- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate).
- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings.
- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling.
- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI.
- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering.
## Top recommendation section
One larger card. Candidate name, one sentence on why, anchor link to its card. That's it.
## Tone
Plain English, concise — but the architectural nouns and verbs come straight from the `/codebase-design` skill. Concision is not an excuse to drift.
**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality.
**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module).
**Phrasings that fit the style:**
- "Order intake module is shallow — interface nearly matches the implementation."
- "Pricing leaks across the seam."
- "Deepen: one interface, one place to test."
- "Two adapters justify the seam: HTTP in prod, in-memory in tests."
**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place.
No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one.
@@ -0,0 +1,68 @@
---
name: improve-codebase-architecture
description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick.
disable-model-invocation: true
---
# Improve Codebase Architecture
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
This command is _informed_ by the project's domain model and built on a shared design vocabulary:
- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion — don't drift into "component," "service," "API," or "boundary."
- The domain language in `CONTEXT.md` gives names to good seams.
## Process
### 1. Explore
**Scope before you scan — YAGNI.** Deepening a module pays off by making future changes to it easier, so put extra weight on the parts of the codebase that have recently changed. Decide *where* to look before you look:
- If the user named a direction — a module, a subsystem, a pain point — take it, and skip the inference below.
- Otherwise, walk back a good stretch of the commit history (`git log --oneline`) to find the codebase's hot spots — the files and areas that keep coming up — and let those paths pull your attention first. If the changes are scattered with no clear hot spot, widen the net.
Read the project's domain glossary (`CONTEXT.md`) first.
Then spawn a sub-agent to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
- Where does understanding one concept require bouncing between many small modules?
- Where are modules **shallow** — interface nearly as complex as the implementation?
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
- Where do tightly-coupled modules leak across their seams?
- Which parts of the codebase are untested, or hard to test through their current interface?
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
### 2. Present candidates as an HTML report
Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `<tmpdir>/architecture-review-<timestamp>.html` so each run gets a fresh file. Open it for the user — `xdg-open <path>` on Linux, `open <path>` on macOS, `start <path>` on Windows — and tell them the absolute path.
The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual.
For each candidate, render a card with:
- **Files** — which files/modules are involved
- **Problem** — why the current architecture is causing friction
- **Solution** — plain English description of what would change
- **Benefits** — explained in terms of locality and leverage, and how tests would improve
- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening
- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge
End the report with a **Top recommendation** section: which candidate you'd tackle first and why.
**Use CONTEXT.md vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
See `.agents/skills/improve-codebase-architecture/HTML-REPORT.md` (path from the repo root) for the full HTML scaffold, diagram patterns, and styling guidance.
Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?"
### 3. Grilling loop
Once the user picks a candidate, run the `/grilling` skill to walk the decision tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
Side effects happen inline as decisions crystallize — run the `/domain-modeling` skill to keep the domain model current as you go:
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md`. Create the file lazily if it doesn't exist.
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern.
+76 -1
View File
@@ -62,7 +62,7 @@ If `git diff main...HEAD --name-only` matches `^frontend/`, the PR body **must**
screenshots of the affected UI. Skip only when there is no visible UI effect (types,
tests, build config) — and say so in the body.
1. Verify the change in the browser (AGENTS.md → "Verifying Frontend Changes").
1. Verify the change in the browser (frontend/AGENTS.md → "Verifying Frontend Changes").
2. Screenshot each affected page with `mcp__playwright__browser_take_screenshot` (save to a file).
3. Host each image and get its Markdown embed by pushing to the public
`windmill-labs/agent-screenshots-internal` repo. **Pipe base64 through stdin**
@@ -130,6 +130,10 @@ and continue once they confirm it's done.
## Review rounds (draft → ready)
A PR leaves draft **only after a clean CI review round**. Never run `gh pr ready` before that.
This is the rule in every mode, autonomous included. A clean round is necessary but not always
sufficient — see "Flip, or ask first" below. The one standing exception is an explicit request to
leave that PR in draft (usually so it can be tested first) — honour it for that PR, and don't
carry it over to the next one.
1. **Trigger a round and wait for it**: launch the waiter as a background Bash task (a round takes 1030 min; you are woken when it exits — do not stop the session or poll in the foreground while it runs):
@@ -162,6 +166,77 @@ A PR leaves draft **only after a clean CI review round**. Never run `gh pr ready
If any P0/P1 finding is unaddressed or the head moved for reasons other than nit fixes, do **not** post the marker or flip — run another round instead.
### A round that never starts is usually a conflict
The review workflows don't run on a PR that cannot merge, so a round that produces no verdict is
more often a conflict with `main` than a CI outage. Check before assuming anything is broken:
```bash
gh pr view <PR_NUMBER> --json mergeable,mergeStateStatus
```
Resolve by **merging, not rebasing** — a rebase rewrites the head SHA that round verdicts and the
clean-round marker are keyed to, invalidating work you have already paid for:
```bash
git fetch origin main
git merge origin/main
```
**If that merge changed `backend/ee-repo-ref.txt`, move the EE worktree to match.** The file pins
the EE commit CE builds against, so a merge that advances it leaves the EE checkout behind what CE
now expects, and `cargo check --features private` compiles a tree neither you nor CI intends:
```bash
git -C <ee-worktree> merge "$(tr -d '[:space:]' < backend/ee-repo-ref.txt)"
```
Push both, then start a fresh round — the head moved, so the earlier verdicts no longer apply.
### Flip, or ask first
A clean round earns the flip; it does not always earn it *unattended*. Judge the blast radius from
the diff first — `git diff --name-only main...HEAD` answers most of these.
**Ask before flipping** when the change:
- touches `*_ee.rs` (it spans the EE repo through symlinks and has a companion PR)
- adds a migration under `backend/migrations/`
- changes `openapi.yaml`, `openflow.openapi.yaml`, or the generated client
- touches auth, permission, or token paths
- changes shared worker infrastructure — the job poller, `handle_child`, an executor
- trips `REVIEW.md`'s "Checklist for new public surfaces"
**Flip without asking** when it is self-contained: a single-file fix, test-only, docs-only, one
call site, no new public surface.
Unattended (webmux oneshot) there is nobody to ask, so the judgement holds and the action
degrades: flip the self-contained ones, and leave the rest at a clean draft with a line in the PR
description saying why — `left in draft: adds a migration, wants a human look before ready`.
Don't flip a wide-blast-radius change just because the round came back clean, and don't ask a
question nobody will read.
`AGENTS.local.md` (gitignored, so it may not exist) carries a "PR ready calibration" section
recording how past ambiguous calls went. Read it before deciding; when a call is still genuinely
ambiguous, ask, then append the answer there so the next one is less ambiguous.
### When rounds stop converging
Three or more rounds without a clean verdict usually means the change's shape is wrong, not that
there is an endless supply of independent bugs. The tells:
- findings keep landing in the same files round after round
- fixing one finding creates the next
- the findings are about coupling, duplication, or state threaded through many places, rather
than logic errors
When that pattern holds, stop running rounds — each one costs a CI cycle and is not going to
converge. Say plainly that the remaining findings look structural rather than incidental, and
name the module or seam they cluster around. With a user present, suggest they run
`/improve-codebase-architecture` over that area: it is slash-only so you cannot invoke it
yourself, and reshaping the code is a scope change they should choose. Unattended, put the
diagnosis in the PR description and stop there rather than grinding out more rounds.
## 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.
-1
View File
@@ -17,7 +17,6 @@ Reflect on the current session and update documentation with lessons learned.
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:
+7
View File
@@ -94,6 +94,13 @@ Use `tokio::sync::mpsc` (bounded) for channels. Avoid `std::thread::sleep` in as
Always use rust-analyzer LSP for go-to-definition, find-references, and type info. Do not guess at module paths.
## Feature Telemetry
`FEATURE_USAGE_KINDS` in `windmill-api-workspaces/src/workspaces.rs` is an allowlist: a
`(feature, kind)` pair missing from it is dropped by `valid_feature_usage_event` with a bare
`continue` — no error, and the route still returns 204. Adding a counter on the frontend without
registering it here records nothing. See `docs/feature-telemetry.md`.
## Axum Handlers
Destructure extractors directly in function signatures:
+55 -3
View File
@@ -7,9 +7,47 @@ description: Svelte coding guidelines for the Windmill frontend. MUST use when w
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.
## Before writing any UI (MUST)
Do both of these before the first line of markup — not after, and not only when something
looks unfamiliar.
**1. Find the component that already exists.** `frontend/src/lib/components/common/index.ts`
is the design-system barrel — 28 lines, read it in full. It exports far more than the three
documented below: `Alert`, `Badge`, `Breadcrumb`, `Drawer`/`DrawerContent`, `Menu`/`MenuItem`,
`Tabs`/`Tab`/`TabContent`, `Skeleton`, `FileInput`, `RadioCard`, `Section`, `Kbd`, `ActionRow`,
`ClearableInput`, `CopyButton`, `SecondsInput`, `UndoRedo`, `Url`.
The barrel is not the full picture either: `common/` has 34 subdirectories and only 23 exports,
so `modal/`, `popup/`, `stepper/`, `tooltip/`, `checkbox/`, `table/`, `contextmenu/`,
`confirmationModal/`, `calendarPicker/`, `fileUpload/`, `toggleButton-v2/` and more exist but
must be imported by path. Selects, text inputs and melt-based primitives sit next to `common/`
in `components/select/`, `components/text_input/`, `components/meltComponents/`.
The tree holds 1,600+ components — grep `frontend/src/lib/components` for the thing you're about
to build; it almost certainly exists. Building a new one is the last resort, not the first move.
**2. Read the guideline for what you're building.** `frontend/brand-guidelines.md` is the
authority on how it should look and read. Don't load all 34k chars — jump to the section:
| Building | Section to read |
|---|---|
| Any new screen or component | `# Components` (Core Rules, Quick Reference) |
| Buttons, CTAs | `## Buttons` — hierarchy matters, only one Accent per view |
| Colors, surfaces, borders | `# Color system` (Quick Reference, Do's and Don'ts) |
| Text, labels, headings | `# Typography` — note `## Text Casing`, sentence case throughout |
| Spacing, grids, page structure | `# Spacing & Layout`; `# Layout``## Form` for forms |
| Shadows, overlays, depth | `# Elevation` |
| Icons | `# Iconography` |
| Wording of any UI copy | `# Voice & Communication`, `# Tone of Voice` |
Get the line range with `grep -n '^#' frontend/brand-guidelines.md`, then read just that span.
## Windmill UI Components (MUST use)
Always use Windmill's design-system components. Never use raw HTML elements.
Always use Windmill's design-system components. Never use raw HTML elements. The three below
are the ones you'll reach for most often — they are examples, not the catalog. For anything
else, go back to the barrel and grep.
### Buttons — `<Button>`
@@ -23,7 +61,13 @@ Always use Windmill's design-system components. Never use raw HTML elements.
<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`
Props: `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`, `unifiedSize?: '2xs' | 'xs' | 'sm' | 'md' | 'lg'`, `startIcon?: { icon: SvelteComponent }`, `iconOnly?: boolean`, `disabled?: boolean`
**`size` on `<Button>` is banned** — it, `spacingSize` and `extendedSize` are the legacy sizing
system (`xs3`/`xs2`/`xs`/…, marked `@deprecated` in `Button.svelte`). Size every button with
`unifiedSize`, the small ones included: `2xs` and `xs` are `h-5`, `sm` is `h-7`, `md` is `h-8`,
`lg` is `h-10`. Existing `size="xs2"` call sites are legacy, not a precedent to copy. Same for
`variant`: `contained`/`border`/`divider` are deprecated — use the four listed above.
### Text inputs — `<TextInput>`
@@ -70,6 +114,14 @@ Form components (TextInput, Toggle, Select, etc.) should use the unified size sy
- Use Windmill's theming classes for colors/surfaces (see `frontend/brand-guidelines.md`)
- Read component props JSDoc before using them
## Feature Telemetry
New user-facing UX is the main source of `feature_usage` counters — propose them in the plan, not
as a separate question, and read `docs/feature-telemetry.md` first. `logFeatureUsage()` from
`$lib/utils/featureUsage` is only half the change: the `(feature, kind)` pair must also be
registered in the backend allowlist or every event is silently discarded, and the disclosure copy
in `InstanceSettings.svelte` must name what you added.
## Svelte MCP Server
Use the Svelte MCP tools when working on Svelte code:
@@ -81,4 +133,4 @@ Use the Svelte MCP tools when working on Svelte code:
## 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.
After changing Svelte code, use the **Playwright MCP** (`mcp__playwright__*`) to drive the running frontend and confirm the change works. See frontend/AGENTS.md → "Verifying Frontend Changes" for the full flow. Use `playwright` (headless) on devboxes; `playwright-headed` when a display is available.
+11 -12
View File
@@ -28,8 +28,10 @@ delete only those.
1. **Back the cache up.** `prepare` deletes `.sqlx/` *before* regenerating, so any compile
failure leaves it gutted (observed: 2350 → 142 entries).
```bash
cp -r backend/.sqlx /tmp/sqlx_backup # restore with: rm -rf backend/.sqlx && cp -r /tmp/sqlx_backup backend/.sqlx
bash .agents/skills/update-sqlx/sqlx-cache.sh backup
```
Its state is per-worktree, so a sibling worktree running `prepare` at the same time
cannot overwrite your backup.
2. **Point `DATABASE_URL` at THIS worktree's database.** `prepare` compiles every
`sqlx::query!` against the **live** database. Another worktree's DB lacks your
migrations, so every new-table query fails and takes the cache down with it. The
@@ -52,24 +54,21 @@ Do not fight it — the abort is a pre-existing EE gap, not something your chang
Take the entries you need and put the backup back:
```bash
cd backend
cp -r .sqlx /tmp/sqlx_backup
ls /tmp/sqlx_backup | sort > /tmp/before.txt
bash .agents/skills/update-sqlx/sqlx-cache.sh backup
cd backend
DATABASE_URL=<this worktree's db> \
cargo sqlx prepare --workspace -- --workspace --features all_sqlx_features --all-targets
# expected to fail; it still wrote the entries it got to before dying
cd ..
ls .sqlx | sort > /tmp/after.txt
mkdir -p /tmp/newq
comm -13 /tmp/before.txt /tmp/after.txt | while read f; do cp ".sqlx/$f" /tmp/newq/; done
rm -rf .sqlx && cp -r /tmp/sqlx_backup .sqlx && cp /tmp/newq/*.json .sqlx/
bash .agents/skills/update-sqlx/sqlx-cache.sh newq # prints each added query
bash .agents/skills/update-sqlx/sqlx-cache.sh restore # backup back, added entries grafted on
```
**Read every file in `/tmp/newq` before copying it in** — print each one's `query` field and
confirm it is one of yours. The set is small (one per new test query), and anything else in
there means the run got further than you think.
**Read what `newq` prints before running `restore`** — it shows each added entry's `query`
field, and every one should be yours. The set is small (one per new test query); anything
else in there means the run got further than you think.
Then verify both targets, since the lib passing says nothing about the tests:
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Backup / inspect / restore the SQLx offline cache around `cargo sqlx prepare`.
#
# `prepare` empties backend/.sqlx before regenerating, so any compile failure leaves the
# cache gutted (observed: 2350 -> 142 entries). A `--all-targets` run in a CE checkout
# aborts that way every time. State lives in a per-worktree directory, so sibling
# worktrees running this concurrently cannot overwrite each other's backup.
#
# sqlx-cache.sh backup snapshot backend/.sqlx
# sqlx-cache.sh newq show the entries prepare added since the snapshot, and stage them
# sqlx-cache.sh restore put the snapshot back, grafting the staged entries on top
#
# Inspect what `newq` prints before running `restore` — an entry you don't recognise means
# the run got further than you think.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cache="$repo_root/backend/.sqlx"
state="${TMPDIR:-/tmp}/wm-sqlx-cache/$(basename "$repo_root")"
backup="$state/backup"
added="$state/added"
# `find -printf` is GNU-only; a glob loop stays portable to a macOS checkout and, unlike
# `ls *.json`, does not fail the script under `set -e` when the cache is empty — which is
# exactly the state a failed `prepare` leaves behind.
list_entries() {
local f
for f in "$1"/*.json; do
[ -e "$f" ] || continue
basename "$f"
done | sort
}
show_query() {
if command -v jq >/dev/null 2>&1; then
jq -r '.query' "$1" 2>/dev/null | head -6
else
sed -n 's/^ *"query": "\(.*\)",*$/\1/p' "$1" | head -6
fi
}
case "${1:-}" in
backup)
[[ -d $cache ]] || { echo "no cache at $cache" >&2; exit 1; }
rm -rf "$state"
mkdir -p "$state"
cp -r "$cache" "$backup"
list_entries "$backup" > "$state/before.txt"
echo "backed up $(wc -l < "$state/before.txt" | tr -d ' ') entries to $backup"
;;
newq)
[[ -d $backup ]] || { echo "no backup — run '$0 backup' first" >&2; exit 1; }
list_entries "$cache" > "$state/after.txt"
comm -13 "$state/before.txt" "$state/after.txt" > "$state/new.txt"
rm -rf "$added"
mkdir -p "$added"
n=0
while read -r f; do
[[ -n $f ]] || continue
cp "$cache/$f" "$added/$f"
n=$((n + 1))
echo "--- $f"
show_query "$cache/$f"
done < "$state/new.txt"
echo "$n entries added since the backup, staged in $added"
;;
restore)
[[ -d $backup ]] || { echo "no backup — nothing to restore" >&2; exit 1; }
[[ -d $added ]] || { echo "run '$0 newq' first so the added entries are staged" >&2; exit 1; }
rm -rf "$cache"
cp -r "$backup" "$cache"
n=0
for f in "$added"/*.json; do
[[ -e $f ]] || continue
cp "$f" "$cache/"
n=$((n + 1))
done
echo "restored $(list_entries "$cache" | wc -l | tr -d ' ') entries ($n grafted from this run)"
;;
*)
sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'
exit 1
;;
esac
+264 -112
View File
@@ -1,36 +1,63 @@
#!/usr/bin/env bash
# PreToolUse allowance for scratch file ops: auto-allow a single, plain, single-line
# `mkdir` / `cp` / `mv` / `touch` / `chmod` / `tar` / `unzip` whose every path operand
# resolves under /tmp. Anything else makes no decision (exit 0) and falls back to the normal
# permission flow — where `Bash(mv:*)` and `Bash(chmod:*)` in the `ask` list prompt. A
# PreToolUse `allow` overrides those ask rules, which is why this is a hook and not an allow
# rule: permission rules match a command prefix, so they can only constrain the FIRST operand.
# `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix, and requiring every operand is the point.
# PreToolUse allowance for scratch file ops: auto-allow `mkdir` / `cp` / `mv` / `touch` /
# `chmod` whose every path operand resolves inside one of the roots `path_class` recognizes —
# under /tmp, inside a git working tree under $HOME, or in an MCP browser cache — and
# `tar` / `unzip` confined to /tmp.
# Anything else makes no decision (exit 0) and falls back to the normal permission flow, except
# for `mv` and `chmod`: those get an explicit `ask`, the only prompt they get (see
# lib-guarded-verb.sh).
#
# Requiring the sources under /tmp too (not just the destination) keeps this from becoming a
# read-exfiltration path around the `Read(**/.env)` / `Read(**/secrets/**)` deny rules: a copy
# out of the project into /tmp would land the content somewhere `Read(/tmp/**)` allows.
# The command is read one segment at a time, so chaining and line breaks carry no weight of
# their own: `cd /tmp/scratch && mv /tmp/a /tmp/b` is proved on the operands of the `mv`. A
# decision covers the whole command line, so `allow` is emitted only when every segment is one
# of these verbs proved here or a `cd` that resolved, AND exactly one of them writes (see the
# gate at the foot of this file — an earlier write can change what a later operand means). A
# line that mixes a proven op with some other command makes no decision instead and leaves that
# line to the normal permission flow, rather than waving an unexamined command through with it.
#
# This is a hook rather than an allow rule because permission rules match a command prefix, so
# they can only constrain the FIRST operand. `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix,
# and requiring every operand is the point.
#
# One operation may not straddle two roots, sources included, and a sibling checkout is a
# different root — `path_class` names the git tree, not just its kind. A copy out of a checkout
# into /tmp would be a read-exfiltration path around the `Read(**/secrets/**)` / `Read(**/*.pem)`
# deny rules, since the content lands where `Read(/tmp/**)` allows it to be read back, and one
# out of a repo the Read tool is not confined to would do the same for that repo. Keeping every
# operand of one operation inside a single root closes both without restating those rules here.
# The checkout root itself is what makes an in-repo `mv` or `chmod` auto-allowable: deleting a
# file there has never prompted, and moving or chmod-ing one is not the graver act.
#
# Deny-by-default tokenizing, in the same spirit as guard-rm-outside-tmp.sh: every path token
# must consist only of alphanumerics and `. _ / -`. That set contains none of the characters
# must consist only of alphanumerics and `. _ / -`, the one exception being the leading `~/` or
# `$HOME/` that `expand_home_prefix` rewrites first. That set contains none of the characters
# bash uses for quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), nor
# any glob character, so all of those forms fail by construction. `realpath -m` then resolves
# any glob character, so all of those forms fail by construction. `canon_path` then resolves
# `..` and existing symlinks, so `/tmp/link` pointing at /etc/passwd is caught.
#
# `tar` and `unzip` keep the stricter rule — /tmp only, and absolute operands only — because
# their positional grammar makes a bare word ambiguous: `tar P -xf ...` is --absolute-names,
# not a file named P, and resolving it as a path would put an option in a root and allow it.
# The other five take relative operands, resolved against the working directory that `cd`
# tracking maintains, since for those a bare word really is a path (a GNU option starts with
# `-`, and the option allowlist below rejects the ones that would change symlink handling).
#
# `tar` and `unzip` get their own parser: their write destination arrives as a flag VALUE
# (`-C`, `-d`) rather than a positional, and a bundle like `-xzf` consumes the token after it.
# Flags are an allowlist, not a denylist, so `-P` / `--absolute-names` — which turn off tar's
# refusal to extract `..` and absolute member paths — defer rather than needing enumeration.
# Extraction additionally requires an explicit destination under /tmp, or a cwd already under
# /tmp, since otherwise members land in the project checkout.
# Extraction additionally requires an explicit destination under /tmp, or a working directory
# already under /tmp, since otherwise members land in the project checkout.
#
# Residual risk accepted: an archive whose members include a symlink pointing out of /tmp
# followed by a write through it can still escape, because tar applies member symlinks as it
# extracts. The archive itself must be under /tmp to get here, so this is a hazard only for
# archives fetched from an untrusted source into the scratch dir.
#
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
# Assumes `jq`. Path canonicalization goes through `canon_path`, which covers both the Linux dev
# env and macOS; with neither backend available it proves nothing and every op falls back.
set -uo pipefail
. "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh"
input=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
@@ -38,61 +65,82 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$cmd" ] && exit 0
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
# A newline separates commands, and the tokenizer below only reads the first line — defer.
case "$cmd" in *$'\n'*) exit 0 ;; esac
read -r -a toks <<< "$cmd"
# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp.
under_tmp() {
local t="$1" canon
# Globs never auto-allow. Bash expands them only after this hook has decided, so realpath
# sees the unexpanded pattern: `/tmp/link*` canonicalizes to itself and passes, then
# expands onto a symlink whose target is outside /tmp. chmod and cp follow command-line
# symlinks, so that is a write to the target. guard-rm-outside-tmp.sh can allow globs
# because `rm` unlinks the symlink itself rather than following it.
case "$t" in *[*?[]*) return 1 ;; esac
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1
# Absolute only. Resolving a relative operand against the cwd makes any bare word look like
# a safe path whenever the cwd is under /tmp, while the tool itself reads it as an option:
# `tar P -xf ...` is --absolute-names, not ./P, and `cp /tmp/t -RL /tmp/o` is a
# dereferencing recursive copy, not a file named -RL.
case "$t" in /*) ;; *) return 1 ;; esac
canon=$(realpath -m -- "$t" 2>/dev/null)
[ -n "$canon" ] || return 1
# /tmp itself is never a target — only paths strictly inside it.
case "$canon" in /tmp/?*) return 0 ;; esac
return 1
}
allow() {
jq -nc --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'
# Every bail-out below goes through `defer`: `mv` and `chmod` prompt from here, since no rule
# covers them, while the other verbs stay silent and leave the decision to the normal flow.
guarded=0
for verb in mv chmod; do
runs_verb "$verb" "$cmd" && { guarded=1; break; }
done
defer() {
[ "$guarded" = 1 ] && decide ask "$1"
exit 0
}
# Bare command word only; wrappers (`timeout cp`), env prefixes, and `/bin/cp` defer.
# Options are an allowlist per command, so anything that changes how symlinks are followed
# defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while
# recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch
# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate
# such a symlink as a symlink instead, so no outside content is materialized.
case "${toks[0]:-}" in
mkdir) takes_mode=0; ok_opts='pv' ;;
cp) takes_mode=0; ok_opts='rRvfnpa' ;;
mv) takes_mode=0; ok_opts='vfn' ;;
touch) takes_mode=0; ok_opts='acmv' ;;
chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path
tar) ok_flags='xctzjJavfC'; val_flags='fC' ;;
unzip) ok_flags='oqnljvd'; val_flags='d' ;;
*) exit 0 ;;
esac
has_substitution "$cmd" && defer "command substitution in the command line"
# ---------------------------------------------------------------- tar / unzip
if [ -n "${ok_flags:-}" ]; then
saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0
i=1
while [ "$i" -lt "${#toks[@]}" ]; do
t="${toks[$i]}"
# 0 iff the token is a literal path this hook may reason about. A glob never auto-allows: bash
# expands it only after the hook has decided, so realpath sees the unexpanded pattern —
# `/tmp/link*` canonicalizes to itself and passes, then expands onto a symlink whose target is
# outside, and `cp` and `chmod` follow a command-line symlink, so that is a write to the target.
# (guard-rm-outside-tmp.sh can allow globs because `rm` unlinks the symlink rather than following
# it.) The charset holds none of the characters bash uses for quoting, expansion or separation.
literal_path() {
case "$1" in *[*?[]*) return 1 ;; esac
[ -z "$(printf '%s' "$1" | tr -d 'A-Za-z0-9._/-')" ]
}
# Prints the root class of a path token, then the path it resolved to on a second line,
# resolving a relative one against the tracked working directory. Fails, printing nothing,
# when the token is unsafe to reason about or lands outside every root.
operand_class() {
local t canon alt cls alt_cls=""
t=$(expand_home_prefix "$1")
literal_path "$t" || return 1
case "$t" in
/*) canon=$(canon_path "$t") ;;
*) # A `cd` may fail at runtime and leave the command where it started, so a relative
# operand has to land in the same root either way.
[ -n "$seg_cwd" ] || return 1
canon=$(canon_path "$seg_cwd/$t")
if [ -n "$alt_cwd" ]; then
alt=$(canon_path "$alt_cwd/$t")
[ -n "$alt" ] || return 1
alt_cls=$(path_class "$alt") || return 1
fi
;;
esac
[ -n "$canon" ] || return 1
cls=$(path_class "$canon") || return 1
[ -n "$alt_cls" ] && [ "$alt_cls" != "$cls" ] && return 1
# Class and resolved path together: a caller runs this in a command substitution, so a global
# set here would be set in that subshell and lost.
printf '%s\n%s' "$cls" "$canon"
}
# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive
# parser's stricter check; everything else goes through operand_class.
under_tmp() {
local t canon
t=$(expand_home_prefix "$1")
literal_path "$t" || return 1
case "$t" in /*) ;; *) return 1 ;; esac
canon=$(canon_path "$t")
[ -n "$canon" ] || return 1
# /tmp itself is never a target — only paths strictly inside it.
case "$canon" in "$TMP_ROOT"/?*) return 0 ;; esac
return 1
}
# Proves one `tar` / `unzip` segment ($1 = the verb), whose tokens are in SEG_TOKS.
check_archive_segment() {
local verb="$1" ok_flags val_flags t flags val
local saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 i=1
case "$verb" in
tar) ok_flags='xctzjJavfC'; val_flags='fC' ;;
unzip) ok_flags='oqnljvd'; val_flags='d' ;;
esac
while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
t="${SEG_TOKS[$i]}"
i=$((i + 1))
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
@@ -101,18 +149,18 @@ if [ -n "${ok_flags:-}" ]; then
flags="${t#-}"
# Allowlist: a long option, -P/--absolute-names, --transform, -I and friends all
# leave a residue here and defer rather than being enumerated as denials.
[ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && exit 0
[ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && defer "unrecognized option \`$t\`"
case "$flags" in *x*) extracting=1 ;; esac
case "${toks[0]}$flags" in unzip*[lv]*) listing=1 ;; esac
case "$verb$flags" in unzip*[lv]*) listing=1 ;; esac
# A flag consuming the next token must be alone in its bundle's final position
# (`-xzf a.tar`), else the token it eats is ambiguous.
case "${flags%?}" in *[$val_flags]*) exit 0 ;; esac
case "${flags%?}" in *[$val_flags]*) defer "ambiguous option bundle \`$t\`" ;; esac
case "${flags: -1}" in
[$val_flags])
val="${toks[$i]:-}"
val="${SEG_TOKS[$i]:-}"
i=$((i + 1))
[ -n "$val" ] || exit 0
under_tmp "$val" || exit 0
[ -n "$val" ] || defer "option \`$t\` has no value"
under_tmp "$val" || defer "\`$val\` is outside /tmp"
case "${flags: -1}" in
f) saw_archive=1 ;;
C | d) saw_dest=1 ;;
@@ -126,54 +174,158 @@ if [ -n "${ok_flags:-}" ]; then
# Positional. For tar these are sources (create) or member names (extract); for unzip the
# first is the archive. Requiring every one under /tmp is conservative for member names,
# which are not filesystem paths — those defer rather than being wrongly allowed.
under_tmp "$t" || exit 0
[ "${toks[0]}" = "unzip" ] && saw_archive=1
under_tmp "$t" || defer "\`$t\` is outside /tmp"
[ "$verb" = "unzip" ] && saw_archive=1
done
[ "$saw_archive" = 1 ] || exit 0 # tar without -f reads a tape/stdin; unzip needs an archive
# tar without -f reads a tape/stdin; unzip needs an archive
[ "$saw_archive" = 1 ] || defer "no archive operand"
# Writes land relative to the working directory unless a destination was given. `unzip -l`
# and `-v` only list, so they need no destination.
if [ "$extracting" = 1 ] || { [ "${toks[0]}" = "unzip" ] && [ "$listing" = 0 ]; }; then
[ "$saw_dest" = 1 ] || under_tmp "${cwd:-$PWD}" || exit 0
if [ "$extracting" = 1 ] || { [ "$verb" = "unzip" ] && [ "$listing" = 0 ]; }; then
# An extraction with no destination lands in the working directory. Word splitting cannot
# tell a `cd` inside a quoted string from one the shell runs, and believing a false one
# would put an archive's members in the checkout, so once any `cd` is in the line only an
# explicit destination will do.
[ "$saw_dest" = 1 ] \
|| { [ "$saw_cd" = 0 ] && [ -n "$seg_cwd" ] && under_tmp "$seg_cwd"; } \
|| defer "extraction target is outside /tmp"
fi
allow "archive paths and extraction target are under /tmp"
fi
}
# ------------------------------------------- mkdir / cp / mv / touch / chmod
path_operand=0
seen_mode=0
end_opts=0
i=1
while [ "$i" -lt "${#toks[@]}" ]; do
t="${toks[$i]}"
i=$((i + 1))
# Proves one `mkdir` / `cp` / `mv` / `touch` / `chmod` segment ($1 = the verb), whose tokens
# are in SEG_TOKS.
check_fileops_segment() {
local verb="$1" takes_mode ok_opts t cls resolved dest seen_class=""
local path_operand=0 seen_mode=0 end_opts=0 i=1 rel_operand=0
local -a ops=()
# Options are an allowlist per command, so anything that changes how symlinks are followed
# defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while
# recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch
# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate
# such a symlink as a symlink instead, so no outside content is materialized.
case "$verb" in
mkdir) takes_mode=0; ok_opts='pv' ;;
cp) takes_mode=0; ok_opts='rRvfnpa' ;;
mv) takes_mode=0; ok_opts='vfn' ;;
touch) takes_mode=0; ok_opts='acmv' ;;
chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path
esac
while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
t="${SEG_TOKS[$i]}"
i=$((i + 1))
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
# Checked at any position, not just before the first operand: GNU utils permute, so
# `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion.
case "$t" in
-?*)
# Allowlist: long options and the dereferencing flags leave a residue and defer.
[ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && exit 0
continue
;;
esac
fi
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
# Checked at any position, not just before the first operand: GNU utils permute, so
# `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion.
case "$t" in
-?*)
# Allowlist: long options and the dereferencing flags leave a residue and defer.
[ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`"
continue
;;
esac
fi
# chmod: consume the mode operand without a path check. Octal, or symbolic clauses.
if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then
case "$t" in
[0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;;
*) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || exit 0 ;;
esac
seen_mode=1
continue
fi
# chmod: consume the mode operand without a path check. Octal, or symbolic clauses.
if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then
case "$t" in
[0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;;
*) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;;
esac
seen_mode=1
continue
fi
under_tmp "$t" || exit 0
path_operand=1
resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and the MCP caches, and not inside a git checkout in \$HOME"
cls="${resolved%%$'\n'*}"
# Every operand of one operation stays in one root: see the exfiltration note above.
[ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots"
seen_class="$cls"
ops+=("${resolved#*$'\n'}")
# Against the expanded token, since `~/a` is cwd-independent and only reads as relative
# before `expand_home_prefix` has run.
case "$(expand_home_prefix "$t")" in /*) ;; *) rel_operand=1 ;; esac
path_operand=1
done
[ "$path_operand" = 1 ] || defer "no path operand"
# In directory form the command writes a path it does not name: `cp x dir` writes `dir/x`,
# and `cp` follows that child when it is a symlink — this checkout is full of them, every
# `*_ee.rs` pointing into the sibling EE repo. Deriving that child would mean reproducing
# which name the tool picks (the operand as written, not as resolved — a symlinked source
# keeps its own name) and how deep `-r` recurses. The form is left unproved instead.
case "$verb" in
cp | mv)
[ "${#ops[@]}" -ge 2 ] || return 0
# Whether the destination is an existing directory is itself a question about which of
# the two candidate working directories the command ran in, and only one of them is in
# `ops`. A `cd` that fails at runtime would otherwise let the form through: the
# destination resolved against the directory the command never reached is some path that
# does not exist, while the one it actually ran in is a directory full of symlinks.
[ -n "$alt_cwd" ] && [ "$rel_operand" = 1 ] \
&& defer "a relative operand after a \`cd\` lands in one of two directories"
# Index arithmetic rather than `${ops[-1]}`: macOS ships bash 3.2, where a negative
# subscript is a fatal error and would abort the guard mid-decision.
dest="${ops[$((${#ops[@]} - 1))]}"
[ -d "$dest" ] \
&& defer "\`$dest\` already exists as a directory, so this $verb writes a path it does not name"
;;
esac
}
split_segments "$cmd"
seg_cwd="${cwd:-$PWD}"
alt_cwd="" # where a `cd` that failed would have left the command
saw_cd=0 # a `cd` moved the working directory somewhere
proved=0 # how many ops came out inside a single root
only_ours=1 # ... and nothing else shares the command line
for seg in "${SEGMENTS[@]}"; do
segment_tokens "$seg"
case "${SEG_TOKS[0]:-}" in
"") continue ;;
mkdir | cp | mv | touch | chmod)
check_fileops_segment "${SEG_TOKS[0]}"
proved=$((proved + 1))
continue
;;
tar | unzip)
check_archive_segment "${SEG_TOKS[0]}"
proved=$((proved + 1))
continue
;;
cd)
# A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative
# operand points, to one of the two candidates `apply_cd` describes.
if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then
alt_cwd="$seg_cwd"
seg_cwd="$new_cwd"
else
# Not the harmless segment an allow assumes: whatever this guard could not account for
# may be a redirect, and a redirect writes. Leave the line to the normal flow.
seg_cwd="" alt_cwd=""
only_ours=0
fi
saw_cd=1
continue
;;
esac
# Some other command shares the line. If an `mv` or `chmod` runs inside it after all — behind
# a wrapper, an env prefix or a path — this hook cannot say what it writes to.
for verb in mv chmod; do
segment_runs_verb "$verb" "$seg" && defer "$verb is not the leading command word in \`$seg\`"
done
only_ours=0
done
[ "$path_operand" = 1 ] || exit 0
allow "every path operand is under /tmp"
# Exactly one write per line. Each segment is proved against the filesystem as it stands now,
# and an earlier write can change what a later operand means: `cp -r /tmp/tree /tmp/live` that
# recreates a symlink out of /tmp turns `/tmp/live/link` — a path under /tmp when this ran —
# into a write through that symlink. Deletes compose safely and guard-rm-outside-tmp.sh allows
# several, because `rm` unlinks a symlink rather than following it.
[ "$proved" -ge 1 ] || exit 0
[ "$only_ours" = 1 ] && [ "$proved" = 1 ] && decide allow "every path operand is inside a single root"
exit 0
+131 -83
View File
@@ -1,31 +1,38 @@
#!/usr/bin/env bash
# PreToolUse guard for `rm`: auto-allow ONLY a single, plain, single-line `rm` whose every
# operand is a whitelisted target — under /tmp, or inside a git working tree located in $HOME
# (a version-controlled project dir). Anything else makes no decision (exit 0) and falls back
# to the normal permission flow, where the `Bash(rm:*)` ask rule prompts (classifier as a
# backstop).
# PreToolUse guard for `rm`: auto-allow deletes whose every operand is a whitelisted target —
# under /tmp, inside a git working tree located in $HOME (a version-controlled project dir), or
# in one of the browser-automation caches the MCP servers rebuild on demand.
# Any other command that runs `rm` gets an explicit `ask`, which is the ordinary permission
# prompt and the only one `rm` gets (see lib-guarded-verb.sh); a command that runs no `rm` at
# all makes no decision (exit 0).
#
# The git-tree allowance trades on "this is a project under version control" being lower-stakes
# than a delete elsewhere — NOT on full recoverability: committed content is restorable via git,
# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history
# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff.
# The command is read one segment at a time, so chaining and line breaks carry no weight of
# their own: `rm -f /tmp/a && rm -rf /tmp/b` is two deletes, each proved on its own operands.
# A decision covers the whole command line, so `allow` is emitted only when every segment is
# an `rm` this guard proved or a `cd` it could resolve. A line that mixes a proven `rm` with
# some other command makes no decision instead and leaves that line to the normal permission
# flow: the delete is not what needed a prompt, and waving the rest of the line through with
# it would turn a trailing `rm -f /tmp/x` into a way to auto-approve anything.
#
# Deny-by-default: every token must consist only of a safe character set (alphanumerics,
# `. _ / -` and glob chars `* ? [ ]`). That set contains none of the characters bash uses for
# `. _ / -` and glob chars `* ? [ ]`), the one exception being the leading `~/` or `$HOME/` that
# `expand_home_prefix` rewrites first. That set contains none of the characters bash uses for
# quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), so those forms
# fail by construction rather than needing to be enumerated. `realpath -m` then resolves `..`
# fail by construction rather than needing to be enumerated. `canon_path` then resolves `..`
# and existing symlinks (so a symlink out of the allowed roots is caught), and a wildcard in a
# non-final path segment is refused because it can expand through a symlink realpath can't see.
#
# The git-repo allowance covers targets inside a git working tree under $HOME, and the tree's
# own root folder only when it is a linked worktree (`.git` is a pointer file, so history in
# the main repo survives); a primary checkout's root (`.git` is a history dir) and any `.git`
# path are never auto-allowed. Globs auto-allow only under /tmp — elsewhere their expansion
# could reach `.git` or a dotfile the literal checks never see. Relative operands resolve
# against the command's cwd (from the hook input). A PreToolUse `allow` overrides the ask rule.
# Which targets those roots cover, and the tradeoff they rest on, is `path_class` in
# lib-guarded-verb.sh. Globs auto-allow only under /tmp and the MCP caches — elsewhere their
# expansion could reach `.git` or a dotfile the literal checks never see. Relative operands resolve
# against the working directory the command runs from, which a `cd` in an earlier segment
# moves; once a `cd` is one this guard cannot resolve, that directory is unknown and a
# relative operand can no longer be proved.
#
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
# Assumes `jq`. Path canonicalization goes through `canon_path`, which covers both the Linux dev
# env and macOS; with neither backend available it proves nothing and every delete prompts.
set -uo pipefail
. "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh"
input=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
@@ -33,74 +40,115 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$cmd" ] && exit 0
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
# A newline separates commands, and the tokenizer below only reads the first line — defer.
case "$cmd" in *$'\n'*) exit 0 ;; esac
read -r -a toks <<< "$cmd"
# Bare leading `rm` only; wrappers (`timeout rm`), env prefixes, and `/bin/rm` defer.
[ "${toks[0]:-}" = "rm" ] || exit 0
# 0 (allow) iff the canonical path is an auto-allowable rm target: under /tmp, or strictly
# inside a git working tree located under $HOME. The walk stops at $HOME, so a dotfiles repo at
# ~ can't make all of $HOME deletable, and top-level ~ files stay protected.
allowed_target() {
local canon="$1" d root=""
case "$canon" in /tmp/?*) return 0 ;; esac
[ -n "${HOME:-}" ] || return 1
case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac
case "$canon" in *"/.git" | *"/.git/"*) return 1 ;; esac # protect history, not recoverable
d="$canon"
while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do
[ -e "$d/.git" ] && { root="$d"; break; }
d=$(dirname "$d")
done
[ -n "$root" ] || return 1 # not inside a git working tree under $HOME
if [ "$canon" = "$root" ]; then
# Deleting the repo root folder itself: allow only for a linked worktree, whose `.git` is
# a file/pointer so the history lives in the main repo and survives. A primary checkout's
# `.git` is a directory holding the history, so deleting it is unrecoverable — defer.
[ -f "$root/.git" ] && return 0
return 1
fi
return 0
# Every bail-out below goes through `defer`, so the forms this guard refuses to reason about —
# wrapped, quoted, expanded — still reach the user as a prompt whenever an `rm` runs among them.
runs_verb rm "$cmd" && guarded=1 || guarded=0
defer() {
[ "$guarded" = 1 ] && decide ask "$1"
exit 0
}
had_operand=0
end_opts=0
i=1
while [ "$i" -lt "${#toks[@]}" ]; do
t="${toks[$i]}"
i=$((i + 1))
# Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm`
# can't slip past): any character outside the safe set makes it unsafe to reason about.
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && exit 0
# A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name`
# into an operand — never a real option, so defer.
case "$t" in -*[*?[]*) exit 0 ;; esac
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
# Skip real options only before the first operand. A bare `-` is a filename, and under
# POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name`
# is a filename too — validate it rather than skipping it.
if [ "$had_operand" = 0 ]; then
case "$t" in -?*) continue ;; esac
has_substitution "$cmd" && defer "command substitution in the command line"
# Proves one `rm` segment, whose tokens are in SEG_TOKS with `rm` at index 0, resolving relative
# operands against $seg_cwd. Returns only once every operand is an auto-allowable target;
# anything it cannot prove defers instead.
check_rm_segment() {
local i=1 t p canon candidates had_operand=0 end_opts=0
while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
t="${SEG_TOKS[$i]}"
i=$((i + 1))
# Messages keep the token as written; everything downstream reasons about the expansion.
p=$(expand_home_prefix "$t")
# Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm`
# can't slip past): any character outside the safe set makes it unsafe to reason about.
[ -n "$(printf '%s' "$p" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`"
# A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name`
# into an operand — never a real option, so defer.
case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
# Skip real options only before the first operand. A bare `-` is a filename, and under
# POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name`
# is a filename too — validate it rather than skipping it.
if [ "$had_operand" = 0 ]; then
case "$t" in -?*) continue ;; esac
fi
fi
fi
had_operand=1
# No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink
# realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine.
case "$t" in */*) case "${t%/*}" in *[*?[]*) exit 0 ;; esac ;; esac
case "$t" in
/*) canon=$(realpath -m -- "$t" 2>/dev/null) ;;
*) canon=$(realpath -m -- "${cwd:-$PWD}/$t" 2>/dev/null) ;;
had_operand=1
# No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink
# realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine.
case "$p" in */*) case "${p%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac
# A relative operand has as many candidate paths as the command has candidate working
# directories, and every one of them has to be auto-allowable: a `cd` that fails at runtime
# leaves the delete running in the directory it started in.
case "$p" in
/*) candidates=$(canon_path "$p") ;;
*) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory this guard cannot pin down"
candidates=$(canon_path "$seg_cwd/$p")
[ -n "$alt_cwd" ] && candidates="$candidates
$(canon_path "$alt_cwd/$p")"
;;
esac
while IFS= read -r canon; do
[ -n "$canon" ] || defer "cannot resolve \`$t\`"
# A glob may auto-allow only in a root where everything is deletable — /tmp and the MCP
# caches, both of which `rm -rf <root>` already clears wholesale, so matching inside one
# grants nothing more. In a checkout the expansion could reach `.git`, a dotfile like
# `.*`, or a nested checkout root that the literal-path checks never see, so require
# literal operands there.
case "$p" in
*[*?[]*)
case "$(path_class "$canon")" in
tmp | mcp-cache) ;;
*) defer "glob \`$t\` is outside /tmp and the MCP caches" ;;
esac
;;
esac
path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and the MCP caches, and not inside a git checkout in \$HOME"
done <<< "$candidates"
done
[ "$had_operand" = 1 ] || defer "no operand"
}
split_segments "$cmd"
seg_cwd="${cwd:-$PWD}"
alt_cwd="" # where a `cd` that failed would have left the command
saw_cd=0
proved=0 # at least one `rm` segment came out auto-allowable
only_ours=1 # ... and nothing else shares the command line
for seg in "${SEGMENTS[@]}"; do
segment_tokens "$seg"
case "${SEG_TOKS[0]:-}" in
"") continue ;;
rm)
check_rm_segment
proved=1
continue
;;
cd)
# A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative
# operand points, to one of the two candidates `apply_cd` describes.
if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then
alt_cwd="$seg_cwd"
seg_cwd="$new_cwd"
else
# Not the harmless segment an allow assumes: whatever this guard could not account for
# may be a redirect, and a redirect writes. Leave the line to the normal flow.
seg_cwd="" alt_cwd=""
only_ours=0
fi
saw_cd=1
continue
;;
esac
[ -n "$canon" ] || exit 0
# A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its
# expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the
# literal-path checks never see — so require literal operands in git repos.
case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) exit 0 ;; esac ;; esac
allowed_target "$canon" || exit 0
# Some other command shares the line. If an `rm` runs inside it after all — behind a wrapper,
# an env prefix or a path — this guard cannot say what it deletes.
segment_runs_verb rm "$seg" && defer "rm is not the leading command word in \`$seg\`"
only_ours=0
done
[ "$had_operand" = 1 ] || exit 0
jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"rm operands are under /tmp or inside a git checkout in $HOME"}}'
[ "$proved" = 1 ] || exit 0
[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp, in an MCP cache, or inside a git checkout in $HOME'
exit 0
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env bash
# Sourced by the PreToolUse guards; not a hook itself.
#
# A permission rule beats a hook: an `ask` rule prompts whatever a PreToolUse hook returns, which
# makes the hook's `allow` dead weight. So settings.json carries no `ask` rule for `rm`, `mv` or
# `chmod`, and the guards own both halves — `allow` what they can prove safe, `ask` for the rest.
# Removing a guard's `ask` path therefore removes that verb's prompt entirely.
#
# `set -f` is global to the sourcing script so that the unquoted word split in runs_verb cannot
# expand a glob operand against the filesystem. Neither guard relies on pathname expansion.
set -f
# Canonical absolute path: `..` and existing symlinks resolved, missing trailing components
# allowed. Resolving symlinks is the load-bearing half — a lexical normalizer would collapse
# `/tmp/link/..` without seeing where `link` points, and let an operand out of its root.
# GNU `realpath -m` is exactly this; BSD realpath on macOS has no `-m` and exits on it, which
# would leave every operand unresolvable and every delete prompting, so fall back to python3's
# os.path.realpath, which has the same semantics. Trying rather than probing keeps the cost off
# the Bash calls that never reach a path check — most of them. With neither available this
# prints nothing, and every caller treats that as "cannot prove".
canon_path() {
local out
out=$(realpath -m -- "$1" 2>/dev/null) && [ -n "$out" ] && { printf '%s' "$out"; return; }
python3 -c 'import os,sys;sys.stdout.write(os.path.realpath(sys.argv[1]))' "$1" 2>/dev/null
}
# The roots every class is anchored to, in the form a canonicalized operand comes back in. On
# macOS /tmp is a symlink to /private/tmp, so a resolved scratch path never starts with `/tmp`
# and matching the literal would put every scratch path outside every class. Both exist, so
# `cd -P` resolves them without the process canon_path would spawn on every sourcing.
TMP_ROOT=$(cd -P -- /tmp 2>/dev/null && pwd)
[ -n "$TMP_ROOT" ] || TMP_ROOT=/tmp
HOME_ROOT=""
[ -n "${HOME:-}" ] && HOME_ROOT=$(cd -P -- "$HOME" 2>/dev/null && pwd)
# Prints <token> ($1) with a leading `~/`, `$HOME/` or `${HOME}/` — and those three words on
# their own — replaced by the home directory, so the ordinary spelling of a path outside every
# checkout can still be proved. Only that prefix and only those spellings: `~user/` names another
# account, and any other `$` is an expansion nothing here can evaluate, so both stay in the token
# and fail the caller's charset check. A quoted token keeps its quotes and fails there too.
expand_home_prefix() {
[ -n "$HOME_ROOT" ] || { printf '%s' "$1"; return; }
case "$1" in
'~' | '$HOME' | '${HOME}') printf '%s' "$HOME_ROOT" ;;
'~/'*) printf '%s/%s' "$HOME_ROOT" "${1#'~/'}" ;;
'$HOME/'*) printf '%s/%s' "$HOME_ROOT" "${1#'$HOME/'}" ;;
'${HOME}/'*) printf '%s/%s' "$HOME_ROOT" "${1#'${HOME}/'}" ;;
*) printf '%s' "$1" ;;
esac
}
# 0 iff <text> ($1) starts with a command that only reads its input. An allowlist, because the
# opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`,
# `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only
# costs a prompt. Text with no command word in it is not evidence of a reader either.
reads_only() {
local w
for w in $1; do
w="${w//[\"\'\\]/}"
w="${w%%<<*}" # a redirect needs no space: `cat<<EOF`
case "$w" in "" | -* | *=* | [0-9]* | '>'* | '<'*) continue ;; esac
case "${w##*/}" in
cat | tee | head | tail | grep | sed | awk | sort | uniq | wc | cut | diff | tr \
| jq | yq | gh | git | base64 | column | envsubst | python | python3 | node \
| psql | mysql | sqlite3 | wmill) return 0 ;;
esac
return 1
done
return 1
}
# A heredoc body is data rather than commands only when its delimiter is quoted and nothing
# executes it; a rule doesn't match a verb inside such a body, and a PR body would otherwise
# prompt for every `rm` in its text. Dropping one needs all of that, a delimiter that could
# really open a heredoc, and a terminator line — failing any part, nothing is dropped.
strip_heredoc_bodies() {
local -a lines=()
local line delim rest after trimmed piped quoted i j n
while IFS= read -r line; do lines+=("$line"); done <<< "$1"
n=${#lines[@]}
i=0
while [ "$i" -lt "$n" ]; do
line="${lines[$i]}"
printf '%s\n' "$line"
i=$((i + 1))
# A `#` opens a comment, and a comment opens no heredoc — including mid-line, as in
# `echo hi # cat <<EOF`. Cutting there also discards a `#` that is really part of a word or
# a string, which at worst leaves a real body to be scanned: an extra prompt, never a lost one.
line="${line%%'#'*}"
case "$line" in *'<<'*) ;; *) continue ;; esac
rest="${line#*<<}"
rest="${rest#-}" # <<- strips leading tabs from the body
rest="${rest#"${rest%%[![:space:]]*}"}"
delim="${rest%%[[:space:]]*}"
# Whatever follows the delimiter word decides whether this line could open a heredoc at
# all. Only a redirect or a pipe can (`cat <<EOF > f`); prose after it means the `<<` sits
# inside a string (`echo "cat <<EOF and more"`), and dropping down to a line that happens
# to match would discard the real commands in between. A quote anywhere in the remainder
# says the same thing, since `echo "cat <<EOF > f"` ends its redirect-looking text with the
# closing quote. That also refuses `cat <<EOF > "f"`, a real heredoc, which only over-prompts.
after="${rest#"$delim"}"
after="${after#"${after%%[![:space:]]*}"}"
case "$after" in
*[\"\'\\]*) continue ;;
"" | '>'* | '<'* | '|'* | [0-9]'>'* | [0-9]'<'*) ;;
*) continue ;;
esac
# A real delimiter is a bare word or one wholly quoted (`<<'EOF'`, `<<\EOF`); a stray quote
# left in it means the `<<` was quoted prose.
quoted=0
case "$delim" in
\'*\' | \"*\") delim="${delim:1:${#delim}-2}" quoted=1 ;;
\\?*) delim="${delim#\\}" quoted=1 ;;
esac
case "$delim" in
[A-Za-z_]*) ;;
*) continue ;;
esac
case "$delim" in *[!A-Za-z0-9_]*) continue ;; esac
# Only a quoted delimiter makes the body inert. Unquoted, the shell expands it before the
# consumer ever sees it, so a `$(rm -rf ~)` written in the body runs whatever reads it.
[ "$quoted" = 1 ] || continue
# Two commands can see this body: the one the `<<` belongs to, and anything it is then piped
# into. The first is whatever was started last before the `<<`, so splitting the text there
# on separators and substitution openers and taking the final piece finds `cat` in
# `--title "fix(agents): …" --body "$(cat <<`, without the title's parenthesis standing in
# for it. A line continuation (`bash \` then `<<'EOF'`) leaves that piece empty, which is
# not evidence of a reader and so keeps the body.
reads_only "$(printf '%s' "${line%%<<*}" | tr ';&|()`' '\n' | grep -v '^[[:space:]]*$' | tail -1)" || continue
piped="$after"
while :; do
case "$piped" in *'|'*) ;; *) break ;; esac
piped="${piped#*|}"
reads_only "${piped%%|*}" || continue 2
done
j="$i"
while [ "$j" -lt "$n" ]; do
trimmed="${lines[$j]#"${lines[$j]%%[![:space:]]*}"}"
[ "$trimmed" = "$delim" ] && break
j=$((j + 1))
done
[ "$j" -lt "$n" ] && i=$((j + 1))
done
}
# 0 iff <verb> ($1) runs as a command word in <segment> ($2), which must already be one
# segment (no separator left in it). Wrapper, env-prefix and `/bin/<verb>` forms all count.
segment_runs_verb() {
local verb="$1" w wrapped=0
for w in $2; do
# The shell strips quotes and backslashes before it looks up the command, so `'rm'` and
# `r\m` run rm and have to compare equal to it.
w="${w//[\"\'\\]/}"
case "$w" in
"$verb" | */"$verb") return 0 ;;
*=*) ;; # leading env assignment
-* | *'>'* | *'<'*) ;; # a flag, or a leading redirect
[0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose
'!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command
timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env)
wrapped=1 ;;
# A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`),
# so past a wrapper the scan runs to the end of the segment instead of stopping at the
# first ordinary word. Before one, that word is the command and the verb cannot follow
# it. Nothing bounds the scan: a wrapper takes unboundedly many operands
# (`env -u A -u B ...`), and any cutoff — a word count, or stopping at the first quoted
# word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the
# price, and it only over-prompts.
*) [ "$wrapped" = 1 ] || break ;;
esac
done
return 1
}
# Splits <command> ($1) into its command segments, into the global array SEGMENTS. Every guard
# reasons one segment at a time, so `a && b` is two commands here rather than one unparsable
# blob, and a newline is a separator like any other.
#
# The split set carries more than `; & |` and newlines: `$(`, backticks and `( )` open a nested
# command, and a separator that only ended statements would read `echo $(rm -rf ~)` as an
# `echo`. Braces are handled as words rather than separators, since splitting on them cuts
# `xargs -I {} … rm` in half and strands the `rm` in a segment that no longer knows a wrapper
# preceded it.
#
# `tr` and not `${1//[...]}`: a `}` inside the bracket expression closes the expansion itself,
# which silently leaves the command unsplit and every separator unseen.
split_segments() {
local seg
SEGMENTS=()
while IFS= read -r seg; do SEGMENTS+=("$seg"); done <<< "$(strip_heredoc_bodies "$1" | tr ';&|()`' '\n')"
}
# 0 iff <command> ($1) carries a command substitution outside a heredoc body. A substitution is
# concatenated into the word it sits in, and splitting on its opener cuts that word in half:
# `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/`, with the traversal validated as an
# unrelated segment. Nothing here can evaluate it, so a guard proves nothing about such a
# command. Heredoc bodies are excepted — those are data the split has already dropped.
has_substitution() {
case "$(strip_heredoc_bodies "$1")" in
*'$('* | *'`'*) return 0 ;;
esac
return 1
}
# Reads <segment> ($1) into the global array SEG_TOKS, dropping the shell keywords that can
# precede a command word so that `then rm -rf x` is analyzed as the `rm` it runs. Word
# splitting only: quotes are left in the token and fail the guards' charset check downstream,
# which is what keeps `rm -rf "$HOME/x"` unprovable.
segment_tokens() {
SEG_TOKS=()
read -r -a SEG_TOKS <<< "$1"
while [ "${#SEG_TOKS[@]}" -gt 0 ]; do
case "${SEG_TOKS[0]}" in
'!' | '{' | '}' | if | then | elif | else | while | until | do) SEG_TOKS=("${SEG_TOKS[@]:1}") ;;
*) break ;;
esac
done
}
# Prints the directory a `cd` lands in, given the current one ($1) and the tokens after the
# `cd` ($2...). Fails, printing nothing, when the destination cannot be resolved — a variable,
# `-`, an option, a relative path, no operand at all (`cd` alone is $HOME), or more than one.
#
# Resolving says nothing about whether the `cd` will SUCCEED: the destination may not exist, and
# `;` runs the next command anyway, leaving it in the directory it started in. So a caller may
# never treat this as the working directory outright — it is one of two candidates, and a
# relative operand has to be provable against the one the command started in as well. That also
# makes a `cd` word splitting invented out of quoted text harmless: it can only add a candidate,
# never drop one. Past the first `cd` the branching outruns two candidates, so a caller that
# sees a second gives up on relative operands entirely.
apply_cd() {
local cwd="$1" t
shift
[ "$#" -eq 1 ] || return 1
t=$(expand_home_prefix "$1")
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1
# Absolute only. A relative destination is not `$cwd/$t`: the shell searches $CDPATH first,
# so `cd ssh` may land in /etc/ssh, and this cannot see the caller's $CDPATH to rule it out.
case "$t" in /*) ;; *) return 1 ;; esac
canon_path "$t"
}
# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp,
# `mcp-cache` for one in a browser-automation cache the MCP servers rebuild on demand, or
# `repo:<root>` for one strictly inside the git working tree at <root>, itself under $HOME.
# Fails, printing nothing, for anything else — those are the only roots the guards are willing
# to touch unprompted. The root is part of the class so that a caller pairing two operands can
# tell one checkout from another: sibling repos are separate permission boundaries, not one.
#
# The `repo` class trades on "this is a project under version control" being lower-stakes than
# the same act elsewhere — NOT on full recoverability: committed content is restorable via git,
# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history
# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff.
#
# The walk stops at $HOME, so a dotfiles repo at ~ can't put all of $HOME in a class, and
# top-level ~ files stay out of one. A working tree's own root folder counts only when it is a
# linked worktree, whose `.git` is a pointer file so the history lives in the main repo and
# survives; a primary checkout's `.git` is a directory holding the history itself, so losing it
# is unrecoverable.
#
# Some paths are in no class in any root, /tmp included. Git history, and the agent's own guards
# and settings, because removing those is what removes the prompt on everything else. And every
# path `.claude/settings.json` refuses to read — `.env`, `secrets/`, `*.pem`, `*.key`,
# `credentials.json`, `.secret*` — because a `cp` or `mv` that is auto-allowed on both ends
# would rename one out of those globs and hand back through `Read` exactly what they deny.
path_class() {
local canon="$1" d root="" folded
# Matched against a lowercased copy: APFS is case-insensitive by default, so `.GIT` and `.git`
# are one directory, and a case-sensitive list would leave the history — and these guards' own
# settings — one keystroke from an auto-allowed delete. On a case-sensitive volume a genuinely
# distinct `.GIT/` over-matches, which costs a prompt and nothing else. `tr` and not `${x,,}`:
# macOS ships bash 3.2, which has no case-folding expansion.
folded=$(printf '%s' "$canon" | tr 'A-Z' 'a-z')
case "$folded" in
*"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;;
*"/.env" | *"/.env."*) return 1 ;;
*"/secrets" | *"/secrets/"*) return 1 ;;
*.pem | *.key | *"/credentials.json") return 1 ;;
*"/.secret"* | *.secret | *.secrets) return 1 ;;
esac
case "$canon" in "$TMP_ROOT"/?*) printf 'tmp'; return 0 ;; esac
[ -n "$HOME_ROOT" ] || return 1
# The Playwright MCP servers download browsers into `ms-playwright` and open a throwaway
# profile per session under `ms-playwright-mcp`; nothing prunes either, so they grow without
# bound (10G here) and clearing one costs a re-download and nothing else. They sit outside
# every checkout, where no other class reaches them. Matched including the root itself,
# unlike the repo class, because wiping the whole directory is the point.
# Each root is named exactly and then again with `/*`, rather than one trailing `*`: a case
# pattern's `*` spans the `-` as well, which would put a sibling somebody created themselves —
# `ms-playwright-mcp-backup` — in a class that auto-allows deleting it.
case "$canon" in
"$HOME_ROOT"/Library/Caches/ms-playwright | "$HOME_ROOT"/Library/Caches/ms-playwright/* \
| "$HOME_ROOT"/Library/Caches/ms-playwright-mcp | "$HOME_ROOT"/Library/Caches/ms-playwright-mcp/* \
| "$HOME_ROOT"/.cache/ms-playwright | "$HOME_ROOT"/.cache/ms-playwright/* \
| "$HOME_ROOT"/.cache/ms-playwright-mcp | "$HOME_ROOT"/.cache/ms-playwright-mcp/*)
printf 'mcp-cache'
return 0
;;
esac
case "$canon" in "$HOME_ROOT"/?*) ;; *) return 1 ;; esac
d="$canon"
while [ "$d" != "/" ] && [ "$d" != "$HOME_ROOT" ]; do
[ -e "$d/.git" ] && { root="$d"; break; }
d=$(dirname "$d")
done
[ -n "$root" ] || return 1 # not inside a git working tree under $HOME
if [ "$canon" = "$root" ]; then
[ -f "$root/.git" ] || return 1
fi
printf 'repo:%s' "$root"
}
# 0 iff <verb> ($1) runs as a command word anywhere in <command> ($2). Mirrors how a Bash
# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt:
# a guard consults this before it starts proving segments, and every bail-out it then takes
# is a prompt for exactly the commands a rule would have caught.
runs_verb() {
local verb="$1" seg
split_segments "$2"
for seg in "${SEGMENTS[@]}"; do
segment_runs_verb "$verb" "$seg" && return 0
done
return 1
}
# Emit a PreToolUse decision and exit. `ask` is the ordinary permission prompt.
decide() {
jq -nc --arg d "$1" --arg r "$2" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
exit 0
}
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# Decision table for the two scratch-dir PreToolUse guards. Run: bash .claude/hooks/test-hooks.sh
#
# What this pins is the `ask` column: a matcher change that turns one into a no-decision drops
# that command's only prompt (see lib-guarded-verb.sh). The wrapper, nested-command and quoted
# rows are the ones that catch it.
#
# The `allow` column carries its own weight, because a decision covers the whole command line:
# `allow` may only appear where every segment was proved here, and a line that also runs
# something unexamined has to come out `none` so the normal permission flow still sees it.
set -uo pipefail
H="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
CWD="$(git -C "$H" rev-parse --show-toplevel)"
OUT="$HOME/not-a-git-tree" # never written to; only the guards' path checks look at it
fails=0
# A tree's own root is auto-allowable only when it is a LINKED worktree, whose `.git` is a
# pointer file so the history lives in the main repo and survives; a primary checkout's `.git`
# is the history itself. The suite runs from either kind, so the rows that name the root follow
# the one it is run in — which is also what pins both halves of that rule.
if [ -f "$CWD/.git" ]; then
ROOT_SOLO=allow ROOT_CHAINED=none # linked worktree
else
ROOT_SOLO=ask ROOT_CHAINED=ask # primary checkout
fi
run() { # run <hook> <allow|ask|none> <command>
local hook="$1" want="$2" cmd="$3" out got
out=$(jq -nc --arg c "$cmd" --arg w "$CWD" \
'{tool_name:"Bash",tool_input:{command:$c},cwd:$w}' | "$H/$hook" 2>&1)
if [ -z "$out" ]; then
got=none
else
got=$(printf '%s' "$out" | jq -r '.hookSpecificOutput.permissionDecision // "PARSE-ERROR"' 2>/dev/null || echo PARSE-ERROR)
fi
local shown="${cmd//$'\n'/ ⏎ }"
if [ "$got" = "$want" ]; then
printf ' ok %-5s %s\n' "$got" "$shown"
else
printf 'FAIL want=%-5s got=%-5s %s\n %s\n' "$want" "$got" "$shown" "$out"
fails=$((fails + 1))
fi
}
echo "== guard-rm-outside-tmp.sh =="
G=guard-rm-outside-tmp.sh
run $G allow "rm -rf /tmp/scratch/x"
run $G allow "rm -rf /tmp/scratch/*"
run $G allow "rm -rf $CWD/frontend/scratch"
run $G ask "rm -rf /tmp"
run $G ask "rm -rf $OUT"
run $G ask "rm -rf $CWD/.git"
run $G ask "rm -rf $CWD/.claude/hooks" # the guards may not delete themselves
run $G ask "rm $CWD/.claude/settings.json"
run $G ask "rm $CWD/.claude/settings.local.json"
run $G ask "rm -rf $CWD/backend/.env"
run $G ask "rm -rf $CWD/.env.local"
run $G $ROOT_SOLO "rm -rf $CWD"
run $G ask "rm -rf $CWD/*"
run $G ask "rm -rf /etc/passwd"
# The MCP caches are the one allowed root outside /tmp and the checkouts, and `~/` and `$HOME/`
# the one expansion the charset check tolerates — so the row that matters is the one proving the
# prefix does not carry anything else along with it.
run $G allow "rm -rf ~/Library/Caches/ms-playwright-mcp"
run $G allow "rm -rf ~/.cache/ms-playwright-mcp" # the Linux spelling of the same root
run $G allow 'rm -rf $HOME/Library/Caches/ms-playwright-mcp/mcp-chrome-*'
run $G ask "rm -rf ~/.cache/ms-playwright-mcp-backup" # a sibling, not the cache
run $G ask "rm -rf ~/not-a-git-tree"
# The exclusion list is the whole protection for these paths — the `repo:` class allows deletes
# everywhere else in a checkout — and macOS resolves `.GIT` to `.git`, so the fold is what keeps
# the list from failing open there. Pattern-matched, so the row holds on either platform.
run $G ask "rm -rf $CWD/.GIT"
run $G ask "rm $CWD/.CLAUDE/settings.json"
run $G ask "rm -rf $CWD/backend/.ENV"
run $G ask 'rm -rf "$HOME/x"'
run $G ask "rm -rf /tmp/../$OUT"
run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour
run $G ask 'echo $(rm -rf /etc)'
run $G ask 'echo `rm -rf /etc`'
run $G ask "{ rm -rf /etc; }"
run $G allow "{ rm -rf /tmp/scratch/x; }" # the keyword drops, the delete still proves
run $G ask "find . -name x | xargs rm"
run $G ask "timeout 5 rm -rf /tmp/x"
run $G ask "stdbuf -o L rm -rf /etc"
run $G ask "FOO=bar rm -rf /tmp/x"
run $G ask "/bin/rm -rf /tmp/x"
run $G ask "'rm' -rf /etc"
run $G ask 'r\m -rf /etc'
run $G ask "! rm -rf /etc"
run $G ask "if true; then rm -rf /etc; fi"
run $G ask ">/dev/null rm -rf $OUT"
# Data that merely mentions a verb is not a command. Both of these prompted in the field.
run $G none "$(printf 'gh pr create --body "$(cat <<%sEOF%s\ndrop `rm` and `mv` from the ask list\nrm is now guarded here\nEOF\n)"' "'" "'")"
run $G none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')"
# A wrapper's own flags and assignments are unbounded, so they may not be charged against the
# scan that looks past it — these run rm and must prompt.
run $G ask "env -i HOME=/tmp PATH=/usr/bin LANG=C USER=root SHELL=/bin/sh rm -rf /etc"
run $G ask "sudo -E -H -u root FOO=1 BAR=2 rm -rf $OUT"
run $G ask "xargs -a f -d d -E e -I {} -L 1 -n 1 rm /etc"
run $G ask "env -u A -u B -u C -u D -u E -u F -u G rm -rf /etc"
run $G ask "sudo -u 'root' rm -rf /etc"
run $G ask "$(printf 'echo hi # cat <<EOF\nrm -rf /etc\nEOF')"
# A `<<` inside a quoted string or a comment opens no heredoc, so the command under it is real.
run $G ask "$(printf 'echo "cat <<EOF"\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'echo "cat <<EOF and more"\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'echo "cat <<EOF "\nrm -rf /etc\nEOF')"
run $G ask "$(printf '# usage: cat <<EOF\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'echo "cat <<EOF > f"\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'echo "cat <<true > /tmp/a"\nrm -rf /etc\ntrue')"
run $G ask "$(printf "echo 'cat <<EOF | tee'\nrm -rf /etc\nEOF")"
# A body fed to a shell is executed, so it is commands and not data.
run $G ask "$(printf 'bash <<EOF\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'cat <<EOF | bash\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'ssh host <<EOF\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'bash<<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
run $G ask "$(printf '/bin/sh <<EOF\nrm -rf /etc\nEOF')"
run $G ask "$(printf 'cat <<%sEOF%s|bash\nrm -rf /etc\nEOF' "'" "'")"
run $G ask "$(printf 'out=$(bash <<%sEOF%s\nrm -rf /etc\nEOF\n)' "'" "'")"
run $G ask "$(printf 'bash \\\n <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
run $G ask "$(printf 'ash <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
run $G ask "$(printf 'busybox sh <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
run $G ask "$(printf 'sudo -s <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
run $G ask "$(printf '(bash <<%sEOF%s)\nrm -rf /etc\nEOF' "'" "'")"
# A redirect or pipe after the delimiter is still a real heredoc.
run $G none "$(printf 'cat <<%sEOF%s > /tmp/a\nrm -rf /etc\nEOF' "'" "'")"
run $G none "$(printf 'cat <<%sEOF%s 2>&1 | tee /tmp/a\nrm -rf /etc\nEOF' "'" "'")"
# An unquoted body is expanded before its consumer sees it, so it is code.
run $G ask "$(printf 'cat <<EOF > /tmp/a\n$(rm -rf /etc)\nEOF')"
run $G ask "$(printf 'cat <<EOF > /tmp/a\nrm -rf /etc\nEOF')"
# ... but a real command after a heredoc still is one.
run $G ask "$(printf 'cat <<EOF > /tmp/s.sh\nhello\nEOF\nrm -rf %s' "$OUT")"
run $G ask "$(printf 'echo "a << b"\nrm -rf %s' "$OUT")"
run $G none "git rm frontend/foo.ts"
run $G none 'echo $(ls /tmp)'
run $G none 'grep -rn "rm" backend/'
run $G none "cargo build --release"
# Chaining and line breaks are not themselves a reason to prompt: each segment is proved on its
# own operands, and a `cd` moves where a relative one points.
run $G allow "rm -f /tmp/a; rm -rf /tmp/b"
run $G allow "$(printf 'rm -f /tmp/a\nrm -rf %s/frontend/scratch' "$CWD")"
run $G allow "cd /tmp/scratch && rm -rf sub"
run $G none "mkdir -p /tmp/x && rm -rf /tmp/x"
run $G ask "$(printf 'ls /tmp\nrm -rf /etc')"
# A `cd` this guard can resolve is where the relative operand lands; one it cannot leaves the
# working directory unknown, and an unknown one proves nothing.
run $G ask "cd /etc && rm -rf foo"
run $G ask 'cd "$D" && rm -rf foo'
run $G ask "cd $CWD && rm -rf .git"
run $G ask "cd /etc && cd /tmp/scratch && rm -rf sub" # a cd out is not walked back
# A `cd` can fail at runtime, and `;` runs the delete from where the command started, so a
# relative operand is proved from both directories.
run $G ask "cd /tmp/does-not-exist; rm -rf .git"
run $G ask "cd /tmp/does-not-exist; rm -rf backend/.env"
run $G ask "cd /tmp/a && cd /tmp/b && rm -rf sub"
run $G ask "rm -rf /tmp/clone/.git" # history is never in a class
run $G ask "rm -rf /tmp/scratch/id_rsa.key"
run $G none "cd /tmp >$OUT; rm -f /tmp/a"
# A substitution is concatenated into its word, so splitting on it would prove only the literal
# half; a relative `cd` is not $cwd/$t either, since the shell searches $CDPATH first.
run $G ask 'rm -rf /tmp/a/`printf ../../etc`'
run $G ask 'rm -rf /tmp/a/$(printf ../../etc)'
run $G ask "cd ssh && rm -rf moduli"
echo
echo "== allow-fileops-in-tmp.sh =="
A=allow-fileops-in-tmp.sh
run $A allow "mv /tmp/a /tmp/b"
run $A allow "chmod 755 /tmp/a"
run $A allow "cp -r /tmp/a /tmp/b"
run $A allow "tar -xzf /tmp/a.tar.gz -C /tmp/out"
run $A ask "mv /tmp/a $OUT"
run $A ask "mv $CWD/AGENTS.md /tmp/a"
run $A $ROOT_SOLO "chmod -R 777 $CWD"
run $A none "ls && mv /tmp/a /tmp/b" # proved move, unexamined neighbour
run $A ask 'echo $(mv /tmp/a /etc)'
run $A ask "timeout --signal KILL 5 mv /tmp/a /etc"
run $A ask "time -f FORMAT chmod 777 $OUT"
run $A ask "'mv' /tmp/a /etc"
run $A ask 'ch\mod 777 /etc'
run $A none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')"
run $A ask "env -i A=1 B=2 C=3 D=4 E=5 F=6 mv /tmp/a /etc"
run $A none "cp $CWD/AGENTS.md /tmp/a"
run $A none "tar -xzf /tmp/a.tar.gz -C $OUT"
run $A none "cargo build"
run $A ask "chmod -R 777 $CWD/.GIT"
run $A allow "chmod -R 755 ~/Library/Caches/ms-playwright-mcp"
run $A ask "chmod -R 777 ~/Library/Caches/ms-playwright-mcp-backup"
# The home prefix reaches this guard through `operand_class`, not the rm guard's own resolver.
case "$CWD" in
"$HOME"/*) run $A allow "mv ~${CWD#"$HOME"}/frontend/a.ts ~${CWD#"$HOME"}/frontend/b.ts" ;;
esac
run $A none "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" # one write per line
run $A none "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')"
run $A ask "ls && mv /tmp/a /etc"
run $A $ROOT_CHAINED "$(printf 'mkdir -p /tmp/x\nchmod -R 777 %s' "$CWD")"
run $A allow "cd /tmp/x && tar -xzf /tmp/a.tar.gz -C /tmp/out"
# The checkout is a root of its own, so an in-repo move or chmod is as auto-allowable as the
# in-repo delete already was — but one operation may not straddle it and /tmp.
run $A allow "chmod +x scripts/worktree-env"
run $A allow "mv backend/.sqlx backend/.sqlx.bad"
run $A allow "mv $CWD/frontend/a.ts $CWD/frontend/b.ts"
run $A ask "mv /tmp/a $CWD/frontend/a.ts"
run $A ask "chmod -R 777 $CWD/.git"
run $A ask "mv $CWD/backend/.env $CWD/backend/.env.bak"
run $A ask "mv $CWD/AGENTS.md $OUT"
run $A ask "cd /etc && mv a b"
# An auto-allowed rename may not carry a path out of the `Read` deny globs.
run $A ask "mv backend/server.pem backend/server.txt"
run $A none "cp backend/secrets/token frontend/token.txt" # cp has no prompt of its own,
# so what matters is it is not allowed
run $A ask "mv $CWD/backend/credentials.json /tmp/x"
run $A ask "cd /tmp/does-not-exist; mv .claude/settings.json settings.bak"
# A segment this hook cannot read whole may carry a redirect, and an earlier write can change
# what a later operand resolves to — neither may ride along on an allow.
run $A none "cd /tmp >$OUT; mv /tmp/a /tmp/b"
run $A none "cp -r /tmp/tree /tmp/live; cp /tmp/payload /tmp/live/link"
run $A ask 'mv /tmp/a/`printf ../../etc/x` /tmp/b'
# A sibling checkout is a different root: its files are outside what the Read tool is confined
# to, and copying them in would hand back what that confinement withholds.
EE="$(dirname "$CWD")/windmill-ee-private" # a sibling checkout; absent elsewhere, still not a root
run $A ask "mv $EE/backend/x.rs $CWD/backend/x.rs"
run $A none "cp $EE/README.md $CWD/README.copy"
# Directory form writes a path the command does not name — DEST/basename(SRC) — and `cp`
# follows that child when it is a symlink, as every `*_ee.rs` in this checkout is.
run $A ask "mv frontend/apps_ee.rs backend/windmill-api/src"
run $A none "cp frontend/apps_ee.rs backend/windmill-api/src"
run $A none "cp frontend/a.ts backend"
run $A ask "mv /tmp/a $CWD/backend"
# ... and a `cd` that fails at runtime may not hide that form: the destination is a directory
# in the directory the command actually ran in, whichever of the two that turns out to be.
run $A none "cd $CWD/AGENTS.md; cp frontend/apps_ee.rs backend/windmill-api/src"
run $A ask "cd $CWD/AGENTS.md; mv frontend/apps_ee.rs backend/windmill-api/src"
run $A none "cd /tmp/x && tar -xzf /tmp/a.tar.gz" # no -C, and the cwd is now two candidates
run $A allow "cp frontend/a.ts backend/a.ts" # ... naming the destination proves fine
echo
[ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; }
-3
View File
@@ -73,10 +73,7 @@
"Edit(**/.env.*)"
],
"ask": [
"Bash(rm:*)",
"Bash(rmdir:*)",
"Bash(mv:*)",
"Bash(chmod:*)",
"Bash(chown:*)",
"Bash(truncate:*)",
"Bash(shred:*)",
+1
View File
@@ -0,0 +1 @@
../../../.agents/skills/codebase-design/SKILL.md
+1
View File
@@ -0,0 +1 @@
../../../.agents/skills/domain-modeling/SKILL.md
+1
View File
@@ -0,0 +1 @@
../../../.agents/skills/grill-me/SKILL.md
+1
View File
@@ -0,0 +1 @@
../../../.agents/skills/grilling/SKILL.md
@@ -0,0 +1 @@
../../../.agents/skills/improve-codebase-architecture/SKILL.md
+1 -1
View File
@@ -42,7 +42,7 @@ RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VER
RUN /usr/local/bin/python3 -m pip install pip-tools
# Bun
COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun
# Install windmill CLI
RUN bun install -g windmill-cli \
+14
View File
@@ -0,0 +1,14 @@
<!--
We are not seeking outside contribution at this time. Small, trivially-verified PRs that fix a
problem are still welcome; low-value PRs (e.g. typo fixes) and PRs longer than a dozen or so lines
will be closed with a reference to CONTRIBUTING.md.
For a bigger idea, please open a feature request instead:
https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md
Read https://github.com/windmill-labs/windmill/blob/main/CONTRIBUTING.md before submitting.
-->
## What does this PR do?
## Related issue
+11
View File
@@ -4,3 +4,14 @@
- 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.
# Before you settle on a verdict
`REVIEW.md` tells you to discard findings you are not confident in. That rule exists to suppress noise, not to license a quick approval. Review in two passes:
1. Enumerate every candidate defect you notice, without judging any of them yet.
2. Take each candidate and try to prove it is real: read the surrounding code, check the caller, check the error path. Keep it, or dismiss it for a specific reason.
A "Good to merge" verdict must be accompanied by a "Considered and dismissed" section listing each candidate from pass 1 with the concrete reason it is not a finding. If that section would be empty, pass 1 was skipped: go back and do it.
Facts cut both ways. If you notice that a cached value can be multiple megabytes, that a lock is held across an await, or that a new parameter is caller-controlled, that observation is a candidate for pass 2 even when the surrounding code looks deliberate. Do not narrate such a fact as evidence that the code is fine without first checking whether it is a bug.
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.4.0
- uses: actions/setup-node@v4
with:
+8 -1
View File
@@ -22,6 +22,7 @@ on:
- "frontend/src/lib/userDraft.svelte.ts"
- "frontend/src/lib/userDraftDbSyncer.svelte.ts"
- "frontend/src/lib/infer.ts"
- "frontend/src/lib/components/sessions/**"
- ".github/workflows/ai-evals-test.yml"
pull_request:
types: [opened, reopened, ready_for_review]
@@ -35,6 +36,7 @@ on:
- "frontend/src/lib/userDraft.svelte.ts"
- "frontend/src/lib/userDraftDbSyncer.svelte.ts"
- "frontend/src/lib/infer.ts"
- "frontend/src/lib/components/sessions/**"
- ".github/workflows/ai-evals-test.yml"
concurrency:
@@ -73,7 +75,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.4.0
- uses: actions/setup-node@v4
with:
@@ -124,6 +126,11 @@ jobs:
bun install
bun test adapters/
# Harness code that reaches into the frontend module graph; bun cannot load it.
- name: Run harness unit tests (frontend graph)
working-directory: ./ai_evals
run: bun run test:frontend-graph
- name: Run global AI evals
timeout-minutes: 20
working-directory: ./ai_evals
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.4.0
- uses: actions/setup-node@v4
with:
+5 -2
View File
@@ -58,7 +58,7 @@ jobs:
go-version: 1.21.5
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.4.0
- uses: actions/setup-node@v4
with:
node-version: "20"
@@ -291,5 +291,8 @@ jobs:
TEST_NPM_REGISTRY: "http://localhost:4873/:_authToken=${{ env.NPM_TOKEN }}"
run: |
deno --version && bun -v && node --version && go version && python3 --version && php --version && ruby --version && pwsh --version && dotnet --version
cd windmill-duckdb-ffi-internal && ./build_dev.sh && cd ..
# The FFI crate is excluded from the workspace, so the `cargo test` below
# never reaches it. Pin the target dir (matching the cache step above) so
# its own tests run off this compile rather than a second bundled build.
(cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal)
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline --all -- --nocapture --test-threads=10
+14
View File
@@ -9,6 +9,13 @@ on:
- "windmill-yaml-validator/**"
- "backend/migrations/**"
- ".github/workflows/cli-tests.yml"
# The bundles cli/ vendors from the frontend: their drift guards live in
# cli/test but the edits that break them land here. The policy bundle
# inlines its imports too, so those sources belong in the filter.
- "frontend/src/lib/components/raw_apps/**"
- "frontend/src/lib/components/recording/**"
- "frontend/src/lib/components/apps/editor/commonAppUtils.ts"
- "frontend/src/lib/components/apps/inputType.ts"
pull_request:
branches: [main]
paths:
@@ -16,6 +23,13 @@ on:
- "windmill-yaml-validator/**"
- "backend/migrations/**"
- ".github/workflows/cli-tests.yml"
# The bundles cli/ vendors from the frontend: their drift guards live in
# cli/test but the edits that break them land here. The policy bundle
# inlines its imports too, so those sources belong in the filter.
- "frontend/src/lib/components/raw_apps/**"
- "frontend/src/lib/components/recording/**"
- "frontend/src/lib/components/apps/editor/commonAppUtils.ts"
- "frontend/src/lib/components/apps/inputType.ts"
env:
CARGO_TERM_COLOR: always
+3
View File
@@ -23,5 +23,8 @@ jobs:
cache-dependency-path: "frontend/package-lock.json"
- name: "npm check"
timeout-minutes: 5
env:
# svelte-check peaks past node's ~4GB default ceiling on this runner and aborts.
NODE_OPTIONS: --max-old-space-size=8192
run: cd frontend && npm ci && npm run generate-backend-client && npm run
check
+1 -1
View File
@@ -133,7 +133,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.4.0
- uses: denoland/setup-deno@v2
with:
+16 -1
View File
@@ -212,7 +212,9 @@ jobs:
- name: Install Pi CLI
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
run: npm install --global @mariozechner/pi-coding-agent
# Pinned: this job holds DEEPSEEK_API_KEY and PR write access, and an
# unpinned reviewer also makes verdicts non-reproducible across runs.
run: npm install --global @earendil-works/pi-coding-agent@0.84.1
- name: Pre-fetch base and head refs for the PR
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
@@ -362,9 +364,14 @@ jobs:
# The context file lives in RUNNER_TEMP (outside the checkout); tell the
# agent its absolute path.
printf '\nReview context file (absolute path): %s\n' "$CTX" >> /tmp/pi-prompt.md
# DeepSeek's reasoning_effort accepts low/high/max and silently maps both
# medium and xhigh onto high. Set the level explicitly rather than letting
# pi's default clamp onto it, so a change to either the default or the
# clamping is a visible diff here instead of a silent shift in review depth.
pi -p \
--provider deepseek \
--model deepseek-v4-pro \
--thinking high \
--tools "$PI_TOOLS" \
"${PI_HARDEN_FLAGS[@]}" \
--mode json \
@@ -399,6 +406,14 @@ jobs:
| (.content[]? | select(.type == "text") | .text)
' "$OUT_DIR/pi-events.jsonl" > "$OUT_DIR/pi-final-message.md"
# The final message often opens with chatter ("Now I have all the context
# I need..."), which would land above the verdict in the posted comment.
# Keep the trim conditional: without the heading there is nothing to cut
# and the range expression would empty the file.
if grep -q '^## Pi Review' "$OUT_DIR/pi-final-message.md"; then
sed -i -n '/^## Pi Review/,$p' "$OUT_DIR/pi-final-message.md"
fi
- name: Post Pi review comment
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
uses: actions/github-script@v7
+8 -2
View File
@@ -167,8 +167,14 @@ jobs:
env:
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
# prior-comments.md is PR comment text verbatim, and commenting needs no write access.
# With a fixed delimiter, a comment containing a bare `EOF` line closes the block early:
# the step dies, and whatever follows in that comment is read as further environment
# assignments for the rest of this job, which holds the review tokens. Hence a random
# delimiter, per GitHub's guidance for untrusted multiline values.
delimiter="REVIEW_PROMPT_EOF_$(openssl rand -hex 16)"
{
echo 'REVIEW_PROMPT<<EOF'
echo "REVIEW_PROMPT<<$delimiter"
cat REVIEW.md
echo ''
cat .claude/review-prompt.md
@@ -182,7 +188,7 @@ jobs:
echo ''
cat prior-comments.md
fi
echo 'EOF'
echo "$delimiter"
} >> "$GITHUB_ENV"
- name: Automatic PR Review
+6 -2
View File
@@ -25,16 +25,20 @@ jobs:
REMAINDER_FIRST_LINE=${FIRST_LINE#"$FIRST_WORD"}
REMAINDER_FIRST_LINE=${REMAINDER_FIRST_LINE# }
REST=$(printf '%s' "$BODY" | tail -n +2)
# The value is the comment body, which anyone can write. A fixed delimiter lets a
# comment close the block early and have the rest of itself read as further step
# outputs, so the delimiter has to be unguessable.
delimiter="EXTRA_EOF_$(openssl rand -hex 16)"
{
echo "command=$COMMAND"
echo 'extra_prompt<<EXTRA_EOF'
echo "extra_prompt<<$delimiter"
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'
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
;;
*)
@@ -150,13 +150,21 @@ jobs:
COMMENT_URL: ${{ inputs.COMMENT_URL }}
COMMENT_IS_EDIT: ${{ inputs.COMMENT_IS_EDIT }}
run: |
# 1) Find the thread by PR number
# 1) Find the thread by PR number. A rate-limited or unauthorized
# response carries no thread list at all, which under `bash -e` aborts
# the step (jq cannot iterate null, nor parse an HTML error page)
# rather than reaching the skip below. It is also not the same thing as
# this PR having no thread, so it is reported rather than swallowed.
threads=$(curl -s -H "Authorization: Bot $BOT_TOKEN" \
"https://discord.com/api/v10/guilds/${GUILD_ID}/threads/active")
if ! echo "$threads" | jq -e 'has("threads")' >/dev/null 2>&1; then
echo "::warning::Discord returned no thread list; the comment on PR #${PR_NUMBER} was not relayed: ${threads:0:200}"
exit 0
fi
thread_id=$(echo "$threads" | jq -r \
--arg cid "$CHANNEL_ID" \
--arg pref "#${PR_NUMBER}:" \
'.threads[] | select(.parent_id == $cid and (.name | startswith($pref))) | .id')
'(.threads // [])[] | select(.parent_id == $cid and (.name | startswith($pref))) | .id')
if [ -z "$thread_id" ]; then
echo "Thread not found for PR #${PR_NUMBER}, skipping"
+7 -1
View File
@@ -21,9 +21,15 @@ jobs:
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-signatures: "signatures/cla.json"
path-to-document: "https://github.com/windmill-labs/windmill/blob/master/CLA.md"
path-to-document: "https://github.com/windmill-labs/windmill/blob/main/CLA.md"
branch: "signatures"
allowlist: rubenfiszel,bot*
custom-notsigned-prcomment: |
Thank you for taking the time to open this PR.
Please note that **we are not seeking outside contribution at this time**. Small, trivially-verified PRs that fix a problem are still accepted, but low-value PRs (e.g. typo fixes) and PRs longer than a dozen or so lines will be closed. If you have a bigger idea, please open a [feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md) instead. See [CONTRIBUTING.md](https://github.com/windmill-labs/windmill/blob/main/CONTRIBUTING.md) for the full policy.
If your PR falls within that scope, we ask that you sign our [Contributor License Agreement](https://github.com/windmill-labs/windmill/blob/main/CLA.md) before we can accept it. You can sign the CLA by just posting a Pull Request Comment same as the below format.
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
#remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository)
+4
View File
@@ -22,6 +22,10 @@ rust-client/Cargo.toml
.claude/settings.local.json
.claude/worktrees/
# Personal agent notes, not shared with the team
AGENTS.local.md
CLAUDE.local.md
# Symlinked cache directories (for git worktrees)
backend/target
node_modules/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.782.0"
".": "1.796.0"
}
+30 -14
View File
@@ -5,9 +5,19 @@ workspace:
mainBranch: main
worktreeRoot: ../windmill__worktrees
defaultAgent: claude
# A new worktree is branched from the *local* `main` ref, so a stale local main means every
# new worktree starts behind. This keeps it current: fetch origin/main + fast-forward merge.
# Fast-forward only — it no-ops rather than forcing if local main has diverged.
autoPull:
enabled: true
intervalSeconds: 300
startupEnvs:
CARGO_FEATURES: "quickjs"
# true clones the base `windmill` DB via CREATE DATABASE ... TEMPLATE, which first
# terminates every open connection to `windmill` — expect the main dev instance to drop.
# false creates an empty DB and runs migrations. Either way the license key is copied over
# and pre-remove drops the DB. See scripts/worktree-common.sh.
WM_CLONE_DB: false
USE_RUST_PLUGIN: false
@@ -48,7 +58,6 @@ profiles:
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
kind: agent
@@ -58,11 +67,19 @@ profiles:
split: right
workingDir: backend
command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
# dev-supervisor runs vite only while someone is looking at the preview, which keeps
# the worktrees nobody has open from each costing 1.1-1.7 GB. The guard keeps panes
# working on branches cut before the script landed.
- 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: >-
npm run generate-backend-client && bash -c 'export
REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}}; if [ -f
scripts/dev-supervisor.mjs ]; then exec node scripts/dev-supervisor.mjs -t
${FRONTEND_PORT:-3000} --bind 0.0.0.0 --idle ${DEV_SUPERVISOR_IDLE:-15m}; else
exec npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0; fi'
frontendOnly:
runtime: host
@@ -78,7 +95,6 @@ profiles:
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
kind: agent
@@ -87,14 +103,16 @@ profiles:
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: >-
npm run generate-backend-client && bash -c 'if [ -f scripts/dev-supervisor.mjs
]; then exec node scripts/dev-supervisor.mjs -t ${FRONTEND_PORT:-3000} --bind
0.0.0.0 --idle ${DEV_SUPERVISOR_IDLE:-15m}; else exec npm run dev -- --port
${FRONTEND_PORT:-3000} --host 0.0.0.0; fi'
agentOnly:
runtime: host
yolo: true
envPassthrough: []
systemPrompt: >
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
panes:
- id: agent
kind: agent
@@ -140,14 +158,12 @@ oneshot:
— 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.
Always open the PR as a draft, then drive the `pr` skill's "Review rounds"
until every reviewer verdict is a go. Never flip to ready without a clean
round behind it, and never stop at an *unreviewed* draft — that is an
unfinished oneshot. Whether a clean round then flips the PR is the skill's
"Flip, or ask first" call, not this prompt's: self-contained changes flip,
wide-blast-radius ones stay a clean draft with the reason in the PR body.
# Ending your turn
Never end your turn with a question, a suggestion to "take a look", or a
+65 -77
View File
@@ -5,9 +5,22 @@ Open-source platform for internal tools, workflows, API integrations, background
## 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
2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages.
For a new user-facing feature, put the `feature_usage` telemetry in the plan as a proposed item
(see `docs/feature-telemetry.md`) so the user can keep or drop it — don't ask separately, and
don't instrument bugfixes or refactors.
3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`, then
**exercise the change on the running instance**. Type-checks are not verification. Whatever the
change touches, get that path actually running, and stand up whatever that takes — this is
expected, not a last resort. A few examples, not a closed list: drive the UI with the Playwright
MCP, run a real job of the kind you touched, restart the backend with the cargo features the
path needs (`backend/AGENTS.md`), put a stub in front of an upstream, start MinIO for an S3
path, plant state with SQL, exercise it through the `wmill` CLI. If the path you need has no
obvious way in, invent one rather than skipping it; `docs/` carries recipes for several areas.
If it needs a credential or a third-party account, ask for one rather than skipping the test or
inventing a value. If you genuinely cannot exercise it, say which path went unexercised instead
of implying it was verified.
## Documentation
@@ -17,6 +30,9 @@ Open-source platform for internal tools, workflows, API integrations, background
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
silently, so frontend-only instrumentation records nothing.
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
@@ -26,6 +42,7 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
- **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.
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
- **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server.
## Dev Environment
@@ -34,9 +51,15 @@ Open-source platform for internal tools, workflows, API integrations, background
> defaults in this section apply only to a plain single checkout. **Discover the real
> values before running anything** — see "Per-worktree ports and database" below.
**Check whether they are already running before starting anything.** In a webmux worktree
(`$WEBMUX_WORKTREE_PATH` is set) the backend and frontend are already up in sibling tmux panes —
use those, don't spawn your own. `tmux list-panes -t "$(tmux display-message -p -t "$TMUX_PANE"
'#{window_id}')" -F '#{pane_index} #{pane_current_command}'` shows what is running; read its log
with `tmux capture-pane`, and see `backend/AGENTS.md` to restart it with different cargo features.
A second server started in your own shell fights the first one for the port. The commands below
are for a plain checkout with nothing running.
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
- **DuckDB local jobs**: before running DuckDB scripts locally, build the FFI shared library with `cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh`. Re-run it after clean builds or when `backend/target/debug/libwindmill_duckdb_ffi_internal.*` is missing. The bundled DuckDB compile (~2min) is cached in a per-user dir shared across worktrees, so a fresh worktree reuses it and the build is near-instant.
- **Data pipelines (DuckLake) from source**: a plain `cargo run` (even `--features quickjs`) advertises a `duckdb` worker tag but **cannot** execute DuckDB scripts and has **no** working S3 proxy (DuckLake writes 404). Build CE DuckLake with `cargo run --features quickjs,duckdb,parquet,private` (add `,python` for Python scripts, `,enterprise,license` for EE) **and** build the FFI (bullet above). See `backend/CLAUDE.md` → "Running data pipelines (DuckLake) from source" for the exact feature sets and the two feature-gate gotchas.
- **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`
@@ -45,10 +68,27 @@ Open-source platform for internal tools, workflows, API integrations, background
### Per-worktree ports and database
A worktree's `.env` / `.env.local` (repo root) and `backend/.env` hold its own
`DATABASE_URL` and `PORT`; the database is typically `windmill_<branch_with_underscores>`
(branch `dbt-runtime``windmill_dbt_runtime`). Read them, or discover from what is
already running:
In a webmux worktree the authoritative values live in
`$(git rev-parse --git-dir)/webmux/runtime.env``BACKEND_PORT`, `FRONTEND_PORT`,
`DATABASE_URL`, `CARGO_FEATURES`, `WM_DB_NAME`. Every pane sources it at startup. Read that
first: it is not a `.env*` file, so the repo's secret-file read rules don't stand in the way.
In a plain checkout, fall back to `.env` / `.env.local` (repo root) and `backend/.env`.
Each worktree gets a **brand-new database**, created and migrated from scratch by the post-create
hook. It is not a copy of the main dev instance: you get the `admins` workspace, the
`admin@windmill.dev` superadmin, the license key copied from the base database, and whatever the
migrations seed — and none of your own workspaces, scripts, flows or apps. Create whatever a test
needs. Cloning the base `windmill` database instead is
opt-in per project via `WM_CLONE_DB` in `.webmux.yaml`; read the note there before turning it on.
The database is named after the **worktree directory, not the branch** (`scripts/worktree-common.sh`):
`windmill_` + the directory basename with `-``_`, which Postgres then truncates at 63
characters. Branch `hugo/win-2340-ai-agent-evals-standalone-agent-runs-and-eval-datasets` sits in
a worktree directory named `win-2340-…`, so its database is
`windmill_win_2340_ai_agent_evals_standalone_agent_runs_and_eval` — no `hugo_`, and the tail
chopped. Take `WM_DB_NAME` from `runtime.env` instead of reconstructing the name. Read those, or
discover from what is already running:
```bash
psql postgres://postgres:changeme@localhost:5432/postgres -tAc \
@@ -74,75 +114,6 @@ Getting these wrong is not a cheap mistake:
Beware that a `pgrep -f "<pattern>"` in a shell whose own command line contains
`<pattern>` matches the shell itself.
## 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
Write screenshots to an absolute path under `/tmp` (the MCP servers already do; standalone
Playwright scripts must be told): moving a PNG out of the checkout afterwards needs a `mv` the
permission hooks always prompt on. Same reason to run `rm`/`mv`/`cp` as one plain command per Bash
call: those hooks defer on `&&`, `;`, redirects, quotes and `$VAR`.
**Attach the screenshots to the PR.** For any change under `frontend/`, embed screenshots of the affected UI in the PR body — the `pr` skill requires this and carries the upload recipe.
If you cannot exercise a UI change (no dev server, etc.), say so explicitly rather than claiming success.
## Verifying Backend Changes
`cargo check` and the unit tests do not exercise a worker code path. **If you changed how
a job runs — an executor, `handle_child`, anything spawning or reading from a
subprocess — run an actual job of that kind** and confirm it completed, then say so.
Whole classes of defect compile and unit-test clean:
- **Stack overflow from a large buffer in an async block.** An array declared across an
`.await` is baked into the future's state; once that future is boxed a few layers deep
by the job poller, two 16 KB arrays abort the worker *process* (`thread
'tokio-runtime-worker' has overflowed its stack`). Heap-allocate read buffers
(`vec![0u8; N]`, not `[0u8; N]`).
- Deadlocks from draining only one of a child's pipes, missed cancellation or timeout
propagation, and anything depending on the real engine's output format.
A crash like this takes down every job on that worker, not just yours, so check the
backend log after the run rather than only the job's own status. If you cannot run one,
say which path went unexercised instead of implying it was verified.
## 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.
@@ -174,6 +145,23 @@ $NAV --root backend callees "X" # what does X call?
## Core Principles
- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics
- **Scratch stays outside the checkout.** Temp scripts, data dumps, cache backups and
screenshots go in the session scratch directory or `/tmp`, so nothing temporary can end up
committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each
operand, and auto-allows deletes, moves, copies and mode changes under `/tmp`, inside a git
checkout under `$HOME`, or in the Playwright MCP browser caches (`~/Library/Caches/ms-playwright`
and `ms-playwright-mcp`, `~/.cache/…` on Linux), as long as one operation stays within a single
root — a sibling checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain
deletes freely, each proved on its own operands, but keep writes to one per line, name the
destination rather than a directory to drop it in, and put anything else on its own line: a
command the hook does not prove drops the whole line back to the normal permission flow. A
leading `~/` or `$HOME/` is expanded and proved; a quoted operand, any other `$VAR`, a redirect,
a `$(…)`, a relative `cd`, or a wrapper like `xargs rm` cannot be, and that deferral is what
turns a cleanup into a prompt.
- **Change files with Edit/Write, not the shell.** `sed -i`, `cat > file <<'EOF'` and inline
`python3 - <<'PY'` scripts put an edit through the PreToolUse guards and the permission
classifier, which match `Bash` and nothing else, so a routine edit arrives as a prompt. Bash
stays right for running things — tests, builds, git, one-off queries.
- 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
+316
View File
@@ -1,5 +1,321 @@
# Changelog
## [1.796.0](https://github.com/windmill-labs/windmill/compare/v1.795.0...v1.796.0) (2026-08-24)
### Features
* add instance setting to mute zombie job restart alerts ([#10813](https://github.com/windmill-labs/windmill/issues/10813)) ([2906504](https://github.com/windmill-labs/windmill/commit/2906504125136afa280884b55e2f877a29a466a9))
* AI agent evals: datasets, scored runs and comparison ([#10633](https://github.com/windmill-labs/windmill/issues/10633)) ([9c55785](https://github.com/windmill-labs/windmill/commit/9c557859c5ffede921690cd3d224239b9305c9b8))
* **datatables:** add a down migration from the migration viewer ([#10812](https://github.com/windmill-labs/windmill/issues/10812)) ([3b2a6d7](https://github.com/windmill-labs/windmill/commit/3b2a6d76045cf5ae48ddfb76b871e24fd889298a))
* **frontend:** warn when COEP blocks cross-origin resources in raw app editor preview ([#10328](https://github.com/windmill-labs/windmill/issues/10328)) ([7751d3e](https://github.com/windmill-labs/windmill/commit/7751d3e43ee1abbba9a6cca026be78504f0c5dff))
* track token cost in AI sessions and chats ([#10688](https://github.com/windmill-labs/windmill/issues/10688)) ([b6e0591](https://github.com/windmill-labs/windmill/commit/b6e059116aa55fa5aa1226f5b3300bb2c8683f1a))
### Bug Fixes
* **ai-chat:** keep the composer usable while a question is pending ([#10816](https://github.com/windmill-labs/windmill/issues/10816)) ([25a3e6e](https://github.com/windmill-labs/windmill/commit/25a3e6ea7a7efb29ea3868ac0d1453d074325717))
* **frontend:** mint string password secrets in the operating workspace ([#10815](https://github.com/windmill-labs/windmill/issues/10815)) ([93081e2](https://github.com/windmill-labs/windmill/commit/93081e255f06386c3e21a838a752e5302ad6e6fe))
* keep ai chat messages when leaving the page mid-generation ([#10809](https://github.com/windmill-labs/windmill/issues/10809)) ([541b6c8](https://github.com/windmill-labs/windmill/commit/541b6c849657d13fed3580407a00a996e891ad9e))
* patch sqlx so a cancelled BEGIN cannot poison a pooled connection ([#10823](https://github.com/windmill-labs/windmill/issues/10823)) ([8dbd12e](https://github.com/windmill-labs/windmill/commit/8dbd12ecc1a8d0c03ba32b797c5a8cd9ee2d57b4))
* qualify foreign key targets in generated datatable migrations ([#10821](https://github.com/windmill-labs/windmill/issues/10821)) ([29c311a](https://github.com/windmill-labs/windmill/commit/29c311ab318f7e443d696baf871b8a0558fa8524))
## [1.795.0](https://github.com/windmill-labs/windmill/compare/v1.794.1...v1.795.0) (2026-08-22)
### Features
* show the date on the runs dashboard chart axes ([#10808](https://github.com/windmill-labs/windmill/issues/10808)) ([a350f7c](https://github.com/windmill-labs/windmill/commit/a350f7c68e14909746427e716c5fdc3144d1df71))
### Bug Fixes
* keep raw-app files within their app folder on sync pull ([#10796](https://github.com/windmill-labs/windmill/issues/10796)) ([5b885ae](https://github.com/windmill-labs/windmill/commit/5b885ae311f079a7852e0ecd8fb94e57e79c707f))
* keep workflow-as-code scripts off dedicated workers ([#10805](https://github.com/windmill-labs/windmill/issues/10805)) ([01891cd](https://github.com/windmill-labs/windmill/commit/01891cd73207e290a614cf7da506909fc4993646))
* name the requested storage when a workspace storage lookup finds nothing ([#10803](https://github.com/windmill-labs/windmill/issues/10803)) ([01fc4f1](https://github.com/windmill-labs/windmill/commit/01fc4f1568c2010af7c23929ff50108b5e1fb635))
* require an unscoped token to reach the workspace encryption key ([#10798](https://github.com/windmill-labs/windmill/issues/10798)) ([25d9a20](https://github.com/windmill-labs/windmill/commit/25d9a206304268326505ceef8c20cdb7941b6558))
* require item read scope on workspace tarball export ([#10797](https://github.com/windmill-labs/windmill/issues/10797)) ([dc27db6](https://github.com/windmill-labs/windmill/commit/dc27db68de21c4d13033222cdaadbc4e2e732fe8))
* scope capture deletion to the workspace in the request path ([#10795](https://github.com/windmill-labs/windmill/issues/10795)) ([40f0cab](https://github.com/windmill-labs/windmill/commit/40f0cab2adbdfbf1bfb12b7fbc3e419951fc8179))
* size the ephemeral job token to the job timeout it must serve ([#10804](https://github.com/windmill-labs/windmill/issues/10804)) ([4b406e3](https://github.com/windmill-labs/windmill/commit/4b406e37c05a63ae89bc007ac0e6e669e7f84125))
## [1.794.1](https://github.com/windmill-labs/windmill/compare/v1.794.0...v1.794.1) (2026-08-21)
### Bug Fixes
* **ci:** unbreak the windows test jobs and the discord comment relay ([#10799](https://github.com/windmill-labs/windmill/issues/10799)) ([5088e13](https://github.com/windmill-labs/windmill/commit/5088e1370537641a83ad86959c586945f6033414))
* keep every value of a repeated multipart field ([#10800](https://github.com/windmill-labs/windmill/issues/10800)) ([e0510fe](https://github.com/windmill-labs/windmill/commit/e0510fea21006a06eee7eecd4587161970d7f4d5))
## [1.794.0](https://github.com/windmill-labs/windmill/compare/v1.793.0...v1.794.0) (2026-08-21)
### Features
* inline login errors and a narrower single-column login card ([#10777](https://github.com/windmill-labs/windmill/issues/10777)) ([28b2ca6](https://github.com/windmill-labs/windmill/commit/28b2ca63672c916e07bd028eab07728d1aa4f0fe))
* support application default credentials for gcp pub/sub triggers ([#10778](https://github.com/windmill-labs/windmill/issues/10778)) ([8e508ea](https://github.com/windmill-labs/windmill/commit/8e508ea01a1b41bd48b2ec3db29123430938f444))
* upgrade bun to 1.4.0 and demote deno in the language picker ([#10784](https://github.com/windmill-labs/windmill/issues/10784)) ([d85050f](https://github.com/windmill-labs/windmill/commit/d85050f505b3ddc3f3c82f43dd3a9e4c32a1ee34))
### Bug Fixes
* apply the first script kind selection in the script editor ([#10789](https://github.com/windmill-labs/windmill/issues/10789)) ([75d0c29](https://github.com/windmill-labs/windmill/commit/75d0c29586a617f2cbfbf66720bdd0e47d6f92b4))
* build the global chat's prompt identity from the operating workspace ([#10793](https://github.com/windmill-labs/windmill/issues/10793)) ([0b3dc3e](https://github.com/windmill-labs/windmill/commit/0b3dc3e5c9dd45847a26969f24ad33825445ea6a))
* confine job tokens to workspace-scoped API routes ([#10631](https://github.com/windmill-labs/windmill/issues/10631)) ([9022dc9](https://github.com/windmill-labs/windmill/commit/9022dc9d440b95a4c45d22675f009acaf78daab7))
* ground the chat's AI agent provider in the workspace's models ([#10774](https://github.com/windmill-labs/windmill/issues/10774)) ([449b1a6](https://github.com/windmill-labs/windmill/commit/449b1a69338479fb654308c83273613929a74fda))
* make workspace preprocessor scripts selectable in flow preprocessor steps ([#10786](https://github.com/windmill-labs/windmill/issues/10786)) ([a9112b7](https://github.com/windmill-labs/windmill/commit/a9112b72a527af06a204827fbdff5ff9cb451f5d))
* resolve a script path to its new version as soon as the lock lands ([#10794](https://github.com/windmill-labs/windmill/issues/10794)) ([3c8e4b4](https://github.com/windmill-labs/windmill/commit/3c8e4b43fd005db405f60794afca0e389445e350))
* split the MCP script tools into createScript and updateScript ([#10783](https://github.com/windmill-labs/windmill/issues/10783)) ([92a454b](https://github.com/windmill-labs/windmill/commit/92a454b7a81cb1ecb98954387cbb8a361932775d))
## [1.793.0](https://github.com/windmill-labs/windmill/compare/v1.792.2...v1.793.0) (2026-08-20)
### Features
* add WM_ROOT_WORKSPACE, the closest dev or prod workspace of a job ([#10776](https://github.com/windmill-labs/windmill/issues/10776)) ([1f59841](https://github.com/windmill-labs/windmill/commit/1f59841a67766582b9b810ef7ff29a9f6d2bdced))
* **cli:** deduplicate identical script lockfiles (dedupeLockfiles) ([#10769](https://github.com/windmill-labs/windmill/issues/10769)) ([05abf6d](https://github.com/windmill-labs/windmill/commit/05abf6d5aa0e4326c79a8cfabda721f8c06ec8c5))
* guided setup wizard for data tables on Cloud ([#10584](https://github.com/windmill-labs/windmill/issues/10584)) ([5fb145c](https://github.com/windmill-labs/windmill/commit/5fb145c79ff74e7447a699c6c70c876ee69fad43))
* make the Git Repo Viewer work with GitHub App repositories ([#10765](https://github.com/windmill-labs/windmill/issues/10765)) ([5099f40](https://github.com/windmill-labs/windmill/commit/5099f405d4f49b4f7231e7a327392221aabf4f64))
* rework the resource type list in the add-resource drawer ([#10757](https://github.com/windmill-labs/windmill/issues/10757)) ([c7ec33c](https://github.com/windmill-labs/windmill/commit/c7ec33cc9f01aa7c616bf009f974dccb4396a5b4))
* **sessions:** batch edit, filters and grouping in the session sidebar ([#10772](https://github.com/windmill-labs/windmill/issues/10772)) ([ac27d02](https://github.com/windmill-labs/windmill/commit/ac27d0200de8234bcf55d23915a87879ae1dd47b))
### Bug Fixes
* explain the 6-field cron format when a schedule is rejected ([#10768](https://github.com/windmill-labs/windmill/issues/10768)) ([f6645af](https://github.com/windmill-labs/windmill/commit/f6645af77e09df669f28a3e4a6c13ae630ebf85e))
* gate the chat's open_page on the operating workspace's role ([#10779](https://github.com/windmill-labs/windmill/issues/10779)) ([ee1f981](https://github.com/windmill-labs/windmill/commit/ee1f9814c2c30e96b3e140ae0810080afc4a73d9))
* refuse an MCP endpoint call whose required request body is empty ([#10771](https://github.com/windmill-labs/windmill/issues/10771)) ([dad8fed](https://github.com/windmill-labs/windmill/commit/dad8fed6477aaaca97e9b6cc348d8be53a76a43c))
* reject invalid AI agent tool names when the chat writes a flow ([#10756](https://github.com/windmill-labs/windmill/issues/10756)) ([2b4369d](https://github.com/windmill-labs/windmill/commit/2b4369d7cb5c026c75a1c8c8fcd5d35dbf7ee5a8))
* scope git-sync concurrency key per repository ([#10767](https://github.com/windmill-labs/windmill/issues/10767)) ([ed2ff6c](https://github.com/windmill-labs/windmill/commit/ed2ff6c5e7755fb32bb7c6fdb015456d8088c701))
* **security:** a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr) ([#10124](https://github.com/windmill-labs/windmill/issues/10124)) ([c2deea1](https://github.com/windmill-labs/windmill/commit/c2deea13b7d5d98e3fc2e0c624b14fd87f2f3341))
* teach the AI the raw-app job bindings, the SDK reference and the draft/deployed split ([#10754](https://github.com/windmill-labs/windmill/issues/10754)) ([574775d](https://github.com/windmill-labs/windmill/commit/574775d50cbb34d114275463e1a4258dbabeb47c))
## [1.792.2](https://github.com/windmill-labs/windmill/compare/v1.792.1...v1.792.2) (2026-08-19)
### Bug Fixes
* check direct-deployment lock and superadmin in the deploy preflight ([#10748](https://github.com/windmill-labs/windmill/issues/10748)) ([ef8a8e8](https://github.com/windmill-labs/windmill/commit/ef8a8e821ca3a5f4a308c49226292565680bf90c))
* make the listScripts parent_hash filter valid SQL ([#10752](https://github.com/windmill-labs/windmill/issues/10752)) ([f34b7fb](https://github.com/windmill-labs/windmill/commit/f34b7fbcfa104bdc00abbfc59ab4a3dd8cafe0e0))
* **security:** validate ansible git repository URLs before invoking git ([#10759](https://github.com/windmill-labs/windmill/issues/10759)) ([fa7fbd3](https://github.com/windmill-labs/windmill/commit/fa7fbd348d4b184ea95cfc953ded7c72b6f04e5e))
## [1.792.1](https://github.com/windmill-labs/windmill/compare/v1.792.0...v1.792.1) (2026-08-18)
### Bug Fixes
* route legacy AI entry points to sessions instead of the unmounted chat ([#10705](https://github.com/windmill-labs/windmill/issues/10705)) ([494e6f1](https://github.com/windmill-labs/windmill/commit/494e6f146e6a22bd498db58fa10c7866cd56dc4d))
## [1.792.0](https://github.com/windmill-labs/windmill/compare/v1.791.0...v1.792.0) (2026-08-18)
### Features
* **frontend:** record the outcome of every AI chat tool call ([#10746](https://github.com/windmill-labs/windmill/issues/10746)) ([7b17e35](https://github.com/windmill-labs/windmill/commit/7b17e358b35bf4ef8c213ea13252a456e87acb32))
### Bug Fixes
* **api:** document cache_ignore_s3_path on the Script read schema ([#10742](https://github.com/windmill-labs/windmill/issues/10742)) ([6783a39](https://github.com/windmill-labs/windmill/commit/6783a396b144948fa60324eae888bc4a83917bc8))
* audit the icon library against brand guidelines ([#10722](https://github.com/windmill-labs/windmill/issues/10722)) ([6749015](https://github.com/windmill-labs/windmill/commit/6749015fbf7afe0c6dcd53b1933b0152915afd32))
* **cli:** keep script settings on push and repair the up-to-date check ([#10741](https://github.com/windmill-labs/windmill/issues/10741)) ([ef4dc46](https://github.com/windmill-labs/windmill/commit/ef4dc46d4bfd00e583a39e5c053c0022f1ad3abd))
* show runtime-detected assets in a run's Assets tab ([#10738](https://github.com/windmill-labs/windmill/issues/10738)) ([1fa3bf3](https://github.com/windmill-labs/windmill/commit/1fa3bf3b291c32ffd62f77df59e24993afb7c78a))
## [1.791.0](https://github.com/windmill-labs/windmill/compare/v1.790.1...v1.791.0) (2026-08-17)
### Features
* add empty state cards to list pages ([#10726](https://github.com/windmill-labs/windmill/issues/10726)) ([66bffaa](https://github.com/windmill-labs/windmill/commit/66bffaa60d48f992e56e459efb813b24e3942610))
* **copilot:** let plan mode draw, but never write the plan ([#10725](https://github.com/windmill-labs/windmill/issues/10725)) ([fd9295a](https://github.com/windmill-labs/windmill/commit/fd9295a58e6868ccc6f371e35b875ae27196e99a))
### Bug Fixes
* compile resource types with no properties instead of throwing ([#10730](https://github.com/windmill-labs/windmill/issues/10730)) ([b17fdab](https://github.com/windmill-labs/windmill/commit/b17fdab8ff96aa7bbfc8b14389294bda1a9e0a07))
* derive a raw app's policy on deploy, and default an omitted execution_mode ([#10733](https://github.com/windmill-labs/windmill/issues/10733)) ([343ce6e](https://github.com/windmill-labs/windmill/commit/343ce6e143343e65613d52d0f12c5264b4ab4c3a))
* include delete_after_secs in script deploy payload ([#10731](https://github.com/windmill-labs/windmill/issues/10731)) ([05eba6c](https://github.com/windmill-labs/windmill/commit/05eba6c9ab078cdedc87f197549dbdbc4b360fe3))
* support [@typechecked](https://github.com/typechecked) decorator in Python relative imports ([#8495](https://github.com/windmill-labs/windmill/issues/8495)) ([ab3c020](https://github.com/windmill-labs/windmill/commit/ab3c0206d7e9b32676d99ed0cd8c9d8939122584))
* type s3-streamed columns that are all-null in the inference sample ([#10728](https://github.com/windmill-labs/windmill/issues/10728)) ([6b5b9f7](https://github.com/windmill-labs/windmill/commit/6b5b9f72d4f9ce86b21d8d21ae342b6c8dc14b93))
## [1.790.1](https://github.com/windmill-labs/windmill/compare/v1.790.0...v1.790.1) (2026-08-17)
### Bug Fixes
* fall back to polling when a proxy mutes the job SSE stream ([#10716](https://github.com/windmill-labs/windmill/issues/10716)) ([64d78b4](https://github.com/windmill-labs/windmill/commit/64d78b4db1d7d939c598c86d1998218d52fbcc21))
### Performance Improvements
* cap resource content sent to the search modal ([#10714](https://github.com/windmill-labs/windmill/issues/10714)) ([529e960](https://github.com/windmill-labs/windmill/commit/529e9606297ee0b41456a66222f31409d7bc7669))
* unblock workers before the API router is built ([#10711](https://github.com/windmill-labs/windmill/issues/10711)) ([0258f3f](https://github.com/windmill-labs/windmill/commit/0258f3f81b96bb8d4e343ba8aeba614f9c836579))
## [1.790.0](https://github.com/windmill-labs/windmill/compare/v1.789.0...v1.790.0) (2026-08-15)
### Features
* add trigger_history table with source tracking ([#10696](https://github.com/windmill-labs/windmill/issues/10696)) ([633d7bc](https://github.com/windmill-labs/windmill/commit/633d7bcb2ea034c39b72f7a8f5109b4ccd71b0be))
* advertise the pinned artifact version in get_preview_status ([#10691](https://github.com/windmill-labs/windmill/issues/10691)) ([850b028](https://github.com/windmill-labs/windmill/commit/850b028778afe0cda1dc357c9e647d066339f3ce))
* **ai-sessions:** add plan mode ([#10057](https://github.com/windmill-labs/windmill/issues/10057)) ([caa1898](https://github.com/windmill-labs/windmill/commit/caa189868c6ec9ebc2b6311e07308a83fa25ad8d))
* let the global AI chat call connected MCP servers as the user ([#10656](https://github.com/windmill-labs/windmill/issues/10656)) ([3f07a1a](https://github.com/windmill-labs/windmill/commit/3f07a1a803a3f8a176de754188f641bdfcaa6cec))
* stream audit logs in batches when a page is slow to load ([#10695](https://github.com/windmill-labs/windmill/issues/10695)) ([9334727](https://github.com/windmill-labs/windmill/commit/9334727d99eac251b0a995916c7ea00bd9596cef))
* **telemetry:** extend feature-usage tracking beyond AI features ([#10681](https://github.com/windmill-labs/windmill/issues/10681)) ([53eb946](https://github.com/windmill-labs/windmill/commit/53eb94659bd27e75ed4acf4ce414046ac8df4cc8))
### Bug Fixes
* **agents:** let the scratch-dir hooks own their permission prompt ([#10702](https://github.com/windmill-labs/windmill/issues/10702)) ([0a40b38](https://github.com/windmill-labs/windmill/commit/0a40b3806fc08c6d7b2f9fd9b7ade07fdf1841f1))
* **agents:** stop the scratch-dir guards prompting on quoted text ([#10703](https://github.com/windmill-labs/windmill/issues/10703)) ([e6e2e53](https://github.com/windmill-labs/windmill/commit/e6e2e53e97bebd407d72819d6163c04b6fc0b6b0))
* **ci:** use random delimiters for untrusted multiline workflow values ([#10706](https://github.com/windmill-labs/windmill/issues/10706)) ([0fc74de](https://github.com/windmill-labs/windmill/commit/0fc74dec5f9d9d6594f1bc4a85b895ee8c3bf17b))
* confine jobs:run tokens to the jobs of the runnables they may start ([#10635](https://github.com/windmill-labs/windmill/issues/10635)) ([ee53327](https://github.com/windmill-labs/windmill/commit/ee533273dd2fa0dc70e45b9750f3556002b150b7))
* drop sampling params on Claude models that reject them ([#10708](https://github.com/windmill-labs/windmill/issues/10708)) ([3468cb6](https://github.com/windmill-labs/windmill/commit/3468cb68b12c27f7f133e350b433fd9379fc8b07))
* **groups:** replace instance-group delta-patching with a state-based reconciler ([#10686](https://github.com/windmill-labs/windmill/issues/10686)) ([b551033](https://github.com/windmill-labs/windmill/commit/b5510333eac99f575aa2251398ca58626e419968))
* keep a resource's linked secret reference in sync while renaming ([#10693](https://github.com/windmill-labs/windmill/issues/10693)) ([60c5ad2](https://github.com/windmill-labs/windmill/commit/60c5ad252afe23642410743632be2f6eaf2fbffd))
* keep non traffic-serving processes out of coordinated restarts ([#10694](https://github.com/windmill-labs/windmill/issues/10694)) ([6d03784](https://github.com/windmill-labs/windmill/commit/6d03784d4b15535666bd4afdc5bbde5af016e078))
* recover from a refused mcp read assertion, drop stale discovery ([#10710](https://github.com/windmill-labs/windmill/issues/10710)) ([effdcd9](https://github.com/windmill-labs/windmill/commit/effdcd99155a9235856b245b7f49a37e0632db08))
* refresh AI provider model defaults and capability metadata ([#10690](https://github.com/windmill-labs/windmill/issues/10690)) ([68fc782](https://github.com/windmill-labs/windmill/commit/68fc7825bb5cd04347debb1a30af227614b9d9f5))
* send sage_intacct oauth client credentials in the request body ([#10685](https://github.com/windmill-labs/windmill/issues/10685)) ([bd5b3ea](https://github.com/windmill-labs/windmill/commit/bd5b3ea779fa6351e937fc3639f0bb985ffc1ce9))
### Performance Improvements
* back off the interactive worker shell under EXIT_AFTER_N_JOBS ([#10700](https://github.com/windmill-labs/windmill/issues/10700)) ([578d5e9](https://github.com/windmill-labs/windmill/commit/578d5e9a7d1016deaa81e5bf314029c5d7de9589))
* cache resolved python interpreter path across worker restarts ([#10701](https://github.com/windmill-labs/windmill/issues/10701)) ([878b8ef](https://github.com/windmill-labs/windmill/commit/878b8ef4c47f650686d4a42fc9763d57cc1c62cf))
* declare a settings pass instead of reading one setting at a time ([#10698](https://github.com/windmill-labs/windmill/issues/10698)) ([30f5d2e](https://github.com/windmill-labs/windmill/commit/30f5d2e7660ad5bfa335d76a69bd5c3ad8e70c77))
* resolve the worker external IP in the background ([#10697](https://github.com/windmill-labs/windmill/issues/10697)) ([22eadab](https://github.com/windmill-labs/windmill/commit/22eadab67d52fe4cb6bf1e73d161a6a439770295))
## [1.789.0](https://github.com/windmill-labs/windmill/compare/v1.788.0...v1.789.0) (2026-08-13)
### Features
* add EXIT_AFTER_N_JOBS worker mode for environment cleanup ([#10671](https://github.com/windmill-labs/windmill/issues/10671)) ([2fcce45](https://github.com/windmill-labs/windmill/commit/2fcce4526a239437221e37cfd4adfd4da616cf19))
* add memory limits to the go build subprocess ([#10666](https://github.com/windmill-labs/windmill/issues/10666)) ([4cb51cf](https://github.com/windmill-labs/windmill/commit/4cb51cf7bc6aa869efd055cb459ac3a58a6e0e7b))
* auto-build binaries to object storage on deployment ([#10673](https://github.com/windmill-labs/windmill/issues/10673)) ([71b9989](https://github.com/windmill-labs/windmill/commit/71b9989daa9c450faa00ec2605c77e0457d820d6))
* open an AI session from runs, jobs and trigger pages ([#10608](https://github.com/windmill-labs/windmill/issues/10608)) ([adc7947](https://github.com/windmill-labs/windmill/commit/adc7947579090d9695c17144e07d3b8d130818c1))
### Bug Fixes
* expand AZURE_DEVOPS_TOKEN placeholder in backend git probes ([#10677](https://github.com/windmill-labs/windmill/issues/10677)) ([2714210](https://github.com/windmill-labs/windmill/commit/2714210d7c74aa9375ecbb8742e16d007d991ea4))
* **flow:** pass the flow's worker tag when testing a loop iteration ([#10680](https://github.com/windmill-labs/windmill/issues/10680)) ([6fbc3fc](https://github.com/windmill-labs/windmill/commit/6fbc3fccb607a8d80885f0face284c01871a3162))
* git sync missed metadata-only deploys, deploy check missed job link ([#10662](https://github.com/windmill-labs/windmill/issues/10662)) ([93b811f](https://github.com/windmill-labs/windmill/commit/93b811fd8d007e2aa715b458194cfdc35a0bb1d6))
* **github-app:** complete the self-managed setup instructions, render the page header ([#10683](https://github.com/windmill-labs/windmill/issues/10683)) ([ef99a73](https://github.com/windmill-labs/windmill/commit/ef99a739dda73fba60df011e34981c2cb5e23a3c))
* stream ansible playbook logs in real time ([#10669](https://github.com/windmill-labs/windmill/issues/10669)) ([dad4c10](https://github.com/windmill-labs/windmill/commit/dad4c10c8b06ce72d8d808ceea95c7d8efa4918d))
## [1.788.0](https://github.com/windmill-labs/windmill/compare/v1.787.0...v1.788.0) (2026-08-12)
### Features
* **sessions:** persist artifact version selection in preview tabs ([#10655](https://github.com/windmill-labs/windmill/issues/10655)) ([73b71a8](https://github.com/windmill-labs/windmill/commit/73b71a8fac61069274460ac8151041677c7c5f2d))
### Bug Fixes
* harden custom env var name handling in the nativets/bun prologue ([#10634](https://github.com/windmill-labs/windmill/issues/10634)) ([84f3b00](https://github.com/windmill-labs/windmill/commit/84f3b0094d0659ac3e458ef9ebe5768d181524c3))
* home search matches each term instead of the whole query verbatim ([#10663](https://github.com/windmill-labs/windmill/issues/10663)) ([603b201](https://github.com/windmill-labs/windmill/commit/603b2012a7d2a03eb1a9bb4cb60bd59bc1859078))
## [1.787.0](https://github.com/windmill-labs/windmill/compare/v1.786.1...v1.787.0) (2026-08-12)
### Features
* expose every runs filter on the open_page chat tool ([#10612](https://github.com/windmill-labs/windmill/issues/10612)) ([ce58b84](https://github.com/windmill-labs/windmill/commit/ce58b8495c8a0a4ad0fe3eddae6dec1f447b89f8))
### Bug Fixes
* **schedule:** hoist non-RLS reads out of the create_schedule tx ([#10658](https://github.com/windmill-labs/windmill/issues/10658)) ([f4a935b](https://github.com/windmill-labs/windmill/commit/f4a935bd1c4f8ae336824b61e0648e8b0d91f8c9))
* stop the AI chat destroying secret variables on edit ([#10616](https://github.com/windmill-labs/windmill/issues/10616)) ([eb238e3](https://github.com/windmill-labs/windmill/commit/eb238e3f0b63acfabe0b8b7c69e8219cc0448f89))
## [1.786.1](https://github.com/windmill-labs/windmill/compare/v1.786.0...v1.786.1) (2026-08-12)
### Bug Fixes
* avoid content shift on home page load and in the script editor logs pane ([#10654](https://github.com/windmill-labs/windmill/issues/10654)) ([2808150](https://github.com/windmill-labs/windmill/commit/2808150ae4c39a76be21a38a2d739fd071dde8cd))
* bound postgres result collection so an oversized result cannot OOM the worker ([#10644](https://github.com/windmill-labs/windmill/issues/10644)) ([201d7c4](https://github.com/windmill-labs/windmill/commit/201d7c4eb2f5fdcdc2a64698c1595e108c2daddf))
* **frontend:** skip reserved ids when auto-assigning flow module ids ([#10651](https://github.com/windmill-labs/windmill/issues/10651)) ([5f819cd](https://github.com/windmill-labs/windmill/commit/5f819cd344ea602c4c06f93ff3da11a685f283c2))
## [1.786.0](https://github.com/windmill-labs/windmill/compare/v1.785.0...v1.786.0) (2026-08-12)
### Features
* bound how much disk a single duckdb job can spill ([#10645](https://github.com/windmill-labs/windmill/issues/10645)) ([a6157ba](https://github.com/windmill-labs/windmill/commit/a6157ba1046b046466212a3370e0cfb8796f4b59))
### Bug Fixes
* bound duckdb result collection so an oversized result cannot OOM the worker ([#10641](https://github.com/windmill-labs/windmill/issues/10641)) ([00822a7](https://github.com/windmill-labs/windmill/commit/00822a7435328b38a5a3bd1ee6d9a1556ca2d0c0))
* **copilot:** read an artifact inside the transaction that revises it ([#10647](https://github.com/windmill-labs/windmill/issues/10647)) ([66c0d12](https://github.com/windmill-labs/windmill/commit/66c0d1251dcb3ed4cdf3b2010d3f5a4e95378341))
## [1.785.0](https://github.com/windmill-labs/windmill/compare/v1.784.0...v1.785.0) (2026-08-11)
### Features
* **ata:** prefer the npm proxy when the instance configures a registry ([#10632](https://github.com/windmill-labs/windmill/issues/10632)) ([a8816b8](https://github.com/windmill-labs/windmill/commit/a8816b896df978c07441642d0c4092c303c967c9))
* **frontend:** show prod and dev as sibling choices in the workspace picker ([#10590](https://github.com/windmill-labs/windmill/issues/10590)) ([583649b](https://github.com/windmill-labs/windmill/commit/583649bb2311c321835e9a1de78a174e55616461))
* keep duckdb spilling behind the local-filesystem fence ([#10607](https://github.com/windmill-labs/windmill/issues/10607)) ([18ae0bd](https://github.com/windmill-labs/windmill/commit/18ae0bdfbfe7791f27ea9d3e4ce9ee4dbded1988))
* **npm-proxy:** keep package files on disk and in the object store ([#10638](https://github.com/windmill-labs/windmill/issues/10638)) ([3394546](https://github.com/windmill-labs/windmill/commit/339454665796d6f4e728f7460d942462792c62dc))
* **sdk:** add cancelJob to the TypeScript client ([#10624](https://github.com/windmill-labs/windmill/issues/10624)) ([d9b9137](https://github.com/windmill-labs/windmill/commit/d9b9137e17d4d009167ca20413bd1a9f1c204f8b))
* **triggers:** nested filter groups and dotted paths ([#10625](https://github.com/windmill-labs/windmill/issues/10625)) ([ec99108](https://github.com/windmill-labs/windmill/commit/ec99108cf6a62951f0aebecbe4b14d7b80a14215))
### Bug Fixes
* accept a bodyless request that advertises a JSON content type ([#10628](https://github.com/windmill-labs/windmill/issues/10628)) ([5125467](https://github.com/windmill-labs/windmill/commit/5125467de4ed54e9a0ba7f408f9e5c74bc8136fb))
* **ata:** fall back to the npm proxy when the CDN request fails outright ([#10630](https://github.com/windmill-labs/windmill/issues/10630)) ([e54b6a9](https://github.com/windmill-labs/windmill/commit/e54b6a914cf3936da408b1ce615313ebdfa6b3fe))
* bump the bundled DuckDB engine to 1.5.5 ([#10588](https://github.com/windmill-labs/windmill/issues/10588)) ([4fafe59](https://github.com/windmill-labs/windmill/commit/4fafe59371a836f738359f3aa438d2a219e3e934))
* **cli:** delete file resources at the right path on sync push ([#10639](https://github.com/windmill-labs/windmill/issues/10639)) ([1816b11](https://github.com/windmill-labs/windmill/commit/1816b114744c7d33826142a938b0e57e75142e5c))
* **cli:** sync push crashed on edited fileset children; reject non-canonical fileset dirs ([#10572](https://github.com/windmill-labs/windmill/issues/10572)) ([8513457](https://github.com/windmill-labs/windmill/commit/85134578b40a938c27110ea39193a36776e430f3))
* **parser-py:** keep first param when def main( line has trailing comment ([#10586](https://github.com/windmill-labs/windmill/issues/10586)) ([a02a97c](https://github.com/windmill-labs/windmill/commit/a02a97ce3ba7e2758a517312b2171f4141f281b0))
* pin MCP OAuth token requests to the validated address ([#10593](https://github.com/windmill-labs/windmill/issues/10593)) ([46eca13](https://github.com/windmill-labs/windmill/commit/46eca132824c37de3dc2c482fabfb015b4c158be))
* **python-client:** return at most size bytes from S3BufferedReader.read ([#10623](https://github.com/windmill-labs/windmill/issues/10623)) ([13b5216](https://github.com/windmill-labs/windmill/commit/13b521651bdbe31bf0179898646c04391d46f20b))
* **raw-apps:** respect the instance .npmrc in the raw app editor ([#10629](https://github.com/windmill-labs/windmill/issues/10629)) ([ceacc17](https://github.com/windmill-labs/windmill/commit/ceacc170144ce554aa62722f8609ade50f16c632))
* scope a fork's cloned app policy and custom path to its creator ([#10595](https://github.com/windmill-labs/windmill/issues/10595)) ([06c6b87](https://github.com/windmill-labs/windmill/commit/06c6b8780c919e6f110bd05ffeaed8bc065ddf56))
* tell MCP clients which tool parameters may be omitted ([#10642](https://github.com/windmill-labs/windmill/issues/10642)) ([f23a5d7](https://github.com/windmill-labs/windmill/commit/f23a5d78b2ea0fcd4607161c0c4cccceff6c4f0c))
## [1.784.0](https://github.com/windmill-labs/windmill/compare/v1.783.0...v1.784.0) (2026-08-10)
### Features
* **flow-editor:** measure step panel placement ([#10543](https://github.com/windmill-labs/windmill/issues/10543)) ([676256b](https://github.com/windmill-labs/windmill/commit/676256baccbd2e4421e8c13a5a239825a9c47551))
* version history for session artifacts ([#10574](https://github.com/windmill-labs/windmill/issues/10574)) ([77adf85](https://github.com/windmill-labs/windmill/commit/77adf85ccd512aad3ea54362bbaceae240e5c8a0))
* version resource values with history, diff and restore ([#10596](https://github.com/windmill-labs/windmill/issues/10596)) ([c09de59](https://github.com/windmill-labs/windmill/commit/c09de594b6e35c0c1c88e504c90730ff217b42fd))
### Bug Fixes
* **cli:** attach the right job path to preview runs ([#10606](https://github.com/windmill-labs/windmill/issues/10606)) ([9eef70e](https://github.com/windmill-labs/windmill/commit/9eef70ea8b366c42f968308112cc41a6fdeccf8a))
* **cli:** load the app's ESM svelte compiler, not its CJS one ([#10622](https://github.com/windmill-labs/windmill/issues/10622)) ([2748d01](https://github.com/windmill-labs/windmill/commit/2748d019f53e37c3254392c5a40776a94c1ca130))
* **duckdb:** cast list columns in quicksearch so tables containing them can be previewed ([#10614](https://github.com/windmill-labs/windmill/issues/10614)) ([bf1b2cd](https://github.com/windmill-labs/windmill/commit/bf1b2cdcf9cd5251ee0560077db307b6003d472c))
* **frontend:** call a dev workspace a dev workspace in the merge UI ([#10605](https://github.com/windmill-labs/windmill/issues/10605)) ([c725d62](https://github.com/windmill-labs/windmill/commit/c725d62fb07e059a377346d13ea491bbc97bd666))
* order workspace members and invites by email ([#10604](https://github.com/windmill-labs/windmill/issues/10604)) ([85916ce](https://github.com/windmill-labs/windmill/commit/85916cedf812eeb2ab96a428939e1198fd55ceaf))
* raw app new-app modal ignores instance-level AI settings ([#10619](https://github.com/windmill-labs/windmill/issues/10619)) ([8c65511](https://github.com/windmill-labs/windmill/commit/8c65511e814e383f6cdd9df3601e544cbc0c1b49))
* **smtp:** explain why a test email failed instead of 'deadline has elapsed' ([#10620](https://github.com/windmill-labs/windmill/issues/10620)) ([5b0a159](https://github.com/windmill-labs/windmill/commit/5b0a159a018662ea7836e72d8ee95d3aebd30cef))
## [1.783.0](https://github.com/windmill-labs/windmill/compare/v1.782.0...v1.783.0) (2026-08-07)
### Features
* add public sharing option for job pages ([#10573](https://github.com/windmill-labs/windmill/issues/10573)) ([5ce29b3](https://github.com/windmill-labs/windmill/commit/5ce29b34364d6429411621752c64e306426aa2dd))
* offer more dev workspace environment labels ([#10570](https://github.com/windmill-labs/windmill/issues/10570)) ([8c6211c](https://github.com/windmill-labs/windmill/commit/8c6211c27718912ad1e1a29f770747ea07fdb161))
* open a session edit in the preview panel from the edits list ([#10486](https://github.com/windmill-labs/windmill/issues/10486)) ([d008975](https://github.com/windmill-labs/windmill/commit/d0089758e6091b07009dee5b8387f3f4b6d97455))
* preview merge result in git-sync PR diff check ([#10542](https://github.com/windmill-labs/windmill/issues/10542)) ([c61404a](https://github.com/windmill-labs/windmill/commit/c61404a0f4008cd1de3c977c653c712ef3d9e0a3))
### Bug Fixes
* **frontend:** collapse the dev-workspace edit notice into a badge ([#10576](https://github.com/windmill-labs/windmill/issues/10576)) ([fdd76a6](https://github.com/windmill-labs/windmill/commit/fdd76a6f1358404b13326c68eed1eac14097bd88))
* **frontend:** hide the fork workspace banner from operators ([#10575](https://github.com/windmill-labs/windmill/issues/10575)) ([57ed0f7](https://github.com/windmill-labs/windmill/commit/57ed0f77e1f83751ea04820b365fb53f098d38bf))
* scope a fork's cloned app policy and custom path to its creator ([#10589](https://github.com/windmill-labs/windmill/issues/10589)) ([8e95bfe](https://github.com/windmill-labs/windmill/commit/8e95bfe6157ebe4f8fc5f6e13c36a6f0bdb9969d))
## [1.782.0](https://github.com/windmill-labs/windmill/compare/v1.781.3...v1.782.0) (2026-08-06)
+34
View File
@@ -0,0 +1,34 @@
# Contributing to Windmill
At this time, we are not seeking outside contribution.
AI has made writing code easy. The hard part, today, is not writing the code, but reviewing it,
making sure quality stays high, and keeping the product coherent. In that light, unfortunately,
external code contributions are "donating" the easy part of the job, while creating more of the
hard work.
With that said, we are happy to accept small, trivially-verified PRs that fix a problem. However,
we ask that you refrain from submitting low-value PRs (e.g. typo fixes) or PRs that are more than a
dozen or so lines. Such PRs will be closed with a reference to this guideline.
If you have a big idea you'd like us to consider, feel free to open a
[feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md)
about it.
This policy may change in the future as the project matures. Until then, thank you for your
understanding.
## What is still very welcome
- [Bug reports](https://github.com/windmill-labs/windmill/issues/new?template=bug_report.yml), with
clear reproduction steps.
- [Feature requests](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md),
including for ideas too big to be a PR.
- Questions and feedback on [Discord](https://discord.gg/V7PM2YHsPB).
- Contributions to the [Windmill Hub](https://hub.windmill.dev), where scripts, flows and apps are
shared with the community.
## If you do open a PR
Small, self-contained fixes are still accepted. They require signing the
[CLA](./CLA.md), which the CLA bot will prompt for on your first PR.
+4 -1
View File
@@ -50,7 +50,10 @@ RUN apt-get update && apt-get install -y clang=1:19.0* libclang-dev=1:19.0* cmak
COPY ./backend/windmill-duckdb-ffi-internal .
# The `duckdb` crate comes from a git dependency (a fork carrying an engine patch),
# which cargo checks out under $CARGO_HOME/git rather than the registry cache.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=$SCCACHE_DIR,sharing=locked \
cargo build --release -p windmill_duckdb_ffi_internal
@@ -284,7 +287,7 @@ COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/t
COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno
COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun
# Install windmill CLI
RUN bun install -g windmill-cli \
+11 -1
View File
@@ -31,7 +31,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together
</p>
<p align="center">
<a href="https://app.windmill.dev">Try it</a> - <a href="https://www.windmill.dev/">Website</a> - <a href="https://www.windmill.dev/docs/intro/">Docs</a> - <a href="https://discord.gg/V7PM2YHsPB">Discord</a> - <a href="https://hub.windmill.dev">Hub</a> - <a href="https://www.windmill.dev/docs/misc/contributing">Contributor's guide</a>
<a href="https://app.windmill.dev">Try it</a> - <a href="https://www.windmill.dev/">Website</a> - <a href="https://www.windmill.dev/docs/intro/">Docs</a> - <a href="https://discord.gg/V7PM2YHsPB">Discord</a> - <a href="https://hub.windmill.dev">Hub</a> - <a href="./CONTRIBUTING.md">Contributing</a>
</p>
# Windmill - Developer platform for APIs, background jobs, workflows and UIs
@@ -62,6 +62,7 @@ https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4
- [Run a local dev setup](#run-a-local-dev-setup)
- [Frontend only](#frontend-only)
- [Backend + Frontend](#backend--frontend)
- [Contributing](#contributing)
- [Contributors](#contributors)
- [Copyright](#copyright)
@@ -260,6 +261,8 @@ On self-hosted instances, you might want to import all the approved resource typ
| NATIVE_MODE | false | Enable native mode: sets NUM_WORKERS=8, rejects non-native jobs (nativets, postgresql, mysql, etc.) | Worker |
| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker |
| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker |
| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). Not counting the init and periodic scripts means they run again on every restart: an init script's runtime is added to the latency of every batch of that many jobs, and a periodic script fires once per process start whatever its interval says. The worker's shell in the workers page also starts backed off rather than after the two minutes it otherwise takes, since a process due to be recycled cannot count on living that long: the first command of a session can wait up to 15s, later ones are immediate. For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and `/tmp` survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. | Worker |
| WORKER_SUFFIX | None | Pins the last part of the worker name, which is otherwise random, so that a restarted worker keeps its row in the workers list. Only needed when several worker processes of the same worker group run on one host, since the name is derived from the hostname: give each of them a distinct value, as two processes sharing one must never happen. At most 64 letters, digits and underscores; anything else is refused at startup. | Worker |
| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
| SLACK_SIGNING_SECRET | None | The signing secret of your Slack app. See [Slack documentation](https://api.slack.com/authentication/verifying-requests-from-slack) | Server |
| COOKIE_DOMAIN | None | The domain of the cookie. If not set, the cookie will be set by the browser based on the full origin | Server |
@@ -282,6 +285,7 @@ On self-hosted instances, you might want to import all the approved resource typ
| MIN_FREE_DISK_SPACE_MB | 15000 | Minimum amount of free space on worker. Sends critical alert if worker has less free space. | Worker |
| RUN_UPDATE_CA_CERTIFICATE_AT_START | false | If true, runs CA certificate update command at startup before other initialization | All |
| RUN_UPDATE_CA_CERTIFICATE_PATH | /usr/sbin/update-ca-certificates | Path to the CA certificate update command/script to run when RUN_UPDATE_CA_CERTIFICATE_AT_START is true | All |
| GOOGLE_APPLICATION_CREDENTIALS | None | (ee only) Credentials file for GCP Pub/Sub triggers that authenticate as the instance rather than through a `gcloud` resource (workspace admins only). Application default credentials also resolve the gcloud well-known file and the GCE metadata server. Workload Identity Federation files work with the `file`, `url` and `aws` credential sources; the `executable` source is not supported. | Server |
## Run a local dev setup
@@ -327,6 +331,12 @@ running options.
2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor.
7. Windmill should be available at `http://localhost:3000`
## Contributing
At this time, we are not seeking outside contribution. Bug reports and feature requests remain very
welcome, and small, trivially-verified PRs that fix a problem are still accepted. See
[CONTRIBUTING.md](./CONTRIBUTING.md) for the full policy.
## Contributors
<a href="https://github.com/windmill-labs/windmill/graphs/contributors">
+34
View File
@@ -150,6 +150,36 @@ Global initial fixtures can also seed `liveEditorDrafts` with `type`,
currently open script, flow, or raw app editor so cases can test prompts that
refer to "this" or the "current" item.
Global initial fixtures can seed the session's `artifacts``{ name, versions: [{ content,
note? }], role?, approvedVersion? }`, oldest version first, so the artifact starts with the
history `list_artifact_versions` reports — and the `previewTabs` open in its side panel, for
cases that run with `runtime.sessionChat: true`. A tab entry names one destination and may
be the `active` one:
```json
"previewTabs": [{ "artifact": { "name": "Onboarding plan", "version": 2 }, "active": true }]
```
`page` (`{ href, label }`) and `item` (`{ kind, path }`) tabs work the same way. Tabs are
driven by the production tab model, so `open_preview`, `get_preview_status` and
`close_page` really open, report and close them, and a `version` is the pin a reader
chose in the artifact's version picker — which only `get_preview_status` reports.
Global initial fixtures can seed `workspace.variables` with
`{ path, value, is_secret, description?, labels?, ws_specific? }` entries so cases can
read and edit variables that already exist in the workspace. The mock mirrors the real
`get_variable`, **decrypt-by-default included**: a secret's `value` is withheld only
when the caller explicitly passes `decryptSecret: false`, and omitting the flag returns
the decrypted value, exactly as against a real backend. The chat's read path passes
`decryptSecret: false`, so a case can verify it never invents a value it was not shown.
Seed a recognizable secret (the existing fixture uses `sk_live_do_not_leak_me`) and
assert it via `valueExcludes` to catch a leak.
`toolExpect.toolCallArgs` entries additionally support `fieldMustBeAbsent: true`: no
recorded call to that tool may pass the field at all (an explicit `null` counts as
passing it). Use it for partial-update tools, where supplying a field the model could
not have read is itself the failure — e.g. `write_variable.value` on a secret variable.
Global (and flow) initial fixtures can seed `workspace.datatables` so the
`list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` tools
return seeded data during evals. Each entry is
@@ -250,6 +280,10 @@ Typical artifacts by mode:
- `history/`: optional tracked pass-rate history written by `run --record`, one JSONL file per mode
- `results/`: local benchmark output and artifacts
Harness unit tests run in two lanes: `bun test adapters/` for plain TypeScript, and
`bun run test:frontend-graph` for `*.vitest.ts` files, which exercise adapters built on
frontend code (Svelte runes, SvelteKit aliases) that bun cannot load.
## Notes
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
handleBenchmarkApiFetch,
hasBenchmarkApiHandler,
registerBenchmarkWorkspaceRunnables,
unregisterBenchmarkWorkspace
} from './mockBackend'
// A global eval run registers its workspace under a mkdtemp path, so the workspace id the
// frontend interpolates into the models URL carries slashes. A handler that assumed a single
// path segment silently fell through to the network, and every run took the offline fallback.
const WORKSPACE = '/tmp/wmill-frontend-global-benchmark-abc123'
const RESOURCE = 'f/evals/global/anthropic_main'
const URL_FOR = (workspace: string) =>
`http://benchmark.local/api/w/${workspace}/ai/proxy/models`
describe('benchmark /ai/proxy/models', () => {
it('serves the seeded listing for a workspace id that is a path', async () => {
registerBenchmarkWorkspaceRunnables(WORKSPACE, {
aiProviders: [
{ path: RESOURCE, kind: 'anthropic', models: ['claude-sonnet-5', 'claude-opus-5'] }
]
})
try {
expect(hasBenchmarkApiHandler(URL_FOR(WORKSPACE))).toBe(true)
const response = handleBenchmarkApiFetch(URL_FOR(WORKSPACE), {
headers: { 'X-Resource-Path': RESOURCE, 'X-Provider': 'anthropic' }
})
await expect(response.json()).resolves.toEqual({
data: [{ id: 'claude-sonnet-5' }, { id: 'claude-opus-5' }]
})
} finally {
unregisterBenchmarkWorkspace(WORKSPACE)
}
})
})
@@ -0,0 +1,39 @@
import { describe, expect, it } from "bun:test";
import { createEvalArtifactHelpers } from "./evalArtifactStore";
import { planArtifactId } from "../../../../../frontend/src/lib/components/copilot/chat/artifacts/planIdentity";
// A hand-written stand-in for SessionArtifactsStore (bun has no IndexedDB), so nothing
// makes it follow that class. A method missing from it surfaces as a tool throwing
// part-way through an eval run, which reads as a model failure rather than a harness one.
describe("eval artifact store", () => {
it("exposes every method the artifact tools call", () => {
const { helpers } = createEvalArtifactHelpers();
for (const method of [
"create",
"get",
"update",
"remove",
"listForSession",
"listVersions",
"getVersion",
]) {
expect(typeof (helpers.artifacts as any)[method]).toBe("function");
}
});
it("files a plan under the id production derives, seeded or created", async () => {
const { helpers, sessionId } = createEvalArtifactHelpers([
{ name: "Seeded plan", role: "plan", versions: [{ content: "v1" }] },
]);
const seeded = await helpers.artifacts.listForSession(sessionId);
expect(seeded.map((a: any) => a.id)).toEqual([planArtifactId(sessionId)]);
const other = createEvalArtifactHelpers();
const created = await other.helpers.artifacts.create(other.sessionId, {
name: "Plan",
content: "v1",
role: "plan",
});
expect(created.id).toBe(planArtifactId(other.sessionId));
});
});
@@ -0,0 +1,184 @@
import { planArtifactId } from "../../../../../frontend/src/lib/components/copilot/chat/artifacts/planIdentity";
// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes),
// so mirror only the shape the artifact tools call, not its scoping or race handling.
// Cases run concurrently in one process and the preview handlers are registered
// process-wide, keyed by session id — so each run needs its own.
let sessionSeq = 0;
/** An artifact the session already holds when the case starts: history has to predate the
* run, since one prompt cannot both build a past and reason about it. */
export interface SeededArtifact {
name: string;
role?: "plan";
/** Which version the user agreed to. Below the last one means the current text is a
* proposal they turned down, which is the state worth seeding. */
approvedVersion?: number;
/** Oldest first; the last one is the artifact's current content. */
versions: Array<{ content: string; note?: string }>;
}
export function createEvalArtifactHelpers(seed: SeededArtifact[] = []) {
const sessionId = `eval-session-${sessionSeq++}`;
const items = new Map<string, Record<string, any>>();
// Snapshots per artifact id, oldest first — the version tools read history from here.
const history = new Map<string, Array<Record<string, any>>>();
// How a preview-tab fixture names the artifact its tab shows.
const seededIds = new Map<string, string>();
let seq = 0;
for (const entry of seed) {
// Derived, not minted: the tools that must not touch the plan recognise it by this id, so
// an id of the harness's own would pass a case the real gate refuses. The counter advances
// either way, or seeding a plan would renumber the rows around it and collapse the update
// order they are sorted on.
const n = seq++;
const id = entry.role === "plan" ? planArtifactId(sessionId) : `eval-artifact-${n}`;
const current = entry.versions.at(-1);
if (!current) continue;
// A preview tab names the artifact it shows, so a shared name would open whichever
// one happened to be seeded last.
if (seededIds.has(entry.name)) {
throw new Error(
`Two seeded artifacts are named "${entry.name}" — a preview tab fixture could not tell them apart`,
);
}
seededIds.set(entry.name, id);
items.set(id, {
id,
sessionId,
chatId: "eval-chat",
kind: "md",
name: entry.name,
content: current.content,
role: entry.role,
approvedVersion: entry.approvedVersion,
createdAt: 0,
updatedAt: seq,
version: entry.versions.length,
});
history.set(
id,
entry.versions.map((v, i) => ({
key: `${id}:${i + 1}`,
artifactId: id,
version: i + 1,
name: entry.name,
content: v.content,
savedAt: i,
note: v.note,
})),
);
}
const snapshotOf = (
artifact: Record<string, any>,
version: number,
note?: string,
) => ({
key: `${artifact.id}:${version}`,
artifactId: artifact.id,
version,
name: artifact.name,
content: artifact.content,
savedAt: artifact.updatedAt,
note,
});
const store = {
create: async (sessionId: string, input: Record<string, any>) => {
// One plan per session, as SessionArtifactsStore enforces it — the tool refuses
// first, so reaching this means a case drove create_artifact past that message.
if (
input.role === "plan" &&
[...items.values()].some(
(a) => a.sessionId === sessionId && a.role === "plan",
)
) {
throw new Error(`Session ${sessionId} already has a plan document`);
}
const now = seq++;
const artifact = {
id:
input.role === "plan"
? planArtifactId(sessionId)
: `eval-artifact-${now}`,
sessionId,
chatId: input.chatId,
kind: input.kind ?? "md",
name: input.name,
content: input.content,
// The plan document is only distinguishable by these, both in the snapshot the
// judge reads and in what list_artifacts reports back to the model.
role: input.role,
approvedVersion: input.approvedVersion,
createdAt: now,
updatedAt: now,
version: 1,
};
items.set(artifact.id, artifact);
history.set(artifact.id, [snapshotOf(artifact, 1)]);
return artifact;
},
get: async (id: string) => items.get(id),
update: async (
id: string,
input: Record<string, any>,
opts?: { sessionId?: string },
) => {
const existing = items.get(id);
if (!existing) return undefined;
if (
opts?.sessionId !== undefined &&
existing.sessionId !== opts.sessionId
)
return undefined;
// Only a content change earns a version, as in SessionArtifactsStore.
const contentChanged =
input.content !== undefined && input.content !== existing.content;
const version = (existing.version ?? 1) + (contentChanged ? 1 : 0);
const updated = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
// Carried only onto a version this write produced, as SessionArtifactsStore does:
// a rename cannot promote a proposal the user turned down.
approvedVersion:
input.approvedVersion ??
(input.keepApproved &&
existing.approvedVersion !== undefined &&
contentChanged
? version
: existing.approvedVersion),
updatedAt: seq++,
version,
};
items.set(id, updated);
if (contentChanged) {
history.set(id, [
...(history.get(id) ?? []),
snapshotOf(updated, version, input.note),
]);
}
return updated;
},
remove: async (id: string) => {
items.delete(id);
history.delete(id);
},
listForSession: async (sessionId: string) =>
[...items.values()].filter((a) => a.sessionId === sessionId),
listVersions: async (id: string) =>
[...(history.get(id) ?? [])].sort((a, b) => b.version - a.version),
getVersion: async (id: string, version: number) =>
(history.get(id) ?? []).find((v) => v.version === version),
};
return {
helpers: {
artifacts: store,
sessionId,
getChatId: () => "eval-chat",
openArtifact: (_id: string, _name: string) => {},
},
sessionId,
seededIds,
snapshot: () => [...items.values()],
};
}
@@ -0,0 +1,188 @@
import {
setClosePreviewTabsHandler,
setGetPreviewStatusHandler,
setOpenPagePreviewHandler,
setOpenPreviewHandler,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
import type { GlobalActivePreviewContext } from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
import {
describePreview,
previewTargetForSessionTarget,
selectPreviewTabsToClose,
SessionPreviewTabs,
whereIs,
} from "../../../../../frontend/src/lib/components/sessions/sessionPreviewTabs.svelte";
import {
previewLocationContext,
previewLocationLabel,
promptSafe,
resolvePreviewTab,
} from "../../../../../frontend/src/lib/components/sessions/previewRouter";
import type { ArtifactVersionTarget } from "../../../../../frontend/src/lib/components/sessions/previewRouter";
import type { SessionTarget } from "../../../../../frontend/src/lib/components/sessions/sessionState.svelte";
// The side panel a session chat talks to, driven by the production tab model rather than
// by canned tool results — so a case measures what the real open_preview / get_preview_status
// / close_page report about the tabs the reader has. sessionRuntime.svelte.ts (the production
// owner of these handlers) can't run here: it reaches for IndexedDB, stores and live editors.
export interface EvalPreviewTabFixture {
/** Artifact tab, named by the artifact fixture it shows. `version` pins it, as a reader does. */
artifact?: { name: string; version?: number };
/** Workspace page tab, e.g. `{ href: "/runs", label: "Runs" }`. */
page?: { href: string; label: string };
/** Editor tab for a workspace item. */
item?: { kind: SessionTarget["kind"]; path: string };
/** Tab the reader is looking at. Defaults to the last seeded one. */
active?: boolean;
}
// Registered once for the whole process, as production does at module load, and dispatched
// by session id: global cases run concurrently, so a per-run registration would have every
// case answering out of whichever run registered last.
const panels = new Map<string, SessionPreviewTabs>();
const NO_SESSION = "No active session; the preview panel is unavailable.";
function panelFor(sessionId: string | undefined): SessionPreviewTabs | undefined {
return sessionId ? panels.get(sessionId) : undefined;
}
setGetPreviewStatusHandler((sessionId) => {
const owner = panelFor(sessionId);
if (!owner) return NO_SESSION;
return describePreview(owner.tabs, owner.activeId, !!owner.displayedTab);
});
setOpenPreviewHandler(async ({ sessionId, kind, path }) => {
const owner = panelFor(sessionId);
if (!owner) return "Error: no active session to open the preview in.";
const target = previewTargetForSessionTarget(kind, path);
if (!target) {
return `Error: ${kind} targets cannot be shown in the preview panel.`;
}
// The pipeline branch of the production handler waits on an editor that only exists once
// a canvas mounts, which never happens here — a pipeline preview reports as any other.
const result = owner.open(target);
return result.status === "focused"
? `A preview tab is already showing ${kind} "${path}" — focused it.`
: `Opened ${kind} preview for ${path} in a new tab in the side panel.`;
});
setOpenPagePreviewHandler(({ sessionId, href, label, newTab }) => {
const owner = panelFor(sessionId);
if (!owner) return undefined;
const result = owner.open({ type: "page", href, label }, { forceNewTab: newTab });
if (result.status === "focused") {
return `A preview tab is already showing ${label} — focused it.`;
}
if (result.status === "retargeted") {
return `Updated the ${label} preview tab with the requested view.`;
}
return `Opened ${label} in a new preview tab in the side panel.`;
});
setClosePreviewTabsHandler(({ sessionId, all, match }) => {
const owner = panelFor(sessionId);
if (!owner) return NO_SESSION;
if (owner.tabs.length === 0) return "The preview panel has no open tabs.";
const labelFor = (t: (typeof owner.tabs)[number]) =>
promptSafe(previewLocationLabel(whereIs(t)));
const doomed = selectPreviewTabsToClose(owner.tabs, { all, match });
if (doomed.length === 0) {
return `No open tab matched "${match}". Open tabs: ${owner.tabs.map(labelFor).join(", ")}.`;
}
const closedLabels = doomed.map(labelFor);
for (const t of doomed) owner.close(t.id);
return `Closed ${closedLabels.length} preview tab${closedLabels.length === 1 ? "" : "s"} (${closedLabels.join(", ")}).`;
});
export interface EvalPreviewPanel {
/** Mirrors production: a written artifact is shown in the panel. `version` carries the
* caller's intent for the version picker — `latest` drops a pin the reader had set. */
openArtifact: (id: string, name: string, version?: ArtifactVersionTarget) => void;
/** What the user message stamps as ACTIVE PREVIEW, as sessionRuntime's resolver reads it. */
activePreview: () => GlobalActivePreviewContext | undefined;
dispose: () => void;
}
export function createEvalPreviewPanel(input: {
sessionId: string;
tabs: EvalPreviewTabFixture[];
/** Artifact ids by name, from the artifact fixture seeding. */
artifactIds: Map<string, string>;
}): EvalPreviewPanel {
// Nothing durable to write back to, and no debounce worth waiting on.
const owner = new SessionPreviewTabs(
{ tabs: [], activeId: "", collapsed: false },
{ persist: () => {} },
0,
);
// Opening a tab makes it the active one, so the fixture's pick can only be applied once
// every tab is seeded — selecting inside the loop would lose to the next open.
let requestedActive: string | undefined;
for (const fixture of input.tabs) {
const opened = seedTab(owner, fixture, input.artifactIds);
if (opened && fixture.active) requestedActive = opened;
}
if (requestedActive) owner.select(requestedActive);
// Registered last: seeding throws on a malformed fixture, and this map outlives the run.
panels.set(input.sessionId, owner);
return {
openArtifact: (id, name, version) => {
owner.open({ type: "artifact", id, name, version });
},
activePreview: () => {
const tab = owner.displayedTab;
if (!tab) return undefined;
// Artifact and editor tabs are not iframes: they carry no page location, and an
// artifact's pinned version reaches the chat only through get_preview_status.
if (resolvePreviewTab(tab.url).kind !== "iframe") return undefined;
return previewLocationContext(whereIs(tab));
},
dispose: () => {
panels.delete(input.sessionId);
},
};
}
// Seeds one tab through the production open path and returns its id, so a fixture cannot
// describe a tab the panel could not have reached on its own.
function seedTab(
owner: SessionPreviewTabs,
fixture: EvalPreviewTabFixture,
artifactIds: Map<string, string>,
): string | undefined {
// A tab shows one destination; the branches below would silently keep the first.
const named = [fixture.artifact, fixture.page, fixture.item].filter(Boolean);
if (named.length > 1) {
throw new Error(
"Preview tab fixture sets more than one of artifact, page and item — a tab shows one of them",
);
}
if (fixture.artifact) {
const id = artifactIds.get(fixture.artifact.name);
if (!id) {
throw new Error(
`Preview tab fixture references artifact "${fixture.artifact.name}", which no artifact fixture seeds`,
);
}
owner.open({ type: "artifact", id, name: fixture.artifact.name });
// A pin is the reader's own pick in the version picker, never a side effect of opening.
if (fixture.artifact.version !== undefined) {
owner.pinArtifactVersion(id, fixture.artifact.version);
}
} else if (fixture.page) {
owner.open({ type: "page", href: fixture.page.href, label: fixture.page.label });
} else if (fixture.item) {
const target = previewTargetForSessionTarget(fixture.item.kind, fixture.item.path);
if (!target) {
throw new Error(`Preview tab fixture has an unpreviewable item kind: ${fixture.item.kind}`);
}
owner.open(target);
} else {
throw new Error("Preview tab fixture must set one of artifact, page or item");
}
return owner.activeId;
}
@@ -0,0 +1,37 @@
import { expect, it, vi } from 'vitest'
// The panel pulls in the global tool module, which reaches the editor stack it never uses here.
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', () => ({}))
const { createEvalPreviewPanel } = await import('./evalPreviewTabs')
// Every open makes its own tab active, so a fixture's `active` flag only means anything if
// it survives the tabs seeded after it. Lose that and a case still runs — against a panel
// state its author never described.
it('keeps the tab a fixture marks active, not the last one seeded', () => {
const panel = createEvalPreviewPanel({
sessionId: 'eval-preview-tabs-unit-test',
tabs: [
{ page: { href: '/runs', label: 'Runs' }, active: true },
{ artifact: { name: 'Onboarding plan' } }
],
artifactIds: new Map([['Onboarding plan', 'eval-artifact-0']])
})
try {
expect(panel.activePreview()?.location).toBe('/runs')
} finally {
panel.dispose()
}
})
@@ -1,6 +1,7 @@
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
import type { AIProvider } from "$lib/gen/types.gen";
import { providerSupportsWebSearch } from "../../../../../frontend/src/lib/components/copilot/lib";
import {
@@ -13,8 +14,19 @@ import {
getGlobalDraft,
listGlobalDrafts,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
import { appendPlanModeInstructions } from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { createEvalPlanTools } from "./planModeTools";
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
import {
createEvalArtifactHelpers,
type SeededArtifact,
} from "./evalArtifactStore";
import {
createEvalPreviewPanel,
type EvalPreviewPanel,
type EvalPreviewTabFixture,
} from "./evalPreviewTabs";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
@@ -44,67 +56,6 @@ const LIVE_EDITOR_ITEM_KINDS = {
app: "raw_app",
} as const;
// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes),
// so mirror only its tool-facing shape; its own logic (scoping, race guard) is unit-tested.
const EVAL_SESSION_ID = "eval-session";
function createEvalArtifactHelpers() {
const items = new Map<string, Record<string, unknown>>();
let seq = 0;
const store = {
create: async (sessionId: string, input: Record<string, any>) => {
const now = seq++;
const artifact = {
id: `eval-artifact-${now}`,
sessionId,
chatId: input.chatId,
kind: input.kind ?? "md",
name: input.name,
content: input.content,
createdAt: now,
updatedAt: now,
};
items.set(artifact.id, artifact);
return artifact;
},
get: async (id: string) => items.get(id),
update: async (
id: string,
input: Record<string, any>,
opts?: { sessionId?: string },
) => {
const existing = items.get(id);
if (!existing) return undefined;
if (
opts?.sessionId !== undefined &&
existing.sessionId !== opts.sessionId
)
return undefined;
const updated = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: seq++,
};
items.set(id, updated);
return updated;
},
remove: async (id: string) => {
items.delete(id);
},
listForSession: async (sessionId: string) =>
[...items.values()].filter((a) => a.sessionId === sessionId),
};
return {
helpers: {
artifacts: store,
sessionId: EVAL_SESSION_ID,
getChatId: () => "eval-chat",
openArtifact: () => {},
},
snapshot: () => [...items.values()],
};
}
export interface GlobalLiveEditorDraftFixture {
type: keyof typeof LIVE_EDITOR_ITEM_KINDS;
storagePath?: string;
@@ -126,11 +77,35 @@ export interface GlobalUserFixture {
folders_read?: string[];
}
/**
* Flatten every assistant turn's text. Tool calls are excluded — only what the
* user would actually read counts as having been said to them.
*/
function assistantTextOf(messages: ChatCompletionMessageParam[]): string {
const parts: string[] = [];
for (const message of messages) {
if (message.role !== "assistant") continue;
const content = message.content;
if (typeof content === "string") {
parts.push(content);
} else if (Array.isArray(content)) {
for (const part of content) {
if (part && typeof part === "object" && "text" in part) {
parts.push(String((part as { text?: unknown }).text ?? ""));
}
}
}
}
return parts.join("\n");
}
export interface GlobalEvalResult {
success: boolean;
state: GlobalDraftState;
error?: string;
assistantMessageCount: number;
/** Everything the assistant said to the user, for `assistantExpect` checks. */
assistantText: string;
toolCallCount: number;
toolsUsed: string[];
toolCallDetails: ToolCallDetail[];
@@ -144,11 +119,18 @@ export interface GlobalEvalOptions {
user?: GlobalUserFixture;
// Emulate a session chat (preview tools + session prompt); default false = standalone baseline.
sessionChat?: boolean;
// Start in plan mode: the gate refuses every tool without `planModeSafe`, and the two plan
// tools are offered. Needs sessionChat, which is what plan mode is gated on in production.
planMode?: boolean;
model?: string;
maxIterations?: number;
provider?: AIProvider;
backend: WindmillBackendSettings;
workspaceRoot?: string;
// Artifacts the session already holds when the run starts.
artifacts?: SeededArtifact[];
/** Tabs already open in the side panel, including any artifact version the reader pinned. */
previewTabs?: EvalPreviewTabFixture[];
runContext?: ModeRunContext;
}
@@ -167,31 +149,71 @@ export async function runGlobalEval(
options.workspaceFixtures ?? {},
);
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
// Declared out here only so `finally` can reach it; a malformed fixture throws while
// building it, and everything seeded above still has to be torn down.
let panel: EvalPreviewPanel | undefined;
try {
const evalArtifacts = createEvalArtifactHelpers(options.artifacts);
// Only a session chat has a side panel, so only it gets one here. Seeded tabs would
// otherwise vanish without a word, and the case would measure an empty panel.
if (!options.sessionChat && options.previewTabs?.length) {
throw new Error(
"This fixture seeds previewTabs, which only a session chat has — set runtime.sessionChat: true on the case.",
);
}
if (options.sessionChat) {
panel = createEvalPreviewPanel({
sessionId: evalArtifacts.sessionId,
tabs: options.previewTabs ?? [],
artifactIds: evalArtifacts.seededIds,
});
}
const model = options.model ?? "claude-haiku-4-5-20251001";
const injectActiveEditorContext =
process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1";
const planMode = options.planMode
? createEvalPlanTools({
create: evalArtifacts.helpers.artifacts.create,
sessionId: evalArtifacts.helpers.sessionId,
chatId: evalArtifacts.helpers.getChatId(),
})
: undefined;
// Pass the seeded identity straight to the prompt builder rather than mutating
// the process-global `userStore`, so concurrent cases never race on it.
const evalArtifacts = createEvalArtifactHelpers();
const baseSystemMessage = prepareGlobalSystemMessage(undefined, {
user: options.user,
previewTools: options.sessionChat ?? false,
// Mirrors runChatLoop's gate. The production default reads the copilot model
// store, which the harness leaves empty, so it would hide guidance every
// benchmarked provider actually serves.
webSearch: providerSupportsWebSearch(options.provider),
});
const rawResult = await runEval({
userPrompt,
systemMessage: prepareGlobalSystemMessage(undefined, {
user: options.user,
previewTools: options.sessionChat ?? false,
// Mirrors runChatLoop's gate. The production default reads the copilot model
// store, which the harness leaves empty, so it would hide guidance every
// benchmarked provider actually serves.
webSearch: providerSupportsWebSearch(options.provider),
systemMessage: baseSystemMessage,
// Re-derived per request, as production's getter is: the instructions have to leave
// the prompt when the plan is approved, or the model is still told it may not build
// while the gate has already opened.
getSystemMessage: planMode
? () =>
planMode.isPlanModeActive()
? appendPlanModeInstructions(baseSystemMessage, 0)
: baseSystemMessage
: undefined,
isPlanModeActive: planMode?.isPlanModeActive,
isToolAvailable: planMode?.isToolAvailable,
userMessage: prepareGlobalUserMessage(userPrompt, [], {
...(injectActiveEditorContext ? { workspace: workspaceRoot } : {}),
activePreview: panel?.activePreview(),
}),
userMessage: prepareGlobalUserMessage(
userPrompt,
[],
injectActiveEditorContext ? { workspace: workspaceRoot } : {},
),
tools: getGlobalEvalTools(options.sessionChat ?? false),
helpers: evalArtifacts.helpers,
tools: [
...getGlobalEvalTools(options.sessionChat ?? false),
...(planMode?.tools ?? []),
],
helpers: panel
? { ...evalArtifacts.helpers, openArtifact: panel.openArtifact }
: evalArtifacts.helpers,
apiKey,
getOutput: async () => ({
...(await collectGlobalDraftState(workspaceRoot)),
@@ -217,6 +239,7 @@ export async function runGlobalEval(
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
assistantText: assistantTextOf(rawResult.messages),
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
toolCallDetails: rawResult.toolCallDetails,
@@ -224,6 +247,7 @@ export async function runGlobalEval(
finalContextTokens: rawResult.finalContextTokens,
};
} finally {
panel?.dispose();
clearGlobalDrafts(workspaceRoot);
clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
@@ -0,0 +1,69 @@
import {
EXIT_PLAN_MODE_TOOL,
EXIT_PLAN_MODE_TOOL_DESCRIPTION,
derivePlanTitle,
exitPlanModeArgs,
planSummaryOf,
} from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
import { PLAN_MODE_MESSAGES } from "../../../../../frontend/src/lib/components/copilot/chat/planModeMessages";
import { createToolDef } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
/**
* `exit_plan_mode` built from the production schema, description and messages, so a case
* exercises the real gate and wording with the posture living here rather than on the
* manager. It resolves immediately — the runners define no `requestConfirmation`, so the
* plan is always approved and a refused one cannot be expressed.
*/
export function createEvalPlanTools(artifacts: {
create: (
sessionId: string,
input: Record<string, unknown>,
) => Promise<{ id: string; name: string }>;
sessionId: string;
chatId: string;
}): {
tools: ProductionTool<{}>[];
isPlanModeActive: () => boolean;
isToolAvailable: (name: string) => boolean;
} {
let planActive = true;
return {
isPlanModeActive: () => planActive,
// Withdrawn on approval, as production's tool getter does it: leaving it advertised
// invites a second hand-over of a plan already agreed, which would write a duplicate.
// Production would offer enter_plan_mode in its place; these cases stop at the first
// hand-over, so a fresh planning round belongs to a case of its own.
isToolAvailable: (name) => name !== EXIT_PLAN_MODE_TOOL || planActive,
// Production offers one plan tool at a time and these cases start in plan mode, so
// enter_plan_mode would only invite a turn spent entering a posture already held.
tools: [
{
def: createToolDef(
exitPlanModeArgs,
EXIT_PLAN_MODE_TOOL,
EXIT_PLAN_MODE_TOOL_DESCRIPTION,
),
// Carries the safety tag for the same reason production does: it is the only way out
// of the posture, so the gate must not refuse it.
planModeSafe: true,
fn: async ({ args }) => {
const summary = planSummaryOf(args);
if (!summary?.trim()) {
return PLAN_MODE_MESSAGES.missingSummary;
}
planActive = false;
await artifacts.create(artifacts.sessionId, {
name: derivePlanTitle(summary),
content: summary,
kind: "md",
role: "plan",
approvedVersion: 1,
chatId: artifacts.chatId,
});
return PLAN_MODE_MESSAGES.approvedWithDoc;
},
},
] as ProductionTool<{}>[],
};
}
@@ -43,6 +43,15 @@ export interface RunEvalParams<THelpers, TOutput> {
getOutput: () => TOutput | Promise<TOutput>;
/** Model and Windmill backend configuration */
options: EvalRunnerOptions;
/** Drives the production plan-mode gate in processToolCall. Absent leaves it inert,
* which is what every mode but an opted-in global case wants. */
isPlanModeActive?: () => boolean;
/** Which of `tools` the model is offered on this request. Absent offers all of them. */
isToolAvailable?: (name: string) => boolean;
/** Re-read before every request, as production's systemMessage getter is. Needed when a
* tool changes what the prompt should say — plan mode's instructions have to come back
* out once the plan is approved. Falls back to the fixed `systemMessage`. */
getSystemMessage?: () => ChatCompletionSystemMessageParam;
onAssistantMessageStart?: () => void;
onAssistantToken?: (token: string) => void;
onAssistantMessageEnd?: () => void;
@@ -68,6 +77,9 @@ export async function runEval<THelpers, TOutput>(
onAssistantToken,
onAssistantMessageEnd,
onToolCall,
isPlanModeActive,
isToolAvailable,
getSystemMessage,
} = params;
let shouldEmitMessageStart = true;
@@ -119,6 +131,7 @@ export async function runEval<THelpers, TOutput>(
} = {
setToolStatus: () => {},
removeToolStatus: () => {},
isPlanModeActive,
onNewToken: (token: string) => {
if (shouldEmitMessageStart) {
onAssistantMessageStart?.();
@@ -140,8 +153,17 @@ export async function runEval<THelpers, TOutput>(
try {
const result = await runChatLoop({
messages,
systemMessage,
tools: wrappedTools,
get systemMessage() {
return getSystemMessage?.() ?? systemMessage;
},
// Re-derived per request, as `systemMessage` is: a tool the posture has withdrawn
// must leave the schema too, or the model keeps being offered a call the run has
// moved past — and the token counts a case reports include a tool it cannot use.
get tools() {
return isToolAvailable
? wrappedTools.filter((t) => isToolAvailable(t.def.function.name))
: wrappedTools;
},
helpers,
abortController,
callbacks,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
import { fileURLToPath } from 'node:url'
import frontendConfig from '../../../frontend/vite.config.js'
// Harness unit tests that reach into the frontend module graph. They can't run under
// `bun test` (Svelte runes and the SvelteKit aliases both need this build), so they are
// named `*.vitest.ts` — bun's `*.test.ts` sweep skips them and this config claims them.
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 UNIT_TESTS = fileURLToPath(new URL('./**/*.vitest.ts', import.meta.url))
const config = {
...frontendConfig,
test: {
...frontendConfig.test,
projects: [
{
extends: FRONTEND_VITE_CONFIG_PATH,
test: {
name: 'server',
environment: 'node',
include: [UNIT_TESTS],
setupFiles: [FRONTEND_TEST_SETUP_PATH]
}
}
]
}
}
export default config
@@ -9,6 +9,15 @@ import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend'
// no meaning in the vitest environment — serve the ones the benchmark handles.
// Every other relative fetch keeps its normal behavior (it fails the same way
// it does without this stub) so unrelated tools see an unchanged environment.
// The frontend builds API URLs from location.origin (fetchAvailableModels does), and node has no
// location — without one those calls throw before the stub below ever sees them.
if (typeof (globalThis as { location?: unknown }).location === 'undefined') {
Object.defineProperty(globalThis, 'location', {
value: new URL('http://benchmark.local/'),
configurable: true
})
}
const ORIGINAL_FETCH = globalThis.fetch
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = typeof input === 'string' ? input : ((input as Request | URL | null)?.url ?? '')
@@ -57,13 +66,18 @@ vi.mock('$lib/gen', async () => {
getBenchmarkOwnDraft,
getBenchmarkScriptByHash,
getBenchmarkScriptByPath,
getBenchmarkAiConfig,
getBenchmarkResourceValue,
getBenchmarkVariableByPath,
hasBenchmarkWorkspace,
listBenchmarkAiProviderResources,
listBenchmarkApps,
listBenchmarkDatatables,
listBenchmarkDrafts,
listBenchmarkFlows,
listBenchmarkJobs,
listBenchmarkScripts,
listBenchmarkVariables,
createBenchmarkFolder,
createBenchmarkHttpTrigger,
createBenchmarkSchedule,
@@ -299,6 +313,10 @@ vi.mock('$lib/gen', async () => {
: actual.JobService.getJobLogs(data)
}),
WorkspaceService: wrapService(actual.WorkspaceService, {
getCopilotInfo: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (getBenchmarkAiConfig(data.workspace) ?? {})
: actual.WorkspaceService.getCopilotInfo(data),
listDataTableTables: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkDatatables(data.workspace) ?? [])
@@ -338,15 +356,34 @@ vi.mock('$lib/gen', async () => {
}),
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),
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkResourceValue(data.workspace, data.path))
: actual.ResourceService.existsResource(data),
// Only AI provider resources are modelled: they are what an AI agent step references.
listResource: async (data: { workspace: string; resourceType?: string }) => {
if (!hasBenchmarkWorkspace(data.workspace)) {
return actual.ResourceService.listResource(data)
}
const seeded = listBenchmarkAiProviderResources(data.workspace) ?? []
const wanted = data.resourceType?.split(',')
return wanted ? seeded.filter((r) => wanted.includes(r.resource_type)) : seeded
},
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)
},
getResourceValue: async (data: { workspace: string; path: string }) => {
if (!hasBenchmarkWorkspace(data.workspace)) {
return actual.ResourceService.getResourceValue(data)
}
const value = getBenchmarkResourceValue(data.workspace, data.path)
if (!value) {
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
}
return value
},
queryResourceTypes: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
}),
@@ -358,12 +395,28 @@ vi.mock('$lib/gen', async () => {
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data),
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkVariableByPath(data.workspace, data.path))
: actual.VariableService.existsVariable(data),
listVariable: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data),
getVariable: async (data: { workspace: string; path: string }) => {
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkVariables(data.workspace) ?? [])
: actual.VariableService.listVariable(data),
getVariable: async (data: {
workspace: string
path: string
decryptSecret?: boolean
}) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Variable "${data.path}" not found in benchmark workspace`)
const variable = getBenchmarkVariableByPath(
data.workspace,
data.path,
data.decryptSecret ?? true
)
if (!variable) {
throw new Error(`Variable "${data.path}" not found in benchmark workspace`)
}
return variable
}
return actual.VariableService.getVariable(data)
}
+29
View File
@@ -324,3 +324,32 @@
- 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
# GIT-967, app mode: asked to track a long-running job, the agent hand-wrote a
# runnable that fetched the jobs REST API — guessing at WM_TOKEN and a base URL
# until it fell back to localhost — instead of using backendAsync + getJob/waitJob,
# which the generated ./wmill bindings already provide.
- id: app-long-job-progress
prompt: |-
Add a "Generate report" button. Building the report takes a few minutes, so as soon
as the user clicks it the app should show the run's job id and keep updating its
status until it finishes, then display the result.
runtime:
maxTurns: 22
validate:
forbiddenAppContent:
- BASE_INTERNAL_URL
- WM_BASE_URL
- WM_TOKEN
- localhost:8000
- getResultMaybe
- jobs/list
- getWorkspaceToken
- getBaseUrl
judgeChecklist:
- adds a Generate report button that starts the report
- shows the run's job id as soon as the run starts
- keeps the status updating while the run is in flight and shows the result when it completes
- starts the run with backendAsync and tracks it with getJob, waitJob or streamJob — all three are real exports of the generated ./wmill module, alongside backend and backendAsync
- does not write a backend runnable that polls job status or lists jobs itself
- does not call the Windmill API with fetch from the frontend, and does not read WM_TOKEN, BASE_INTERNAL_URL or WM_BASE_URL anywhere
+387
View File
@@ -1116,9 +1116,71 @@
- preselects only the created script on the review page
- does not deploy or delete anything
- id: global-openpage8-runs-label-and-worker
prompt: |-
Show me the runs carrying the label nightly-digest that ran on the worker wk-eval-1.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
# One page carrying both filters — two pages each carrying one is not the ask.
toolCallArgsSameCall:
- tool: open_page
args:
- field: page
stringIncludesAnyOf:
- runs
- field: label
stringIncludesAnyOf:
- nightly-digest
- field: worker
stringIncludesAnyOf:
- wk-eval-1
skipJudge: true
judgeChecklist:
- opens the Runs page filtered to the nightly-digest label on worker wk-eval-1
- does not write, deploy, or delete anything
# Exclusion is the filter shape most easily lost in translation: the page encodes it as
# a `!`-prefixed value, and a model that only knows the positive form silently opens a
# page showing exactly what the user asked to hide.
- id: global-openpage9-runs-exclude-schedules
prompt: |-
Open the runs page but hide everything a schedule kicked off — I only care about the rest.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgsSameCall:
- tool: open_page
args:
- field: page
stringIncludesAnyOf:
- runs
- field: job_trigger_kind
stringIncludesAnyOf:
- '!schedule'
skipJudge: true
judgeChecklist:
- opens the Runs page with schedule-triggered jobs excluded
- does not write, deploy, or delete anything
- id: global-closepage1-close-runs-tab
prompt: |-
You just opened the runs page for me in the side panel. Close that tab, I'm done looking at it.
initial: ai_evals/fixtures/frontend/global/initial/preview_runs_tab.json
runtime:
maxTurns: 6
sessionChat: true
@@ -1140,6 +1202,64 @@
- closes the runs preview tab in the side panel
- does not write, deploy, or delete anything
# --- Artifact version history ---
# Every content change to an artifact is snapshotted, and update_artifact requires a
# change_note that the user reads in the version picker. A blank note makes the history
# unreadable, so pin that the model fills it on every edit.
- id: global-artifact-note-on-each-edit
prompt: |-
Write up a short rollout plan for me as a doc I can come back to, covering a staged
rollout in three phases. Then add a rollback section to it, and after that tighten
the wording of phase 2.
runtime:
maxTurns: 12
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- create_artifact
- update_artifact
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
# The note is what the version picker shows; a blank one makes history unreadable.
- tool: update_artifact
field: change_note
nonEmpty: true
skipJudge: true
judgeChecklist:
- creates one artifact and revises it rather than creating a second artifact
- each revision carries a short description of what changed
# A reader who pins an older version in the artifact's picker is looking at something the
# artifact tools never report: an artifact tab carries no ACTIVE PREVIEW section, so the pin
# reaches the chat through get_preview_status alone. Asked what is on screen, the model has
# to read the panel instead of answering from the artifact's own history.
- id: global-artifact-pinned-version-question
prompt: |-
Which version of the onboarding plan am I looking at right now?
initial: ai_evals/fixtures/frontend/global/initial/artifact_onboarding_plan_pinned_v2.json
runtime:
maxTurns: 6
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- get_preview_status
forbiddenToolsUsed:
- create_artifact
- update_artifact
- deploy_workspace_item
skipJudge: true
judgeChecklist:
- answers that the panel is showing version 2, not the latest version 5
- does not edit or re-create the artifact
# --- Documentation search (search_docs) ---
# Pure product-knowledge questions: the assistant should consult the docs via
# search_docs and answer conversationally, not draft or mutate anything. No
@@ -1663,8 +1783,55 @@
judgeChecklist:
- saves the plan as a markdown artifact via create_artifact rather than only replying inline
- the artifact content has a title, a one-line summary, and three or four bullet steps for onboarding
- the artifact is registered as the session's plan (role "plan"), not as an ordinary note - the user asked for the plan they will come back to and revise
- does not create a flow or script draft yet
- id: global-planmode1-hands-over-a-plan
prompt: |-
Our support inbox is a mess. I want incoming emails triaged by urgency and routed to the
right team, with anything urgent also posted to Slack.
Work out how you'd build this in Windmill.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 10
sessionChat: true
planMode: true
# No draft assertion: approving the plan opens the gate mid-run, and building from there is
# what production asks for, so a draft is not a failure. The gate itself is covered by
# shared.test.ts; what only a real model can show is whether it researches and hands over a
# usable plan instead of guessing at one.
toolExpect:
requiredToolsUsed:
- exit_plan_mode
# Not "saves the plan as an artifact": exit_plan_mode writes it, so the harness would
# satisfy that on every run the tool is called at all — it grades itself, not the model.
judgeChecklist:
- the plan covers classifying an incoming email by urgency, routing it to a team, and posting urgent ones to Slack
- the plan is specific about what would be built in Windmill (a flow and its steps, or the scripts involved)
- id: global-planmode2-sketches-while-planning
prompt: |-
We're moving our nightly CSV export off a schedule and onto a webhook the vendor calls
when their file is ready. Work out how you'd rebuild it in Windmill — and draw me the
shape of it before you write anything, I find that easier to react to than prose.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 10
sessionChat: true
planMode: true
# Both tools, because either alone is a different behaviour: create_artifact without
# exit_plan_mode means the model filed the plan as the drawing, and exit_plan_mode without
# create_artifact means the posture blocked the drawing it was asked for.
toolExpect:
requiredToolsUsed:
- create_artifact
- exit_plan_mode
judgeChecklist:
- saves a diagram of the proposed design as an artifact rather than only describing it in chat
- the diagram covers the webhook that starts the run and the steps that replace the nightly schedule
- hands the plan over with exit_plan_mode instead of leaving it in the artifact
- does not register the diagram as the session's plan document
- id: global-npm1-script-search-package
prompt: |-
Find a good npm package for parsing RSS/Atom feeds and use it to create a draft Bun script
@@ -2149,3 +2316,223 @@
- the request authenticates with a bearer token built from that resource
- the script posts an annotation rather than reading data back
- the result stays an AI draft and is not deployed
# The value of a secret variable is unreadable, so a metadata-only edit must leave
# it untouched: passing any `value` here means inventing one, which silently
# replaces the real secret at deploy.
- id: global-secret-variable-description-only-edit
prompt: |-
The variable `f/evals/global/stripe_api_token` has a confusing description.
Change it to say it is the Stripe key used by the nightly billing sync, and leave
everything else about the variable alone. Keep it as an AI draft; do not deploy it.
initial: ai_evals/fixtures/frontend/global/initial/secret_api_token_variable.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
path: f/evals/global/stripe_api_token
valueIncludes:
# "billing" is already in the seeded description and labels, so it would pass
# without any edit; "nightly" can only come from the new description.
- nightly
- '"is_secret": true'
valueExcludes:
# The draft must not carry an invented value, a self-reference, or the
# real secret it was never shown.
- '$var:'
- sk_live_do_not_leak_me
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
# Restating `is_secret: true` is fine; supplying a value is not.
- tool: write_variable
field: value
fieldMustBeAbsent: true
judgeChecklist:
- updates the description of f/evals/global/stripe_api_token to mention Stripe and the nightly billing sync
- keeps the variable secret
- does not invent, guess, or restate a value for the variable
# A secret draft stores "" when it stages no new value, and this case must not stage
# one — the judge has to be told, or it reads the "" as the value having been cleared.
- 'the draft''s empty value string is expected and correct: a secret variable draft stores "" when no new value is staged, which is exactly right for a description-only edit, so it does NOT mean the value was cleared or changed'
- leaves the result as an AI draft and does not deploy it
# Reproduces GIT-967: a hello-world flow with a React app in front of it. The agent
# built a backend runnable that fetched the Windmill REST API directly, guessing at
# WM_TOKEN / BASE_INTERNAL_URL / WM_BASE_URL until it fell back to localhost:8000, and
# wired the app to a flow that was never deployed. Both are pinned here.
- id: global-app-triggers-flow
prompt: |-
Create a hello world workflow at `f/evals/global/hello_world_flow` that just returns a
greeting, and a React app at `f/evals/global/hello_world_app` with a button that runs
that workflow and shows the result on the page.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 24
validate:
draftCountAtLeast: 2
requiredDrafts:
- type: flow
path: f/evals/global/hello_world_flow
- type: app
path: f/evals/global/hello_world_app
valueIncludes:
# The frontend must go through the generated bindings, which is the only
# credentialed way it can reach anything server-side.
- './wmill'
valueExcludes:
# Every artifact of hand-rolling HTTP against the Windmill API from a
# runnable instead of using the client or a path runnable.
- BASE_INTERNAL_URL
- WM_BASE_URL
- WM_TOKEN
- localhost:8000
- getResultMaybe
- jobs/list
- getWorkspaceToken
- getBaseUrl
toolExpect:
requiredToolsUsed:
- write_flow
- init_app
- write_app_runnable
judgeChecklist:
- creates a flow at f/evals/global/hello_world_flow that returns a greeting
- creates a React raw app at f/evals/global/hello_world_app with a button that runs the flow
- the app actually invokes the flow rather than reimplementing its logic in an inline runnable
# Both items staying drafts is the CORRECT outcome — the chat must not deploy without
# being asked. What is judged is that the flow is named as the one item needing a deploy.
- 'leaving both the flow and the app as drafts is expected and correct: the chat deploys nothing unless asked. Judge only whether the flow is identified as the single item that will need deploying, and that the app is NOT presented as needing deployment to be tried'
# The judge has repeatedly flagged a correct app as broken over this.
- '`/wmill.d.ts` is generated by Windmill from the app''s runnables and is deliberately absent from the app''s files — its absence is correct and is NOT a missing-module bug'
- the app reaches the flow through a backend runnable, not through hand-written HTTP calls to the Windmill API
- the app's frontend calls the runnable via the generated ./wmill bindings — backend, or backendAsync together with waitJob/getJob/streamJob, all of which are real exports of that module
- does not read WM_TOKEN, BASE_INTERNAL_URL or WM_BASE_URL, and does not construct a Windmill API URL anywhere
- does not call windmill-client functions that do not exist, such as getBaseUrl or getWorkspaceToken
# The point of the case: a path runnable (and wmill.runFlow*) resolves the deployed
# item, so a flow left as a draft makes the app dead on arrival and the user has to be
# told. This can't live in judgeChecklist — the global judge only ever sees the drafts,
# never what the assistant said.
assistantExpect:
# Plain substring test: an alternative must name the FLOW and read as an outstanding
# obligation. Flow-agnostic wording is satisfied by "the app must be deployed"; tense-neutral
# wording by a deploy the agent only claims to have made. The mirror expectation "don't ask
# to deploy the app" can't be a forbiddenMentions entry, since correct answers negate it.
requiredMentionsAnyOf:
- - deploy the flow
- deploy that flow
- deploy this flow
- deploy just the flow
- deploy the workflow
- deploy that workflow
- deploy hello_world_flow
- flow must be deployed
- flow needs to be deployed
- flow has to be deployed
- flow needs deploying
- flow will need to be deployed
- flow will have to be deployed
- once the flow is deployed
- until the flow is deployed
- workflow must be deployed
- workflow needs to be deployed
- workflow has to be deployed
# --- AI agent steps: the provider config must name a model the referenced resource serves ---
# The workspace AI settings are not a runtime gate, so the model id can only come from the
# resources themselves. Both cases seed AI provider resources; without them the assistant has
# nothing to write but a guessed id, which is the defect these pin.
- id: global-ai-agent-step-uses-workspace-model
prompt: |-
Create a draft flow at `f/evals/global/support_answer` with a single AI agent step that answers
the user's question. The question comes in as a flow input called `query`. No tools.
Leave it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/ai_provider_anthropic.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/support_answer
valueIncludes:
- aiagent
- $res:f/evals/global/anthropic_main
- claude-sonnet-5
toolExpect:
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a draft flow at f/evals/global/support_answer with one AI agent step
- the step's provider is an object carrying kind, resource and model, not a bare resource string
- the provider references the workspace's own AI provider resource f/evals/global/anthropic_main
- the model is one the workspace configured for that resource, not an id invented from memory
- the user's question reaches the agent from the query flow input
- the result stays as an AI draft and is not deployed
- id: global-ai-agent-step-asks-which-provider
prompt: |-
Create a draft flow at `f/evals/global/triage_ticket` with a single AI agent step that summarises
an incoming ticket. The ticket text comes in as a flow input called `ticket`. No tools.
Leave it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/ai_providers_two.json
runtime:
maxTurns: 10
toolExpect:
requiredToolsUsed:
- askUserQuestion
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
# Two configured providers make the choice the user's: the step may only be written once the
# question is answered, and it must use one of the workspace's own resources. Which one depends
# on the answer, so the assertion covers the shared path prefix rather than a fixed resource.
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/triage_ticket
valueIncludes:
- aiagent
- $res:f/evals/global/
# Whether the assistant asked before writing lives in the tool record, not in the draft the
# global judge sees.
skipJudge: true
# Only satisfiable from the resource's own model listing: the workspace AI settings configure just
# claude-sonnet-5, so an assistant working off the settings has no Opus id to write.
- id: global-ai-agent-step-uses-listed-model
prompt: |-
Create a draft flow at `f/evals/global/deep_review` with a single AI agent step that reviews a
pull request diff. The diff comes in as a flow input called `diff`. Use the Opus model — this
one needs the strongest model available. No tools.
Leave it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/ai_provider_anthropic.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/deep_review
valueIncludes:
- aiagent
- $res:f/evals/global/anthropic_main
- claude-opus-5
toolExpect:
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a draft flow at f/evals/global/deep_review with one AI agent step
- the step uses the workspace's anthropic resource f/evals/global/anthropic_main
- the model is the Opus one the user asked for, taken from the models that resource serves
- the diff flow input reaches the agent
+2
View File
@@ -21,6 +21,7 @@ interface RawEvalCase {
validate?: EvalValidationSpec;
toolExpect?: EvalCase["toolExpect"];
cliExpect?: CliValidationSpec;
assistantExpect?: EvalCase["assistantExpect"];
judgeChecklist?: string[];
skipJudge?: boolean;
runtime?: EvalCaseRuntimeSpec;
@@ -50,6 +51,7 @@ export async function loadCases(mode: EvalMode): Promise<EvalCase[]> {
validate: entry.validate,
toolExpect: entry.toolExpect,
cliExpect: entry.cliExpect,
assistantExpect: entry.assistantExpect,
judgeChecklist: entry.judgeChecklist,
skipJudge: entry.skipJudge,
runtime: entry.runtime,
+8 -1
View File
@@ -7,7 +7,10 @@ import type {
FrontendBenchmarkProgressEvent,
ModeRunner,
} from "./types";
import { validateToolExpectations } from "./validators";
import {
validateAssistantExpectations,
validateToolExpectations,
} from "./validators";
export async function runSuite<TInitial, TExpected, TActual>(input: {
modeRunner: ModeRunner<TInitial, TExpected, TActual>;
@@ -182,6 +185,10 @@ async function runCaseAttempts<TInitial, TExpected, TActual>(input: {
...validateToolExpectations({
run,
toolExpect: input.evalCase.toolExpect,
}),
...validateAssistantExpectations({
run,
assistantExpect: input.evalCase.assistantExpect,
})
);
}
+48
View File
@@ -33,6 +33,9 @@ export interface EvalCaseRuntimeSpec {
appContext?: EvalCaseRuntimeAppContextSpec;
// Global mode: run as a session chat (preview tools + session prompt) vs the standalone chat.
sessionChat?: boolean;
// Global session chats: start the case in plan mode, so the workspace-changing tools are
// refused until the model hands over a plan with exit_plan_mode.
planMode?: boolean;
}
export interface FlowValidationSpec {
@@ -166,6 +169,29 @@ export interface ToolCallArgumentRule {
* tool — e.g. SQL where a mutation is mixed with verification SELECTs.
*/
stringIncludesAnyOf?: string[];
/**
* Universal over calls: every recorded call to `tool` must carry `field` as a
* non-blank string. Use for a required argument whose value is free text, where
* the point is that the model filled it in at all rather than what it said.
*/
nonEmpty?: boolean;
/**
* Universal over calls: no recorded call to `tool` may pass `field` at all.
* For partial-update tools, where supplying a field the model could not have
* read is itself the failure — e.g. `write_variable.value` on a secret.
*/
fieldMustBeAbsent?: boolean;
}
/**
* Several field constraints that must hold on the *same* call, where separate
* calls each satisfying one of them would not be the requested behavior — e.g.
* opening one Runs page filtered by both a label and a worker, rather than two
* pages each carrying one filter.
*/
export interface ToolCallSameCallRule {
tool: string;
args: { field: string; stringIncludesAnyOf: string[] }[];
}
export interface ToolValidationSpec {
@@ -179,6 +205,7 @@ export interface ToolValidationSpec {
requiredToolsAnyOf?: string[][];
forbiddenToolsUsed?: string[];
toolCallArgs?: ToolCallArgumentRule[];
toolCallArgsSameCall?: ToolCallSameCallRule[];
}
export type EvalValidationSpec =
@@ -186,6 +213,24 @@ export type EvalValidationSpec =
| AppValidationSpec
| GlobalValidationSpec;
/**
* Expectations on what the assistant SAID, for cases where the deliverable is
* partly a warning to the user. The `global` judge only ever sees the resulting
* drafts, so "tells the user X" is invisible to it and has to be checked here.
* Needs a mode whose runner reports `assistantText`.
*/
export interface AssistantValidationSpec {
/** Each entry: at least one of its phrases appears somewhere in the assistant's text. */
requiredMentionsAnyOf?: string[][];
/**
* Plain case-insensitive substring test, so it cannot see negation: a phrase the correct
* answer might use in the negative ("you don't need to deploy the app") is not a valid
* entry. Use it for tokens that never legitimately appear, and leave nuanced "did the
* assistant say the right thing" expectations to the judge checklist.
*/
forbiddenMentions?: string[];
}
export interface EvalCase {
id: string;
prompt: string;
@@ -194,6 +239,7 @@ export interface EvalCase {
validate?: EvalValidationSpec;
toolExpect?: ToolValidationSpec;
cliExpect?: CliValidationSpec;
assistantExpect?: AssistantValidationSpec;
judgeChecklist?: string[];
skipJudge?: boolean;
runtime?: EvalCaseRuntimeSpec;
@@ -260,6 +306,8 @@ export interface ModeRunOutput<TActual> {
toolsUsed: string[];
toolCallDetails?: ToolCallDetail[];
skillsInvoked: string[];
/** Concatenated assistant-visible text of the run, when the mode reports it. */
assistantText?: string;
tokenUsage?: BenchmarkTokenUsage | null;
/**
* Total input tokens occupying the context window on the LAST model request
+235
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from "bun:test";
import { loadCases } from "./cases";
import {
validateAppState,
validateCliWorkspace,
validateGlobalState,
validateScriptState,
validateAssistantExpectations,
validateToolExpectations,
} from "./validators";
@@ -41,6 +43,113 @@ describe("validateScriptState", () => {
});
});
describe("validateAssistantExpectations", () => {
it("checks assistant mentions across the whole run, not just the last turn", () => {
const run = {
success: true,
actual: {},
assistantMessageCount: 2,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
assistantText: "Wired the app up.\nThe flow HAS TO BE DEPLOYED before the app works.",
};
const checks = validateAssistantExpectations({
run,
assistantExpect: {
requiredMentionsAnyOf: [["must be deployed", "has to be deployed"], ["never said"]],
forbiddenMentions: ["WM_TOKEN"],
},
});
expect(checks.map((c) => c.passed)).toEqual([true, false, true]);
});
it("rejects a deploy claim that names the app instead of the flow", () => {
const checks = validateAssistantExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
assistantText: "Built both. The app must be deployed before the button works.",
},
assistantExpect: {
requiredMentionsAnyOf: [["deploy the flow", "flow must be deployed"]],
},
});
expect(checks.map((c) => c.passed)).toEqual([false]);
});
it("fails instead of passing green when the mode reports no assistant text", () => {
const checks = validateAssistantExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
},
assistantExpect: { forbiddenMentions: ["WM_TOKEN"] },
});
expect(checks.map((c) => c.passed)).toEqual([false]);
});
});
// The matcher is a plain substring test, so its failure mode is accepting an answer it should
// reject. The real alternatives are therefore exercised against wrong answers rather than
// eyeballed, and read out of global.yaml so an edit there cannot silently loosen them.
describe("global-app-triggers-flow deploy expectation", () => {
const run = (assistantText: string) => ({
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
assistantText,
});
const passes = async (assistantText: string) => {
const cases = await loadCases("global");
const target = cases.find((c) => c.id === "global-app-triggers-flow");
if (!target?.assistantExpect) throw new Error("case or its assistantExpect is missing");
const checks = validateAssistantExpectations({
run: run(assistantText),
assistantExpect: target.assistantExpect,
});
return checks.every((c) => c.passed);
};
// Deploying is impossible in eval mode and the judge only sees drafts, so a claim of
// having deployed is a hallucination this case has to reject, not evidence of success.
it.each([
["names the app as what needs deploying", "Built both. The app must be deployed before the button works."],
["claims the deploy is already done", "All set — done deploying the flow, everything works now."],
["claims it deployed the flow itself", "I deployed the flow for you, so the button works."],
["reports a completed deploy after the fact", "After deploying the flow, I clicked the button and it returns the greeting."],
["reports a completed deploy instrumentally", "I fixed it by deploying the flow; everything works now."],
["says nothing about deploying", "Built the flow and the app. The button calls the flow."],
])("rejects an answer that %s", async (_label, text) => {
expect(await passes(text)).toBe(false);
});
it.each([
["you'll need to deploy the flow before the app's button will work"],
["the flow has to be deployed first; the app can stay a draft"],
["once the flow is deployed, the button will work in the preview"],
["want me to deploy just the flow? the app stays a draft"],
])("accepts a correct answer: %s", async (text) => {
expect(await passes(text)).toBe(true);
});
});
describe("validateToolExpectations", () => {
it("accepts Windmill-prefixed schedule paths", () => {
const checks = validateToolExpectations({
@@ -119,6 +228,57 @@ describe("validateToolExpectations", () => {
});
});
// The whole point of the same-call rule: the per-field rules are existential over
// calls, so two single-filter pages would satisfy them while never opening the
// combined view the case asks for.
it("requires the listed fields on one and the same call", () => {
const splitCalls = {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 2,
toolsUsed: ["open_page"],
toolCallDetails: [
{ name: "open_page", arguments: { page: "runs", label: "nightly-digest" } },
{ name: "open_page", arguments: { page: "runs", worker: "wk-eval-1" } },
],
skillsInvoked: [],
};
const sameCallRule = {
toolCallArgsSameCall: [
{
tool: "open_page",
args: [
{ field: "label", stringIncludesAnyOf: ["nightly-digest"] },
{ field: "worker", stringIncludesAnyOf: ["wk-eval-1"] },
],
},
],
};
expect(
validateToolExpectations({ run: splitCalls, toolExpect: sameCallRule }).every(
(check) => check.passed
)
).toBe(false);
expect(
validateToolExpectations({
run: {
...splitCalls,
toolCallCount: 1,
toolCallDetails: [
{
name: "open_page",
arguments: { page: "runs", label: "nightly-digest", worker: "wk-eval-1" },
},
],
},
toolExpect: sameCallRule,
}).every((check) => check.passed)
).toBe(true);
});
it("rejects forbidden tool usage", () => {
const checks = validateToolExpectations({
run: {
@@ -174,6 +334,52 @@ describe("validateToolExpectations", () => {
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails nonEmpty when any call left the field blank", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 2,
toolsUsed: ["update_artifact"],
toolCallDetails: [
{ name: "update_artifact", arguments: { change_note: "Added a rollback section" } },
// A whitespace-only note is as unreadable in the picker as a missing one.
{ name: "update_artifact", arguments: { change_note: " " } },
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [{ tool: "update_artifact", field: "change_note", nonEmpty: true }],
},
});
const nonEmptyCheck = checks.find((c) => c.name.includes("is filled in on every call"));
expect(nonEmptyCheck?.passed).toBe(false);
expect(nonEmptyCheck?.details).toContain("blank on 1 of 2");
});
it("passes nonEmpty when every call filled the field", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["update_artifact"],
toolCallDetails: [
{ name: "update_artifact", arguments: { change_note: "Tightened phase 2" } },
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [{ tool: "update_artifact", field: "change_note", nonEmpty: true }],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts a stringIncludesAnyOf substring inside an array-valued field", () => {
const checks = validateToolExpectations({
run: {
@@ -280,6 +486,35 @@ describe("validateToolExpectations", () => {
});
});
// Absence has to mean absence: a partial-update tool is only proven correct if the
// field was never passed, and an explicit null IS passing it.
it("fieldMustBeAbsent accepts an omitted field and rejects a supplied or null one", () => {
const run = (args: Record<string, unknown>) =>
validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["write_variable"],
toolCallDetails: [{ name: "write_variable", arguments: args }],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [
{ tool: "write_variable", field: "value", fieldMustBeAbsent: true },
],
},
});
const absent = (checks: Array<{ name: string; passed: boolean }>) =>
checks.find((check) => check.name === "write_variable.value is not supplied")
?.passed;
expect(absent(run({ path: "u/a/b", description: "only metadata" }))).toBe(true);
expect(absent(run({ path: "u/a/b", value: "****" }))).toBe(false);
expect(absent(run({ path: "u/a/b", value: null }))).toBe(false);
});
it("passes requiredToolsAnyOf when any alternative in the group is used", () => {
const checks = validateToolExpectations({
run: {
+116 -10
View File
@@ -2,6 +2,7 @@ import path from "node:path";
import ts from "typescript";
import type {
AppValidationSpec,
AssistantValidationSpec,
BenchmarkCheck,
CliTrace,
CliValidationSpec,
@@ -147,6 +148,65 @@ export function validateFlowState(input: {
return checks;
}
// Array-valued fields (e.g. open_page.items) match on any element.
function valueIncludesAnyOf(value: unknown, lowercaseNeedles: string[]): boolean {
const haystacks =
typeof value === "string"
? [value]
: Array.isArray(value)
? value.filter((v): v is string => typeof v === "string")
: [];
return haystacks.some((hay) =>
lowercaseNeedles.some((needle) => hay.toLowerCase().includes(needle))
);
}
export function validateAssistantExpectations(input: {
run: ModeRunOutput<unknown>;
assistantExpect?: AssistantValidationSpec;
}): BenchmarkCheck[] {
const expect = input.assistantExpect;
if (!expect) {
return [];
}
// Only some mode runners report assistantText. Defaulting a missing one to "" would pass
// every forbiddenMentions entry forever, so a case that expects to inspect what the
// assistant said fails on the mode that cannot show it.
if (input.run.assistantText === undefined) {
return [
check(
"assistant text is available to check",
false,
"this mode's runner does not report assistantText, so assistantExpect cannot be evaluated"
),
];
}
const text = input.run.assistantText;
const checks: BenchmarkCheck[] = [];
for (const phrases of expect.requiredMentionsAnyOf ?? []) {
checks.push(
check(
`assistant mentions one of: ${phrases.join(" / ")}`,
phrases.some((phrase) => assistantMentions(text, phrase)),
truncateForDetails(text)
)
);
}
for (const phrase of expect.forbiddenMentions ?? []) {
checks.push(
check(
`assistant does not mention '${phrase}'`,
!assistantMentions(text, phrase),
truncateForDetails(text)
)
);
}
return checks;
}
export function validateToolExpectations(input: {
run: ModeRunOutput<unknown>;
toolExpect?: ToolValidationSpec;
@@ -233,22 +293,39 @@ export function validateToolExpectations(input: {
);
}
if (rule.nonEmpty) {
const blankValues = values.filter(
(value) => typeof value !== "string" || value.trim().length === 0
);
checks.push(
check(
`${rule.tool}.${rule.field} is filled in on every call`,
blankValues.length === 0,
`blank on ${blankValues.length} of ${values.length} call(s); values: ${summarizeToolValues(values)}`
)
);
}
if (rule.fieldMustBeAbsent) {
// Anything other than `undefined` was supplied — an explicit `null` is the
// model passing the field, not omitting it.
const suppliedValues = values.filter((value) => value !== undefined);
checks.push(
check(
`${rule.tool}.${rule.field} is not supplied`,
suppliedValues.length === 0,
`values: ${summarizeToolValues(values)}`
)
);
}
if (rule.stringIncludesAnyOf && rule.stringIncludesAnyOf.length > 0) {
// Existential: at least one call must contain one of the substrings.
// Other calls to the same tool may do anything — this suits SQL, where a
// model mixes the requested statement (e.g. an UPDATE) with verification
// SELECTs that would otherwise fail an "all calls" check.
const needles = rule.stringIncludesAnyOf.map((needle) => needle.toLowerCase());
// Array-valued fields (e.g. open_page.items) match on any element.
const haystacks = (value: unknown): string[] =>
typeof value === "string"
? [value]
: Array.isArray(value)
? value.filter((v): v is string => typeof v === "string")
: [];
const hasMatch = values.some((value) =>
haystacks(value).some((hay) => needles.some((needle) => hay.toLowerCase().includes(needle)))
);
const hasMatch = values.some((value) => valueIncludesAnyOf(value, needles));
checks.push(
check(
`${rule.tool}.${rule.field} includes a required substring`,
@@ -259,6 +336,35 @@ export function validateToolExpectations(input: {
}
}
for (const rule of expect.toolCallArgsSameCall ?? []) {
const fields = rule.args.map((arg) => arg.field).join(" + ");
const matchingCall = toolCallDetails.find(
(call) =>
call.name === rule.tool &&
rule.args.every((arg) =>
valueIncludesAnyOf(
getToolArgumentValue(call.arguments, arg.field),
arg.stringIncludesAnyOf.map((needle) => needle.toLowerCase())
)
)
);
checks.push(
check(
`one ${rule.tool} call carries ${fields} together`,
matchingCall !== undefined,
toolCallDetails
.filter((call) => call.name === rule.tool)
.map(
(call) =>
`{${rule.args
.map((arg) => `${arg.field}=${summarizeToolValues([getToolArgumentValue(call.arguments, arg.field)])}`)
.join(", ")}}`
)
.join(" | ") || `no ${rule.tool} calls`
)
);
}
return checks;
}
@@ -0,0 +1,13 @@
{
"workspace": {
"aiProviders": [
{
"path": "f/evals/global/anthropic_main",
"kind": "anthropic",
"models": ["claude-sonnet-5", "claude-opus-5", "claude-haiku-4-5"],
"configuredModels": ["claude-sonnet-5"],
"isDefault": true
}
]
}
}
@@ -0,0 +1,19 @@
{
"workspace": {
"aiProviders": [
{
"path": "f/evals/global/anthropic_main",
"kind": "anthropic",
"models": ["claude-sonnet-5", "claude-opus-5", "claude-haiku-4-5"],
"configuredModels": ["claude-sonnet-5"],
"isDefault": true
},
{
"path": "f/evals/global/openai_main",
"kind": "openai",
"models": ["gpt-5.6-sol", "gpt-5.6-terra"],
"configuredModels": ["gpt-5.6-sol"]
}
]
}
}
@@ -0,0 +1,38 @@
{
"user": {
"username": "admin",
"is_admin": true
},
"artifacts": [
{
"name": "Onboarding plan",
"versions": [
{
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Create the customer record\n- Send the welcome email\n"
},
{
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record\n- Send the welcome email\n",
"note": "Added domain verification"
},
{
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record\n- Send the welcome email\n- Schedule the 7-day check-in\n",
"note": "Added the 7-day check-in"
},
{
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record in the CRM\n- Send the welcome email\n- Schedule the 7-day check-in\n",
"note": "Named the CRM as the record store"
},
{
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record in the CRM\n- Send the welcome email\n- Schedule the 7-day check-in\n- Hand over to the account manager\n",
"note": "Added the account-manager handover"
}
]
}
],
"previewTabs": [
{
"artifact": { "name": "Onboarding plan", "version": 2 },
"active": true
}
]
}
@@ -0,0 +1,12 @@
{
"user": {
"username": "admin",
"is_admin": true
},
"previewTabs": [
{
"page": { "href": "/runs", "label": "Runs" },
"active": true
}
]
}
@@ -0,0 +1,13 @@
{
"workspace": {
"variables": [
{
"path": "f/evals/global/stripe_api_token",
"value": "sk_live_do_not_leak_me",
"is_secret": true,
"description": "Token used by the billing sync job",
"labels": ["billing"]
}
]
}
}
+24 -4
View File
@@ -6,9 +6,15 @@ import {
type GlobalLiveEditorDraftFixture,
type GlobalUserFixture,
} from "../adapters/frontend/core/global/globalEvalRunner";
import type { SeededArtifact } from "../adapters/frontend/core/global/evalArtifactStore";
import type { EvalPreviewTabFixture } from "../adapters/frontend/core/global/evalPreviewTabs";
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
import type { FrontendEvalModelConfig } from "../core/models";
import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
import type {
BenchmarkArtifactFile,
GlobalValidationSpec,
ModeRunner,
} from "../core/types";
import { validateGlobalState, type GlobalDraftState } from "../core/validators";
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
import { getFrontendApiKey } from "./frontendCommon";
@@ -17,6 +23,8 @@ export interface GlobalInitialFixture {
workspace?: BenchmarkWorkspaceRunnables;
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
user?: GlobalUserFixture;
artifacts?: SeededArtifact[];
previewTabs?: EvalPreviewTabFixture[];
}
export function createGlobalModeRunner(
@@ -41,7 +49,10 @@ export function createGlobalModeRunner(
workspaceFixtures: initial?.workspace,
liveEditorDrafts: initial?.liveEditorDrafts,
user: initial?.user,
artifacts: initial?.artifacts,
previewTabs: initial?.previewTabs,
sessionChat: context.evalCase?.runtime?.sessionChat,
planMode: context.evalCase?.runtime?.planMode,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
@@ -59,6 +70,7 @@ export function createGlobalModeRunner(
toolsUsed: result.toolsUsed,
toolCallDetails: result.toolCallDetails,
skillsInvoked: [],
assistantText: result.assistantText,
tokenUsage: result.tokenUsage,
finalContextTokens: result.finalContextTokens,
};
@@ -81,7 +93,9 @@ export function createGlobalModeRunner(
};
}
async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixture> {
async function loadGlobalInitialFixture(
path: string,
): Promise<GlobalInitialFixture> {
if ((await stat(path)).isDirectory()) {
const { initialFrontend, initialBackend, initialDatatables } =
await loadAppFixtureForEval(path);
@@ -104,14 +118,20 @@ async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixt
};
}
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
const parsed = JSON.parse(
await readFile(path, "utf8"),
) as GlobalInitialFixture;
return {
workspace: parsed.workspace ?? {},
liveEditorDrafts: parsed.liveEditorDrafts ?? [],
user: parsed.user,
artifacts: parsed.artifacts,
previewTabs: parsed.previewTabs ?? [],
};
}
async function loadGlobalExpectedFixture(path: string): Promise<GlobalDraftState> {
async function loadGlobalExpectedFixture(
path: string,
): Promise<GlobalDraftState> {
return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState;
}
+2 -1
View File
@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"cli": "bun cli/index.ts",
"typecheck": "tsc -p tsconfig.json"
"typecheck": "tsc -p tsconfig.json",
"test:frontend-graph": "cd ../frontend && node_modules/.bin/vitest run --project server --config ../ai_evals/adapters/frontend/vitest.unit.config.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.25",
+3 -1
View File
@@ -14,6 +14,8 @@
],
"exclude": [
"./**/*.test.ts",
"./adapters/frontend/vitest.config.ts"
"./**/*.vitest.ts",
"./adapters/frontend/vitest.config.ts",
"./adapters/frontend/vitest.unit.config.ts"
]
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE eval_experiment\n SET subject = jsonb_set(\n jsonb_set(subject, '{kind}', '\"agent\"'),\n '{version}', to_jsonb($4::bigint))\n WHERE workspace_id = $1 AND dataset_path = $2 AND id = $3\n AND subject ->> 'kind' = 'agent_draft'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Uuid",
"Int8"
]
},
"nullable": []
},
"hash": "01bce88dd622f314d1a09c24cd12df5e7d3ff6a15a93e1c0c95e262f7b3d0ef1"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, value, version FROM resource_version WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "version",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "0276e6030abb2eb00a68c568a9cc60f3e7c2af0331388c4b358035de865a121a"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT coalesce(max(run_number), 0) + 1 FROM eval_experiment\n WHERE workspace_id = $1 AND dataset_path = $2 AND subject ->> 'path' = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "0335de6713de6678b9bf266121af23abc46d5db95da095bb15726c5a2db7ad2f"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "WITH v AS (\n INSERT INTO app_version (app_id, value, created_by)\n VALUES ($1, '{}'::json, 'test-user') RETURNING id\n )\n UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT login_type, COUNT(*) FROM password GROUP BY login_type",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "login_type",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
null
]
},
"hash": "08e4a2dc49c75aa356f3cc75a4abd8fc61409776d641ddb592a4c731e61a0468"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE eval_dataset SET scorers = COALESCE((\n SELECT jsonb_agg(\n CASE WHEN elem->>'path' LIKE ('u/' || $2 || '/%')\n THEN jsonb_set(elem, '{path}', to_jsonb(REGEXP_REPLACE(elem->>'path', 'u/' || $2 || '/(.*)', $1 || '/\\1')))\n ELSE elem END)\n FROM jsonb_array_elements(scorers) elem), '[]'::jsonb)\n WHERE workspace_id = $3\n AND EXISTS (SELECT 1 FROM jsonb_array_elements(scorers) e WHERE e->>'path' LIKE ('u/' || $2 || '/%'))",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0aae275d9196e742b5783df4e67c72459d45e275bfeafa2952349cae259ac9f0"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12 WHERE worker = $6",
"query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6",
"describe": {
"columns": [],
"parameters": {
@@ -16,10 +16,11 @@
"Float4",
"Float4",
"Float4",
"Bool"
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "a41c4cbaffdb714e4a963557de5a4011744d684eb24e03cb4beae6a512613159"
"hash": "0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_advisory_xact_lock(hashtext('ai_eval_open:' || $1 || '/' || $2 || '/' || $3))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_advisory_xact_lock",
"type_info": "Void"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "0d6700ccffb8179e365bbc1f03398e474f23b013f642ba29ad6f68e6f047c1e5"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE job_tree AS (\n SELECT id, tag FROM v2_job WHERE id = $2 AND workspace_id = $1\n UNION\n SELECT j.id, j.tag FROM v2_job j JOIN job_tree t ON j.parent_job = t.id\n WHERE j.workspace_id = $1\n )\n SELECT\n a.path,\n a.kind AS \"kind!: windmill_common::assets::AssetKind\",\n -- Several jobs of the tree touch one asset, each recording its own\n -- access. A job that recorded none contributes nothing rather than\n -- erasing a sibling's, so an all-null group is the only unknown one.\n -- Grouping here, not in Rust, is what makes LIMIT count assets: the\n -- retention keeps up to ten job rows per asset.\n COALESCE(bool_or(a.usage_access_type IN ('r', 'rw')), false) AS \"any_read!\",\n COALESCE(bool_or(a.usage_access_type IN ('w', 'rw')), false) AS \"any_write!\"\n FROM asset a JOIN job_tree t ON a.usage_path = t.id::text\n WHERE a.workspace_id = $1 AND a.usage_kind = 'job'\n AND ($3::text[] IS NULL OR t.tag = ANY($3))\n GROUP BY a.path, a.kind\n ORDER BY a.path, a.kind\n LIMIT $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "kind!: windmill_common::assets::AssetKind",
"type_info": {
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume",
"dbt"
]
}
}
}
},
{
"ordinal": 2,
"name": "any_read!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "any_write!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"TextArray",
"Int8"
]
},
"nullable": [
false,
false,
null,
null
]
},
"hash": "0df1c23ac429b5807d42e1da5ee2e2176a8a774f107bece5804cc7b221365c89"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path)\n VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path')\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM resource WHERE workspace_id = $1 AND path = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "10eba371c513030f165a6c8d3a32f7e33828247ab8108ffe304d7548aab5a42d"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE eval_experiment SET subject = jsonb_set(subject, '{path}', to_jsonb(REGEXP_REPLACE(subject->>'path', 'u/' || $2 || '/(.*)', $1 || '/\\1'))) WHERE subject->>'path' LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1815730982dcaf7239ddcb22f88ae5c79794213cf6278167f8afdbca30b1b15c"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) AS \"count!\" FROM eval_experiment_case\n WHERE experiment_id = $1 AND status IS NOT NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "186c663249ffada82abf61ce214f52e2730501774a0ca4dc855380e6c6487917"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, summary, scorers, created_at, created_by,\n edited_at, edited_by\n FROM eval_dataset WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "scorers",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "edited_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true,
false,
false,
false,
false,
false
]
},
"hash": "196939257a334f7d37aa6d66153b251446d7701a893cb7852b81cf842c0fa228"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT case_id, ordinal FROM eval_experiment_case WHERE experiment_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "case_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "ordinal",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false
]
},
"hash": "1b6e229545f6b877e72d21728257d1bddaba15ef0fbe72bb4f43b45140f184ce"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT FROM native_trigger GROUP BY service_name\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e"
}
@@ -0,0 +1,247 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,\n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle,\n COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\"\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "runnable_id: ScriptHash",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 9,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "script_lang: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
}
},
{
"ordinal": 12,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
}
},
{
"ordinal": 15,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 16,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 17,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 18,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 19,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 21,
"name": "runnable_settings_handle",
"type_info": "Int8"
},
{
"ordinal": 22,
"name": "build_binary_only!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
true,
true,
true,
null
]
},
"hash": "1d9a3c21655738fbffe09388a29114cd5ecd3b61139784d5078138435ed44857"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT path FROM script\n WHERE workspace_id = $1 AND path = ANY($2)\n AND deleted = false AND lock IS NOT NULL AND lock_error_logs IS NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "1db80f3ba2c6c769a98424ebf9aaf168a4fa2c64e446a824038cf267236fe979"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT username, added_via\n FROM usr\n WHERE workspace_id = $1 AND email = $2\n AND added_via->>'source' = 'instance_group'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "added_via",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "2132a715995f2775917c59c01ba66f2e472a2347f25cc7109f36a805806ee6e2"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "instance_role",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "236028886d13526daa184f9e6d0a4b2ae43fbaf7f0bbbc8246c5e2d73b6f6aee"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE eval_score SET error = 'The case did not run'\n WHERE experiment_id = $1 AND ordinal = ANY($2)\n AND score IS NULL AND error IS NULL AND NOT not_applicable",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Int4Array"
]
},
"nullable": []
},
"hash": "242845c86084e010ab33c2197d44af9aeb181672a2f2330dbe65bfe586376450"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_token_usage (workspace_id, email, provider, model, session_id, input_tokens, cache_read_tokens, cache_write_tokens, output_tokens, reported_cost_nano_usd, requests)\n SELECT $1, $2, * FROM UNNEST($3::text[], $4::text[], $5::text[], $6::bigint[], $7::bigint[], $8::bigint[], $9::bigint[], $10::bigint[], $11::bigint[])\n ON CONFLICT (workspace_id, day, email, provider, model, session_id)\n DO UPDATE SET\n input_tokens = ai_token_usage.input_tokens + EXCLUDED.input_tokens,\n cache_read_tokens = ai_token_usage.cache_read_tokens + EXCLUDED.cache_read_tokens,\n cache_write_tokens = ai_token_usage.cache_write_tokens + EXCLUDED.cache_write_tokens,\n output_tokens = ai_token_usage.output_tokens + EXCLUDED.output_tokens,\n reported_cost_nano_usd = CASE\n WHEN EXCLUDED.reported_cost_nano_usd IS NULL\n THEN ai_token_usage.reported_cost_nano_usd\n ELSE COALESCE(ai_token_usage.reported_cost_nano_usd, 0)\n + EXCLUDED.reported_cost_nano_usd\n END,\n requests = ai_token_usage.requests + EXCLUDED.requests,\n updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"TextArray",
"TextArray",
"TextArray",
"Int8Array",
"Int8Array",
"Int8Array",
"Int8Array",
"Int8Array",
"Int8Array"
]
},
"nullable": []
},
"hash": "24fcc2b69f30953915f0cbf246e1c19b2310075a644d81c21784c991e52b4001"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM flow\n WHERE archived = false AND pg_column_size(value) >= $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
null
]
},
"hash": "255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68"
}
@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_workers = EXCLUDED.dedicated_workers, native_mode = EXCLUDED.native_mode",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"TextArray",
"Varchar",
"Varchar",
"TextArray",
"Varchar",
"Int8",
"Int8",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "298fa4f8eb05b4c3f33b608b0cdb6ed918af2df012de33acb3befd3fcccbc257"
}

Some files were not shown because too many files have changed in this diff Show More