mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-14 16:02:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00392ba548 |
@@ -1,63 +0,0 @@
|
||||
# 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.
|
||||
@@ -31,21 +31,6 @@ The `up.sql` usually defines:
|
||||
- trigger-specific fields
|
||||
- Indexes on foreign keys + any frequently-filtered columns
|
||||
- Foreign key to `workspace`
|
||||
- The RLS policies (`see_own`, `see_member`, `see_folder_extra_perms_user_*`, `see_extra_perms_user_*`, `see_extra_perms_groups_*`), copied from an existing trigger table
|
||||
|
||||
**RLS: wrap every session GUC read in a scalar sub-select.** Write the session
|
||||
reads as `(select current_setting('session.user'))`,
|
||||
`= any((select regexp_split_to_array(current_setting('session.groups'), ','))::text[])`,
|
||||
`?| (select regexp_split_to_array(current_setting('session.pgroups'), ','))::text[]`,
|
||||
`? (select concat('u/', current_setting('session.user')))`, etc. — not the bare
|
||||
`current_setting(...)`. The GUCs are set with `SET LOCAL`, so the sub-select
|
||||
hoists them to a one-time InitPlan instead of re-evaluating per scanned row.
|
||||
Put the `::text[]` cast **outside** the sub-select for the array cases: in an
|
||||
`= any (...)` context, casting inside — `= any((select ...::text[]))` — makes
|
||||
Postgres parse the operand as a row-returning subquery and fails at CREATE with
|
||||
`operator does not exist: text = text[]`. The outside cast keeps it in
|
||||
array-operand form. See migration `20260714230440_wrap_session_gucs_in_rls_policies`
|
||||
for the canonical wrapped forms.
|
||||
|
||||
Down migration drops the table and any enum types.
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: ai-chat
|
||||
description: Guidance for improving the Windmill AI chat (copilot), especially global mode — tools, prompts, and context-window discipline. Use when editing chat tools, system prompts, or tool-result shapes under frontend/src/lib/components/copilot/chat, or when changing how the chat manages its context window.
|
||||
---
|
||||
|
||||
## Always benchmark before and after
|
||||
|
||||
No context or behavior change ships without an `ai_evals` A/B on the affected mode.
|
||||
Add or adjust cases for exactly what you changed — see the `ai-evals` skill for
|
||||
authoring and the full run reference.
|
||||
|
||||
Run the affected mode **before** your change and **after**, same model(s), same cases.
|
||||
|
||||
## Measure the window first, and cumulative second
|
||||
|
||||
Optimize **`finalContextTokens`** (window occupancy — what drives overflow and
|
||||
compaction), then cumulative prompt tokens.
|
||||
|
||||
## Context discipline
|
||||
|
||||
The dominant fixed cost is per-iteration overhead: the system prompt **plus every
|
||||
tool schema** is re-sent on every loop iteration. So:
|
||||
|
||||
- **Every tool and every parameter is a permanent tax.** Justify each one and measure
|
||||
it; an extra "locate" round-trip can cost more than the reads it saves. Strip dead
|
||||
params rather than leaving them in the schema.
|
||||
- **Tool results return the minimum.** Never echo content the model already has. The
|
||||
canonical mistake: a write tool that returns the whole edited artifact right after
|
||||
the model authored it — return `{ success, message }` instead. When you touch a
|
||||
*shared* write helper (e.g. `finishAppDraftWrite` in `global/core.ts`), re-check
|
||||
this invariant for **all** the write tools routing through it — the echo has
|
||||
regressed before via a shared refactor.
|
||||
|
||||
## Prompts and tool descriptions are part of the surface
|
||||
|
||||
The system prompt and tool descriptions steer behavior as much as the tools
|
||||
themselves, and are benchmarkable the same way. A description that advertises
|
||||
truncation makes the model self-limit; the path-conventions block changes where
|
||||
drafts land. Treat prompt/description edits as real changes and A/B them — a
|
||||
pure-prompt change is a legitimate, measurable improvement.
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
name: ai-evals
|
||||
description: Author and run black-box benchmark cases for the Windmill AI generation modes (flow/app/script/cli/global) in ai_evals/. Use when adding or changing eval cases, or when running before/after benchmarks for AI chat / copilot changes.
|
||||
---
|
||||
|
||||
# AI evals — authoring and running benchmark cases
|
||||
|
||||
`ai_evals/` is a black-box benchmark runner for the Windmill AI generation modes:
|
||||
`flow`, `app`, `script`, `cli`, `global`. It always tests the **current** production
|
||||
prompts, tools, and guidance in this checkout. Each attempt runs the real production
|
||||
path, deterministic validation, then LLM judging.
|
||||
|
||||
The goal is to test current production guidance with realistic user requests — **not**
|
||||
to pin one exact implementation shape.
|
||||
|
||||
## Running benchmarks
|
||||
|
||||
```bash
|
||||
cd ai_evals
|
||||
bun install # first time; frontend modes also need `cd frontend && bun install`
|
||||
bun run cli -- models # list model aliases
|
||||
bun run cli -- cases global # list cases for a mode
|
||||
bun run cli -- run global global-test1-script-create --model sonnet
|
||||
```
|
||||
|
||||
Frontend modes (`flow`/`script`/`app`/`global`) route model calls through a Windmill
|
||||
backend's `/api/w/<ws>/ai/proxy`, so you need **any** reachable backend:
|
||||
|
||||
```bash
|
||||
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:<port> WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests \
|
||||
bun run cli -- run global <caseIds...> --models sonnet,gpt-5.5,gemini-3.1-pro-preview
|
||||
```
|
||||
|
||||
- **Reuse an existing workspace.** CE builds cap workspaces, so temp-workspace
|
||||
creation 400s ("reached workspace limit"). Always set
|
||||
`WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` (or any existing workspace) to
|
||||
reuse one. The only side effect of a run is upserting an `f/evals/ai/<provider>`
|
||||
resource there.
|
||||
- Provider keys live in `ai_evals/.env` and are auto-loaded by bun. The judge is a
|
||||
separate Anthropic call (default `claude-sonnet-4-6`) regardless of the model under
|
||||
test.
|
||||
|
||||
## Authoring core rules
|
||||
|
||||
1. Write prompts like a real user request.
|
||||
2. Prefer behavior, inputs, constraints, and outcomes over internal implementation.
|
||||
3. Keep deterministic validation narrow and hard.
|
||||
4. Put semantic expectations in `judgeChecklist`.
|
||||
5. Use `expected` fixtures only when exact structure really matters.
|
||||
|
||||
### Prompt writing
|
||||
|
||||
Prompts should sound like something a user would naturally ask. Do not write prompts
|
||||
as if the user knows Windmill internals unless the case explicitly tests a power-user
|
||||
workflow.
|
||||
|
||||
Good:
|
||||
- "Create a flow that routes support requests based on customer tier."
|
||||
- "Add a reset button that sets the counter back to 0."
|
||||
- "Create a flow that reuses the existing greeting script instead of duplicating the logic."
|
||||
|
||||
Bad:
|
||||
- "Use `branchone` with 3 branches and a default branch."
|
||||
- "Create a `rawscript` step with this exact topology."
|
||||
- "This is a benchmark harness."
|
||||
|
||||
### Deterministic validation
|
||||
|
||||
Use deterministic checks only for hard failures: missing required files; unexpected
|
||||
extra files when the prompt says not to create them; syntax errors; unresolved flow
|
||||
refs; missing required special modules or suspend config; obvious corruption.
|
||||
|
||||
Do **not** encode one preferred implementation. Bad hard checks: exact step topology
|
||||
for a creation flow; exact branch structure when the prompt only asked for routing;
|
||||
exact input shape when multiple reasonable shapes are acceptable.
|
||||
|
||||
### Judge checklist
|
||||
|
||||
Every non-trivial case should have a `judgeChecklist` capturing user-visible behavior
|
||||
that must be present, important constraints, and key completion criteria — not
|
||||
low-level implementation details unless truly required.
|
||||
|
||||
Good: "the flow calculates the order total with 8% tax"; "the flow reuses the existing
|
||||
workspace script instead of rewriting the logic". Bad: "uses `branchone`"; "contains a
|
||||
`rawscript` node".
|
||||
|
||||
See `ai_evals/README.md` for the full case format, fields, and fixture details.
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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 1–3 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.
|
||||
@@ -1,114 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,122 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
name: local-review-codex
|
||||
description: Run the CI Codex PR review locally against this branch's unpushed work (committed + uncommitted) before pushing. Same policy and reasoning effort as the codex-pr-review GitHub action, on a newer model.
|
||||
---
|
||||
|
||||
# Local Codex Review (pre-push)
|
||||
|
||||
Runs the exact same review Codex performs in CI (`.github/workflows/codex-pr-review.yml`),
|
||||
but locally and scoped to work you have not pushed yet — so you catch what CI would flag
|
||||
before the PR exists. Use this before `git push` on a non-trivial change.
|
||||
|
||||
**Correspondence with CI** — identical:
|
||||
- Policy: `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test coverage).
|
||||
- Reasoning effort: `model_reasoning_effort="xhigh"`.
|
||||
- Output: markdown starting with `## Codex Review`, findings tagged P0 / P1 / P2 with file:line.
|
||||
|
||||
**Differences from CI** — local-only:
|
||||
- Model is `gpt-6-astra`; CI stays on `gpt-5.6-sol`. Not an oversight to reconcile: `gpt-6-astra` is confirmed on the ChatGPT auth `codex login` uses locally, while CI authenticates with `OPENAI_API_KEY` (`codex-pr-review.yml` prefers it over `CODEX_AUTH_JSON`) and that tier is unverified for the model. Move CI once API access is confirmed, or once CI switches to `CODEX_AUTH_JSON`.
|
||||
- Scope is the current branch vs `main` at the merge-base, **including uncommitted changes** (CI reviews a pushed PR diff).
|
||||
- Sandbox is `read-only` (CI uses `danger-full-access` on an ephemeral runner). Codex reads the diff and files but cannot modify your working tree.
|
||||
- Fresh context is inherent: `codex exec` is a separate cold process, so it does not anchor on the current chat session — the same reason `local-review` insists on a subagent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `codex` CLI **>= 0.153.4** installed and authed via `codex login` (an `OPENAI_API_KEY` in the environment takes priority and may not reach `gpt-6-astra` — see the model note above). Older CLIs reject the model with "requires a newer version of Codex"; `run.sh` checks the version up front. Upgrade with `npm install --global @openai/codex@0.153.4` (may need `sudo` for a global install). This matches the pin in `.github/workflows/codex-pr-review.yml` — the CLI version is the same on both sides, only the model differs.
|
||||
- `git fetch` the base ref if it's stale, so the merge-base is accurate.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bash .agents/skills/local-review-codex/run.sh # review vs main (default)
|
||||
bash .agents/skills/local-review-codex/run.sh <base> # review vs a different base ref
|
||||
```
|
||||
|
||||
Invoke with `bash` (or run the executable directly) — the script needs Bash for
|
||||
`set -o pipefail`; `sh` is Dash on Debian/Ubuntu and would fail. If `main` isn't a
|
||||
local branch (e.g. a fresh single-branch checkout), the runner falls back to
|
||||
`origin/main` automatically.
|
||||
|
||||
The script computes `BASE_SHA = git merge-base HEAD <base>`, feeds Codex `REVIEW.md` plus a
|
||||
diff context pointing at `git diff <BASE_SHA>` (which folds in uncommitted edits), and prints
|
||||
the review. It writes only temp files — nothing lands in the working tree.
|
||||
|
||||
## Relaying the result
|
||||
|
||||
Print the Codex output verbatim. Do not re-summarize or filter it — the value of a cold Codex
|
||||
pass is surfacing what the current session would rationalize away. Then decide with the user
|
||||
whether to address findings before pushing.
|
||||
|
||||
For a Claude-native review instead, use `local-review` (branch-diff-reviewer subagent). This
|
||||
skill is the Codex counterpart; run both for independent perspectives.
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local Codex review — mirrors the .github/workflows/codex-pr-review.yml CI job,
|
||||
# but scoped to this branch's unpushed work (committed + uncommitted) so you can
|
||||
# review before pushing. Same policy (REVIEW.md) and reasoning effort (xhigh) as CI.
|
||||
#
|
||||
# The model deliberately differs from CI: gpt-6-astra is confirmed available on the
|
||||
# ChatGPT auth `codex login` uses here, but CI authenticates with OPENAI_API_KEY and
|
||||
# that tier is unverified for it, so codex-pr-review.yml stays on gpt-5.6-sol.
|
||||
#
|
||||
# Usage: run.sh [BASE_REF] (BASE_REF defaults to "main")
|
||||
set -euo pipefail
|
||||
|
||||
MODEL="gpt-6-astra"
|
||||
CODEX_MIN="0.153.4"
|
||||
|
||||
BASE_REF="${1:-main}"
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
echo "codex CLI not found. Install with: npm install --global @openai/codex@$CODEX_MIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Older CLIs reject the model with an error that never names the CLI version as the
|
||||
# cause, so check it up front rather than letting the exec fail opaquely. The `|| true`
|
||||
# keeps an unrecognised --version format from aborting under `set -e`: an unparseable
|
||||
# version means "cannot tell", which must fall through to the exec, not kill the review.
|
||||
CODEX_VER="$(codex --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
||||
if [ -n "$CODEX_VER" ] && [ "$(printf '%s\n%s\n' "$CODEX_MIN" "$CODEX_VER" | sort -V | head -1)" != "$CODEX_MIN" ]; then
|
||||
echo "codex $CODEX_VER is too old for $MODEL (need >= $CODEX_MIN). Upgrade with: npm install --global @openai/codex@$CODEX_MIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# codex prefers OPENAI_API_KEY over the ChatGPT credentials `codex login` stores, and
|
||||
# that tier is not confirmed for $MODEL — the resulting failure names the model, not the
|
||||
# auth that selected it.
|
||||
if [ -n "${OPENAI_API_KEY:-}" ]; then
|
||||
echo "warning: OPENAI_API_KEY is set and takes priority over 'codex login' credentials; $MODEL may be unavailable on that tier." >&2
|
||||
fi
|
||||
|
||||
# Resolve the base to a concrete commit, preferring a local ref but falling back to
|
||||
# the remote-tracking ref — checkouts (CI, single-branch clones) often have only
|
||||
# origin/main, not a local main.
|
||||
if git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then
|
||||
BASE_COMMITISH="$BASE_REF"
|
||||
elif git rev-parse --verify --quiet "origin/${BASE_REF}^{commit}" >/dev/null; then
|
||||
BASE_COMMITISH="origin/${BASE_REF}"
|
||||
else
|
||||
echo "Base ref '$BASE_REF' not found as '$BASE_REF' or 'origin/$BASE_REF'. Try: git fetch origin $BASE_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Diff from the merge-base so only this branch's changes are reviewed. Using the
|
||||
# base SHA with a single-ref `git diff` also folds in uncommitted working-tree edits,
|
||||
# but `git diff` never sees untracked files — those are gathered separately below so
|
||||
# brand-new files (a whole new module, a new skill dir) are not silently skipped.
|
||||
BASE_SHA="$(git merge-base HEAD "$BASE_COMMITISH")"
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
UNTRACKED="$(git ls-files --others --exclude-standard)"
|
||||
|
||||
if [ "$BASE_SHA" = "$HEAD_SHA" ] && git diff --quiet "$BASE_SHA" && [ -z "$UNTRACKED" ]; then
|
||||
echo "No changes vs $BASE_REF — nothing to review." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PROMPT="$(mktemp)"
|
||||
OUT="$(mktemp)"
|
||||
trap 'rm -f "$PROMPT" "$OUT"' EXIT
|
||||
|
||||
# REVIEW.md is the shared policy CI feeds Codex. Append the local output-format
|
||||
# and diff context inline (CI reads these from a generated context file; inlining
|
||||
# keeps the working tree clean — no scratch files land in the repo).
|
||||
cat REVIEW.md > "$PROMPT"
|
||||
cat >> "$PROMPT" <<EOF
|
||||
|
||||
# Codex output format
|
||||
|
||||
- This is a pre-push LOCAL review of unpushed work; there is no PR yet.
|
||||
- Inspect the changes by running the diff commands in the review context below.
|
||||
- Untracked files do NOT appear in \`git diff\`. Review every untracked path listed below by reading it directly (\`cat\`) — treat its entire contents as newly added.
|
||||
- Return markdown starting with \`## Codex Review\`.
|
||||
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
|
||||
|
||||
# Review context
|
||||
|
||||
Local review (pre-push): current branch vs $BASE_REF
|
||||
Base SHA: $BASE_SHA
|
||||
Head SHA: $HEAD_SHA (plus any uncommitted working-tree changes)
|
||||
|
||||
Changed commits command:
|
||||
git log --oneline $BASE_SHA..HEAD
|
||||
|
||||
Changed files command:
|
||||
git diff --stat $BASE_SHA
|
||||
|
||||
Full review diff command (tracked changes, includes uncommitted edits):
|
||||
git diff --unified=0 $BASE_SHA
|
||||
|
||||
Untracked files (NOT in the diff above — read each one directly, it is entirely new):
|
||||
$(if [ -n "$UNTRACKED" ]; then printf '%s\n' "$UNTRACKED"; else echo "(none)"; fi)
|
||||
EOF
|
||||
|
||||
codex exec \
|
||||
-C "$REPO_ROOT" \
|
||||
-m "$MODEL" \
|
||||
-c 'model_reasoning_effort="xhigh"' \
|
||||
-s read-only \
|
||||
-o "$OUT" \
|
||||
- < "$PROMPT"
|
||||
|
||||
echo
|
||||
echo "===== Codex review ====="
|
||||
cat "$OUT"
|
||||
+4
-120
@@ -1,19 +1,18 @@
|
||||
---
|
||||
name: pr
|
||||
user_invocable: true
|
||||
description: Open a draft pull request on GitHub and drive CI review rounds until it is ready. MUST use when you want to create/open a PR.
|
||||
description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR.
|
||||
---
|
||||
|
||||
# Pull Request Skill
|
||||
|
||||
Create a draft pull request with a clear title and explicit description of changes, then drive it through CI review rounds to ready.
|
||||
Create a draft pull request with a clear title and explicit description of changes.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Analyze branch changes**: Understand all commits since diverging from main
|
||||
2. **Push to remote**: Ensure all commits are pushed
|
||||
3. **Create draft PR**: Always open as draft for review before merging
|
||||
4. **Drive review rounds**: trigger CI reviews on the draft and only flip to ready once every verdict is a go (see "Review rounds" below)
|
||||
|
||||
## PR Title Format
|
||||
|
||||
@@ -62,7 +61,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 (frontend/AGENTS.md → "Verifying Frontend Changes").
|
||||
1. Verify the change in the browser (AGENTS.md → "Verifying Frontend Changes").
|
||||
2. Screenshot each affected page with `mcp__playwright__browser_take_screenshot` (save to a file).
|
||||
3. Host each image and get its Markdown embed by pushing to the public
|
||||
`windmill-labs/agent-screenshots-internal` repo. **Pipe base64 through stdin** —
|
||||
@@ -97,11 +96,7 @@ and continue once they confirm it's done.
|
||||
1. Run `git status` to check for uncommitted changes
|
||||
2. Run `git log main..HEAD --oneline` to see all commits in this branch
|
||||
3. Run `git diff main...HEAD` to see the full diff against main
|
||||
4. **Review the diff before creating the PR — run both reviews, do not skip:**
|
||||
- **`local-review`** — Claude-native branch-diff-reviewer (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi).
|
||||
- **`local-review-codex`** — cold Codex pass, the same review CI runs, for an independent perspective the Claude pass misses (`/local-review-codex` in Claude Code, or `bash .agents/skills/local-review-codex/run.sh`). If the `codex` CLI is missing or older than the version pinned in that skill, note it in your summary and continue — never block the PR on codex being unavailable.
|
||||
|
||||
Run both — they catch different things. If either surfaces issues, fix them and commit before proceeding.
|
||||
4. **Invoke the `local-review` skill** before creating the PR (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi). If issues are found, fix them and commit before proceeding. Do not skip this step.
|
||||
5. **Screenshots for frontend changes**: if `git diff main...HEAD --name-only` matches `^frontend/`, capture and embed screenshots of the affected UI per "Screenshots" above before writing the PR body (skip only if there is no visible UI effect).
|
||||
6. Check if remote branch exists and is up to date:
|
||||
```bash
|
||||
@@ -125,117 +120,6 @@ and continue once they confirm it's done.
|
||||
)"
|
||||
```
|
||||
9. Return the PR URL to the user
|
||||
10. Drive the PR through CI review rounds to ready (see "Review rounds" below)
|
||||
|
||||
## 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 10–30 min; you are woken when it exits — do not stop the session or poll in the foreground while it runs):
|
||||
|
||||
```bash
|
||||
bash .agents/skills/pr/review-round.sh <PR_NUMBER>
|
||||
```
|
||||
|
||||
It comments `/review` on the PR — which runs the Codex, Claude and Pi CI reviewers even on a draft — waits for the spawned `PR Review Commands` workflow run(s) to complete, then prints one verdict line per reviewer and saves the full review comments to files.
|
||||
|
||||
`/review` (and `/codex`) are **idempotent per head SHA**: if a running or successful review already covers the current head, they skip that agent and post nothing new — the waiter reads the existing verdict for that head, so a skipped agent is *not* a missing one. A cancelled/failed head run is re-run in place; a fresh run is launched only when nothing covers the head. So an unchanged-head re-review is a near no-op, not a new round — push a commit to get genuinely fresh reviews.
|
||||
|
||||
2. **Judge the round.** Codex is mandatory; Claude, Pi and cubic count whenever they posted. Every review starts with one of the three `REVIEW.md` verdicts:
|
||||
- Codex verdict missing → the round is void: the waiter warns only when the head has no green Codex run (cancelled/failed/absent — not merely skipped-because-already-reviewed). Comment `/codex` on the PR, which re-runs the interrupted run in place (or launches one if none exists), wait the same way, and judge again.
|
||||
- Any **"Should address issues before merging"** → fix the P0/P1 findings (and the nits while you're there), commit, push, and start a new round (step 1).
|
||||
- Only **"Mergeable, but should ideally address nits"** and/or **"Good to merge"** → fix the nits too; a nit that is wrong or genuinely not worth fixing may instead be dismissed by replying to the review comment with your reasoning. Push nit-only fixes without starting another full round.
|
||||
|
||||
3. **Flip to ready with the marker comment.** The review workflows skip the redundant `ready_for_review`-triggered round when the PR author has posted a marker naming the current head SHA **and** the PR's latest Codex review *posted before the marker* has a non-blocking verdict (reviewer evidence — a bare marker with no round behind it, or one whose last pre-marker Codex verdict is "Should address issues", skips nothing). Keep the prefix exact and use the full 40-char SHA of the head you are flipping:
|
||||
- every verdict was "Good to merge" (head unchanged since the round):
|
||||
|
||||
`✅ Review round clean @ <head-sha>`
|
||||
|
||||
- nit-only round, nits fixed or dismissed afterwards (head may have moved past the reviewed SHA — say so):
|
||||
|
||||
`✅ Review round clean @ <head-sha> — nit-only verdicts at <round-sha>; nits addressed in <commit sha(s)> / dismissed in review replies`
|
||||
|
||||
```bash
|
||||
gh pr comment <PR_NUMBER> --body "✅ Review round clean @ $(git rev-parse HEAD)"
|
||||
gh pr ready <PR_NUMBER>
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trigger a CI review round on a PR and wait for it to finish.
|
||||
#
|
||||
# Usage: bash .agents/skills/pr/review-round.sh [PR_NUMBER]
|
||||
# PR_NUMBER defaults to the current branch's PR.
|
||||
#
|
||||
# Comments `/review` on the PR (works on drafts), waits for the spawned
|
||||
# "PR Review Commands" workflow run(s) to complete, then prints one verdict
|
||||
# line per reviewer and saves each full review comment to a file. A round
|
||||
# takes 10-30 minutes: run this in the background and act on its output when
|
||||
# it exits, per the pr skill ("Review rounds").
|
||||
set -euo pipefail
|
||||
|
||||
REPO=${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}
|
||||
PR=${1:-$(gh pr view --json number --jq .number)}
|
||||
|
||||
# Timestamp of the trigger comment, straight from GitHub, so local clock skew
|
||||
# can't make the run/comment filters below miss part of the round.
|
||||
TRIGGER_TIME=$(gh api "repos/$REPO/issues/$PR/comments" -f body='/review' --jq .created_at)
|
||||
echo "Review round triggered on $REPO#$PR at $TRIGGER_TIME"
|
||||
|
||||
# Retry wrapper for one-off gh/API hiccups: a 45-minute wait must not die on
|
||||
# a single transient failure.
|
||||
retry() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
if "$@"; then return 0; fi
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The /review comment spawns one "PR Review Commands" run holding the
|
||||
# claude/codex/pi jobs. Runs aren't linked to a PR, so wait on every run of
|
||||
# that workflow created after the trigger: a concurrent round on another PR
|
||||
# can only delay the answer, never truncate it. Every issue comment on any PR
|
||||
# spawns a fast-completing parse run of the same workflow, so the round's own
|
||||
# run may briefly lag the listing while unrelated runs already show completed:
|
||||
# require the all-completed state to hold past a floor and across two
|
||||
# consecutive polls before trusting it.
|
||||
DEADLINE=$(( $(date +%s) + 45 * 60 ))
|
||||
NO_RUN_DEADLINE=$(( $(date +%s) + 5 * 60 ))
|
||||
MIN_WAIT_UNTIL=$(( $(date +%s) + 3 * 60 ))
|
||||
STABLE=0
|
||||
FAILURES=0
|
||||
while :; do
|
||||
if RUNS=$(gh run list --repo "$REPO" --workflow=pr-review-commands.yml \
|
||||
--created ">=$TRIGGER_TIME" --limit 100 --json status); then
|
||||
FAILURES=0
|
||||
else
|
||||
FAILURES=$(( FAILURES + 1 ))
|
||||
if [ "$FAILURES" -ge 5 ]; then
|
||||
echo "ERROR: listing workflow runs failed $FAILURES times in a row; aborting the wait." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "WARNING: listing workflow runs failed (attempt $FAILURES/5); retrying in 60s." >&2
|
||||
sleep 60
|
||||
continue
|
||||
fi
|
||||
TOTAL=$(jq length <<<"$RUNS")
|
||||
PENDING=$(jq '[.[] | select(.status != "completed")] | length' <<<"$RUNS")
|
||||
NOW=$(date +%s)
|
||||
if [ "$TOTAL" -gt 0 ] && [ "$PENDING" -eq 0 ] && [ "$NOW" -gt "$MIN_WAIT_UNTIL" ]; then
|
||||
STABLE=$(( STABLE + 1 ))
|
||||
if [ "$STABLE" -ge 2 ]; then
|
||||
break
|
||||
fi
|
||||
else
|
||||
STABLE=0
|
||||
fi
|
||||
if [ "$TOTAL" -eq 0 ] && [ "$NOW" -gt "$NO_RUN_DEADLINE" ]; then
|
||||
echo "ERROR: no 'PR Review Commands' run appeared within 5 minutes of the /review comment; check that the comment author has write access and the workflow is enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$NOW" -gt "$DEADLINE" ]; then
|
||||
echo "WARNING: review round still pending after 45 minutes; reporting whatever has been posted so far." >&2
|
||||
break
|
||||
fi
|
||||
sleep 60
|
||||
done
|
||||
|
||||
# Head SHA at trigger time. `/review` is idempotent per head: it skips an agent a
|
||||
# running/successful review already covers, re-runs a cancelled/failed one in place on a
|
||||
# separate head-tied run, and launches fresh only when nothing covers the head. Verdict
|
||||
# reading below therefore keys off the head, not just the trigger timestamp.
|
||||
HEAD_SHA=$(retry gh api "repos/$REPO/pulls/$PR" --jq .head.sha)
|
||||
echo "Reviewing head $HEAD_SHA"
|
||||
|
||||
# Newest non-skipped run of <workflow> tied to the head ("status conclusion"), or empty
|
||||
# when none exists. A re-run-in-place or an already-covering review resolves on such a
|
||||
# head-tied run — separate from the pr-review-commands run waited on above (a fresh
|
||||
# launch instead runs inside it, and posts after the trigger). A `skipped` run is the
|
||||
# draft/fork gate and produced no review, so it is ignored.
|
||||
head_run_state() {
|
||||
gh run list --repo "$REPO" --workflow "$1" --commit "$HEAD_SHA" --limit 20 \
|
||||
--json databaseId,status,conclusion \
|
||||
--jq '[.[] | select(.conclusion != "skipped")] | sort_by(.databaseId) | last | if . then "\(.status) \(.conclusion // "-")" else empty end' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# A re-run-in-place review lands on a head-tied run that finishes after the fast
|
||||
# pr-review-commands run, so let those settle before reading verdicts.
|
||||
for wf in codex-pr-review.yml pi-pr-review.yml pr-ready-review.yml; do
|
||||
while :; do
|
||||
case "$(head_run_state "$wf")" in
|
||||
""|"completed "*) break ;;
|
||||
*) if [ "$(date +%s)" -gt "$DEADLINE" ]; then break; fi; sleep 30 ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
OUT_DIR=$(mktemp -d -t review-round-XXXXXX)
|
||||
COMMENTS_RAW=$(retry gh api "repos/$REPO/issues/$PR/comments?per_page=100" --paginate)
|
||||
# Two views: comments from THIS round (after the trigger) and the full history. A fresh
|
||||
# launch posts after the trigger; an idempotent skip leaves the covering verdict in the
|
||||
# earlier run's comment, so fall back to history when that agent's head run is green.
|
||||
jq -s --arg t "$TRIGGER_TIME" '[.[][] | select(.created_at > $t)]' \
|
||||
<<<"$COMMENTS_RAW" > "$OUT_DIR/comments.json"
|
||||
jq -s '[.[][]]' <<<"$COMMENTS_RAW" > "$OUT_DIR/comments-all.json"
|
||||
# cubic posts through the PR reviews API, not issue comments.
|
||||
REVIEWS_RAW=$(retry gh api "repos/$REPO/pulls/$PR/reviews?per_page=100" --paginate)
|
||||
jq -s --arg t "$TRIGGER_TIME" '[.[][] | select((.submitted_at // "") > $t)]' \
|
||||
<<<"$REVIEWS_RAW" > "$OUT_DIR/pr-reviews.json"
|
||||
|
||||
VERDICT_RE='(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)'
|
||||
|
||||
body_by_header() { # <file> <header-substring>
|
||||
jq -r --arg h "$2" '[.[] | select(.body // "" | contains($h))] | last | .body // empty' "$1"
|
||||
}
|
||||
body_by_login() { # <file> <login>
|
||||
jq -r --arg l "$2" '[.[] | select(.user.login == $l)] | last | .body // empty' "$1"
|
||||
}
|
||||
head_ok() { [ "$(head_run_state "$1")" = "completed success" ]; }
|
||||
# Latest verdict for a reviewer: prefer this round's comment; if none and the reviewer's
|
||||
# head run succeeded (an idempotent /review skipped re-reviewing an already-green head),
|
||||
# fall back to the covering comment from the full history.
|
||||
verdict_body() { # <header|login> <value> <workflow>
|
||||
local body
|
||||
body=$("body_by_$1" "$OUT_DIR/comments.json" "$2")
|
||||
if [ -z "$body" ] && head_ok "$3"; then
|
||||
body=$("body_by_$1" "$OUT_DIR/comments-all.json" "$2")
|
||||
fi
|
||||
printf '%s' "$body"
|
||||
}
|
||||
report() { # <reviewer-name> <comment-body>
|
||||
local name=$1 body=$2 verdict
|
||||
if [ -z "$body" ]; then
|
||||
echo "$name: (no review posted for this head)"
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$body" > "$OUT_DIR/$name.md"
|
||||
verdict=$(printf '%s\n' "$body" | grep -m1 -oE "${VERDICT_RE}.*" | sed 's/\*\*//g' || true)
|
||||
echo "$name: ${verdict:-(review posted but no verdict line; read $OUT_DIR/$name.md)}"
|
||||
}
|
||||
|
||||
echo
|
||||
echo "=== Review round verdicts for $REPO#$PR (head $HEAD_SHA) ==="
|
||||
CODEX_BODY=$(verdict_body header '## Codex Review' codex-pr-review.yml)
|
||||
report codex "$CODEX_BODY"
|
||||
report claude "$(verdict_body login 'claude[bot]' pr-ready-review.yml)"
|
||||
report pi "$(verdict_body header '## Pi Review' pi-pr-review.yml)"
|
||||
CUBIC_BODY=$(jq -r '[.[] | select(.user.login | test("^cubic(-dev-ai)?(\\[bot\\])?$"; "i"))] | last | .body // empty' \
|
||||
"$OUT_DIR/pr-reviews.json")
|
||||
if [ -z "$CUBIC_BODY" ]; then
|
||||
CUBIC_BODY=$(jq -r '[.[] | select(.user.login | test("^cubic(-dev-ai)?(\\[bot\\])?$"; "i"))] | last | .body // empty' \
|
||||
"$OUT_DIR/comments.json")
|
||||
fi
|
||||
report cubic "$CUBIC_BODY"
|
||||
echo
|
||||
echo "Full round output: $OUT_DIR (comments.json, pr-reviews.json, one .md per reviewer)"
|
||||
if [ -z "$CODEX_BODY" ]; then
|
||||
echo "WARNING: no Codex verdict for $HEAD_SHA - its head run is not green (cancelled/failed/absent, not merely skipped-because-already-reviewed). Re-trigger with a '/codex' PR comment (re-runs the interrupted run in place, or launches one) and wait again." >&2
|
||||
fi
|
||||
@@ -17,6 +17,7 @@ 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:
|
||||
|
||||
@@ -94,13 +94,6 @@ 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:
|
||||
|
||||
@@ -7,47 +7,9 @@ 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. 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.
|
||||
Always use Windmill's design-system components. Never use raw HTML elements.
|
||||
|
||||
### Buttons — `<Button>`
|
||||
|
||||
@@ -61,13 +23,7 @@ else, go back to the barrel and grep.
|
||||
<Button startIcon={{ icon: ChevronLeft }} iconOnly onclick={prev} />
|
||||
```
|
||||
|
||||
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.
|
||||
Props: `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`, `unifiedSize?: 'sm' | 'md' | 'lg'`, `startIcon?: { icon: SvelteComponent }`, `iconOnly?: boolean`, `disabled?: boolean`
|
||||
|
||||
### Text inputs — `<TextInput>`
|
||||
|
||||
@@ -114,14 +70,6 @@ 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:
|
||||
@@ -133,4 +81,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 frontend/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 AGENTS.md → "Verifying Frontend Changes" for the full flow. Use `playwright` (headless) on devboxes; `playwright-headed` when a display is available.
|
||||
|
||||
@@ -9,74 +9,11 @@ Windmill uses `SQLX_OFFLINE=true` in CI, which requires all `sqlx::query!` / `sq
|
||||
|
||||
## When to Run
|
||||
|
||||
Run after **adding or editing** a SQL query in Rust source. Without it, CI fails with:
|
||||
Run after any change to SQL queries in Rust source files. Without it, CI will fail with:
|
||||
```
|
||||
error: `SQLX_OFFLINE=true` but there is no cached data for this query
|
||||
```
|
||||
|
||||
**Do NOT run it when a change only *removes* queries.** The cache is already complete for
|
||||
CI; all that is left are orphaned entries, which are cosmetic and never break a build.
|
||||
Running `prepare` to tidy them risks destroying the cache for no gain. Delete them
|
||||
offline instead: for each `.sqlx/query-*.json`, normalize its `query` field (strip `\`
|
||||
line-continuations, collapse whitespace) and check whether it still appears in any `.rs`
|
||||
file. That detector reports ~48 false positives in a CE checkout — EE queries live in
|
||||
`*_ee.rs` symlinks it cannot read — so **filter to the tables your change touched** and
|
||||
delete only those.
|
||||
|
||||
## Before You Run Anything
|
||||
|
||||
1. **Back the cache up.** `prepare` deletes `.sqlx/` *before* regenerating, so any compile
|
||||
failure leaves it gutted (observed: 2350 → 142 entries).
|
||||
```bash
|
||||
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
|
||||
symptom is `relation "<your_new_table>" does not exist` — that is a wrong
|
||||
`DATABASE_URL`, not a broken query. See AGENTS.md → "Per-worktree ports and database".
|
||||
|
||||
## Queries Inside Tests Need `--all-targets`, Which Fails In A CE Checkout
|
||||
|
||||
`prepare` only caches queries in code it compiles, and `--workspace` alone does **not**
|
||||
compile test targets. A `sqlx::query!` inside `tests/*.rs` therefore gets no entry, and CI
|
||||
fails on the test target with the usual "no cached data" error even though the lib built
|
||||
clean. `SQLX_OFFLINE=true cargo check --workspace --all-targets` is what reproduces it.
|
||||
|
||||
Adding `--all-targets` caches them — and, in a CE checkout, **aborts partway through**:
|
||||
`backend/tests/otel.rs` imports `windmill_common::otel_ee`, which exists only behind the
|
||||
`private` feature, so the compile dies after `prepare` has already emptied `.sqlx/`.
|
||||
Observed: 2435 → 4 entries, `error: cargo check failed with status: exit status: 101`.
|
||||
|
||||
Do not fight it — the abort is a pre-existing EE gap, not something your change caused.
|
||||
Take the entries you need and put the backup back:
|
||||
|
||||
```bash
|
||||
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 ..
|
||||
|
||||
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 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:
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo check --workspace --features all_sqlx_features # lib
|
||||
SQLX_OFFLINE=true cargo check -p <your-crate> --all-targets # tests
|
||||
```
|
||||
|
||||
## The Problem
|
||||
|
||||
`cargo sqlx prepare --workspace` **deletes all existing cache files** and regenerates only the ones found in the current compilation. If you don't compile with every feature flag (especially `private` for EE files), you will **silently delete EE query caches**, breaking CI for enterprise tests.
|
||||
@@ -131,16 +68,7 @@ But if it fails with EE compilation errors, use the safe procedure above.
|
||||
|
||||
- **Never** run `cargo sqlx prepare --workspace` with only OSS features and commit the result — it will delete EE caches.
|
||||
- **Never** set `SQLX_OFFLINE=true` for local `cargo sqlx prepare` — use a live database per CLAUDE.md. (CI runs with `SQLX_OFFLINE=true`, which is why the cache must be complete.)
|
||||
- **Never** run `prepare` without a `.sqlx` backup, or against a `DATABASE_URL` you have not confirmed belongs to this worktree.
|
||||
- **Never** run `prepare` at all for a removal-only change.
|
||||
- **Never** skip the verification step (step 4 above).
|
||||
- **Never** leave a `--all-targets` run's output in place after it aborts — it is a
|
||||
near-empty cache. Restore the backup and graft on only the entries you verified.
|
||||
|
||||
Step 4 compares against `origin/main` because step 1 restored from it, so the two agree.
|
||||
If you did **not** run step 1 — auditing a branch's cache on its own, say — compare
|
||||
against `git merge-base HEAD origin/main` instead: `origin/main` advances, so its newer
|
||||
entries would read as losses on your branch.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,331 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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).
|
||||
#
|
||||
# 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 `. _ / -`, 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. `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 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 `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
|
||||
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)
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
has_substitution "$cmd" && defer "command substitution in the command line"
|
||||
|
||||
# 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; }
|
||||
case "$t" in
|
||||
-?*)
|
||||
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")" ] && defer "unrecognized option \`$t\`"
|
||||
case "$flags" in *x*) extracting=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]*) defer "ambiguous option bundle \`$t\`" ;; esac
|
||||
case "${flags: -1}" in
|
||||
[$val_flags])
|
||||
val="${SEG_TOKS[$i]:-}"
|
||||
i=$((i + 1))
|
||||
[ -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 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
# 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" || defer "\`$t\` is outside /tmp"
|
||||
[ "$verb" = "unzip" ] && saw_archive=1
|
||||
done
|
||||
|
||||
# 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 ] || { [ "$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
|
||||
}
|
||||
|
||||
# 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")" ] && 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]*)*$' || defer "unrecognized mode \`$t\`" ;;
|
||||
esac
|
||||
seen_mode=1
|
||||
continue
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -10,12 +10,8 @@ if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only the frontend app itself, i.e. a "frontend" directory sitting at a repo root.
|
||||
# A bare */frontend/* substring also matches ai_evals/adapters/frontend and the
|
||||
# ai_evals app fixtures, which no prettier config governs — prettier then falls back
|
||||
# to its defaults and rewrites the whole file. Anchoring to $CLAUDE_PROJECT_DIR
|
||||
# instead would skip worktrees edited from a session rooted elsewhere.
|
||||
if [[ "$FILE_PATH" == *"/frontend/"* ]] && [[ -e "${FILE_PATH%%/frontend/*}/.git" ]]; then
|
||||
# Check if the file is in the frontend directory
|
||||
if [[ "$FILE_PATH" == *"/frontend/"* ]]; then
|
||||
# Check if it's a formattable file type
|
||||
if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then
|
||||
cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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 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 `* ? [ ]`), 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. `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.
|
||||
#
|
||||
# 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 `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
|
||||
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)
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
# 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
|
||||
|
||||
[ "$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
|
||||
@@ -1,331 +0,0 @@
|
||||
#!/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
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
#!/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; }
|
||||
+32
-33
@@ -48,42 +48,51 @@
|
||||
"Read(/tmp/**)",
|
||||
"Write(/tmp/**)",
|
||||
"Edit(/tmp/**)",
|
||||
"mcp__claude_ai_Gmail__search_threads",
|
||||
"mcp__claude_ai_Gmail__get_thread",
|
||||
"mcp__claude_ai_Gmail__get_message",
|
||||
"mcp__claude_ai_Gmail__list_labels",
|
||||
"mcp__claude_ai_Gmail__list_drafts"
|
||||
"Bash(rm:/tmp/*)",
|
||||
"Bash(rm:/tmp/**)",
|
||||
"Bash(rmdir:/tmp/*)",
|
||||
"Bash(mkdir:/tmp/*)",
|
||||
"Bash(mkdir:/tmp/**)",
|
||||
"Bash(cp:/tmp/*)",
|
||||
"Bash(cp:/tmp/**)",
|
||||
"Bash(mv:/tmp/*)",
|
||||
"Bash(mv:/tmp/**)",
|
||||
"Bash(touch:/tmp/*)",
|
||||
"Bash(touch:/tmp/**)",
|
||||
"Bash(chmod:/tmp/*)",
|
||||
"Bash(chmod:/tmp/**)",
|
||||
"Bash(tar * /tmp/*)",
|
||||
"Bash(unzip * /tmp/*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(.env)",
|
||||
"Read(.env.*)",
|
||||
"Read(**/.env)",
|
||||
"Read(**/.env.*)",
|
||||
"Read(**/secrets/**)",
|
||||
"Read(**/*.pem)",
|
||||
"Read(**/*.key)",
|
||||
"Read(**/credentials.json)",
|
||||
"Read(**/.secret*)",
|
||||
"Read(**/.secrets*)",
|
||||
"Read(**/*.secret)",
|
||||
"Read(**/*.secrets)",
|
||||
"Edit(.env)",
|
||||
"Edit(.env.*)",
|
||||
"Edit(**/.env)",
|
||||
"Edit(**/.env.*)",
|
||||
"Edit(**/secrets/**)",
|
||||
"Edit(**/*.pem)",
|
||||
"Edit(**/*.key)",
|
||||
"Edit(**/credentials.json)",
|
||||
"Edit(**/.secret*)",
|
||||
"Edit(**/.secrets*)",
|
||||
"Edit(**/*.secret)",
|
||||
"Edit(**/*.secrets)"
|
||||
"Edit(**/.env.*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(rm:*)",
|
||||
"Bash(rmdir:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(chown:*)",
|
||||
"Bash(truncate:*)",
|
||||
"Bash(shred:*)",
|
||||
"Bash(unlink:*)",
|
||||
"mcp__claude_ai_Stripe",
|
||||
"mcp__claude_ai_Gmail__create_draft",
|
||||
"mcp__claude_ai_Gmail__update_draft",
|
||||
"mcp__claude_ai_Gmail__create_label",
|
||||
"mcp__claude_ai_Gmail__label_message",
|
||||
"mcp__claude_ai_Gmail__label_thread",
|
||||
"mcp__claude_ai_Gmail__unlabel_message",
|
||||
"mcp__claude_ai_Gmail__unlabel_thread",
|
||||
"mcp__claude_ai_Gmail__apply_sensitive_message_label",
|
||||
"mcp__claude_ai_Gmail__apply_sensitive_thread_label",
|
||||
"mcp__claude_ai_Gmail",
|
||||
"mcp__claude_ai_Google_Calendar",
|
||||
"mcp__claude_ai_Google_Drive",
|
||||
"mcp__claude_ai_Slack",
|
||||
@@ -100,16 +109,6 @@
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-main-branch.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-rm-outside-tmp.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/allow-fileops-in-tmp.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/ai-chat/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/ai-evals/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/codebase-design/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/domain-modeling/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/grill-me/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/grilling/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/improve-codebase-architecture/SKILL.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../.agents/skills/local-review-codex/SKILL.md
|
||||
@@ -1,3 +0,0 @@
|
||||
# Files a generator owns. Collapsed in review diffs and left out of language
|
||||
# stats: reviewing them means reviewing the generator instead.
|
||||
*.gen.ts linguist-generated=true
|
||||
@@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
@@ -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.4.0 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# Install windmill CLI
|
||||
RUN bun install -g windmill-cli \
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<!--
|
||||
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
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Sign image and attach provenance
|
||||
description: >
|
||||
Keyless-signs a pushed image digest with cosign (index and per-arch
|
||||
manifests) and records SLSA provenance as a GitHub artifact attestation
|
||||
pushed to the registry. SBOMs are not generated here: the build step embeds
|
||||
them as BuildKit attestation manifests (depot `sbom: true`), which the index
|
||||
signature then covers. The calling job must already be logged in to the
|
||||
registry and must have id-token: write, attestations: write and
|
||||
packages: write permissions (write-all covers all three).
|
||||
inputs:
|
||||
image:
|
||||
description: "Fully-qualified image name without tag, e.g. ghcr.io/windmill-labs/windmill"
|
||||
required: true
|
||||
digest:
|
||||
description: "Pushed manifest digest (sha256:...) from build-push-action"
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Preflight
|
||||
shell: bash
|
||||
env:
|
||||
DIGEST: ${{ inputs.digest }}
|
||||
run: |
|
||||
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
|
||||
echo "::error::No OIDC token available; the calling job needs id-token: write"
|
||||
exit 1
|
||||
fi
|
||||
case "$DIGEST" in
|
||||
sha256:*) ;;
|
||||
*)
|
||||
echo "::error::digest '$DIGEST' is not a sha256: digest"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# cosign v2 writes the classic sha256-<digest>.sig tag format that the
|
||||
# installed base of cosign clients can verify; v3's bundle format cannot be
|
||||
# verified by v2 clients yet, so stay on v2 until v3 verification is common.
|
||||
- uses: sigstore/cosign-installer@v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.6.5"
|
||||
|
||||
- name: Cosign keyless sign (index + per-arch manifests)
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ inputs.image }}
|
||||
DIGEST: ${{ inputs.digest }}
|
||||
run: cosign sign --yes --recursive "${IMAGE}@${DIGEST}"
|
||||
|
||||
- name: SLSA provenance (GitHub artifact attestation)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-name: ${{ inputs.image }}
|
||||
subject-digest: ${{ inputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -14,7 +14,6 @@ sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
|
||||
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
|
||||
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/jsr.json
|
||||
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
|
||||
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/windmill-yaml-validator/package.json
|
||||
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
|
||||
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
|
||||
sed -i -e "/^[[:space:]]*ModuleVersion[[:space:]]*=/s/= .*/= '$VERSION'/" ${root_dirpath}/powershell-client/WindmillClient/WindmillClient.psd1
|
||||
@@ -29,7 +28,3 @@ sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/parsers/w
|
||||
sed -i -zE "s/(name = \"windmill[^\"]*\"\nversion = )\"[^\"]*\"/\\1\"$VERSION\"/g" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.lock
|
||||
|
||||
cd ${root_dirpath}/frontend && npm i --package-lock-only --ignore-scripts
|
||||
|
||||
# The CLI installs this package on every `bun install`, which would otherwise rewrite the
|
||||
# lockfile's version and leave a dirty tree.
|
||||
cd ${root_dirpath}/windmill-yaml-validator && npm i --package-lock-only --ignore-scripts
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Codex output format
|
||||
|
||||
- Read the review context file whose absolute path is given at the end of these instructions; it holds the PR metadata and the diff commands.
|
||||
- Read `./.github/codex/pr-review-context.md` for PR metadata and the diff commands.
|
||||
- Return a markdown PR comment starting with `## Codex Review`.
|
||||
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
# Pi output format
|
||||
|
||||
- Read the review context file whose absolute path is given at the end of these instructions; it holds the PR metadata and the diff (or the git commands to produce it).
|
||||
- Read `./.github/pi/pr-review-context.md` for PR metadata and the diff commands.
|
||||
- Return a markdown PR comment starting with `## Pi Review`.
|
||||
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
|
||||
- Output ONLY the final review markdown — no preamble, no thinking, no tool transcripts.
|
||||
|
||||
# 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,160 +0,0 @@
|
||||
// Extracts every windmill.dev/docs link referenced in the frontend source and
|
||||
// verifies none of them 404. Run: `node .github/scripts/check-docs-links.mjs`.
|
||||
// Used by the check-docs-links GitHub workflow (release / manual trigger only).
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, extname } from 'node:path'
|
||||
|
||||
const ROOT = 'frontend/src'
|
||||
const EXTS = new Set(['.ts', '.js', '.svelte', '.mjs', '.cjs'])
|
||||
const DOCS_RE = /https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^\s"'`)>\]}]*/g
|
||||
// `const someBaseUrl = 'https://www.windmill.dev/docs/...'` used later as `${someBaseUrl}/foo`
|
||||
const BASE_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"`](https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^'"`]+)['"`]/g
|
||||
|
||||
const CONCURRENCY = 24
|
||||
const TIMEOUT_MS = 20000
|
||||
const RETRIES = 2
|
||||
|
||||
// Links whose target page is written but not yet deployed on windmill.dev: the app
|
||||
// link is already the final slug, so a 404 is expected until the docs side ships.
|
||||
// The value is why the entry exists, for whoever has to judge whether it still should.
|
||||
const PENDING_DEPLOY = new Map()
|
||||
|
||||
async function walk(dir) {
|
||||
const out = []
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.svelte-kit') continue
|
||||
out.push(...(await walk(p)))
|
||||
} else if (EXTS.has(extname(entry.name))) {
|
||||
out.push(p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// url (no fragment) -> Set of source files it appears in
|
||||
const urls = new Map()
|
||||
const unresolved = []
|
||||
|
||||
function record(url, file) {
|
||||
const clean = url
|
||||
.replace(/\\.*$/, '') // cut at an escape sequence embedded in a string literal (e.g. \n)
|
||||
.replace(/#.*$/, '') // drop anchor fragment — irrelevant to a 404 check
|
||||
.replace(/[.,;:'")\]]+$/, '')
|
||||
if (!clean) return
|
||||
// A `{`/`${` means the URL is built from an unresolved template/interpolation var.
|
||||
if (clean.includes('{')) {
|
||||
unresolved.push(`${clean} (${file})`)
|
||||
return
|
||||
}
|
||||
if (!urls.has(clean)) urls.set(clean, new Set())
|
||||
urls.get(clean).add(file)
|
||||
}
|
||||
|
||||
for (const file of await walk(ROOT)) {
|
||||
let content = await readFile(file, 'utf8')
|
||||
// Inline file-local base-url constants so `${base}/page` template literals resolve.
|
||||
const bases = []
|
||||
for (const m of content.matchAll(BASE_RE)) bases.push({ name: m[1], value: m[2], decl: m[0] })
|
||||
for (const { name, value } of bases) {
|
||||
content = content.replaceAll('${' + name + '}', value)
|
||||
}
|
||||
// Blank each base declaration so a prefix-only base (no index page of its own,
|
||||
// e.g. .../app_configuration_settings) isn't checked as a standalone link.
|
||||
// A genuinely bare `${base}` usage was already inlined above, so it's still covered.
|
||||
for (const { decl } of bases) content = content.replace(decl, '')
|
||||
for (const m of content.matchAll(DOCS_RE)) record(m[0], file)
|
||||
}
|
||||
|
||||
const allUrls = [...urls.keys()].sort()
|
||||
console.log(`Found ${allUrls.length} distinct docs links across ${ROOT}`)
|
||||
if (unresolved.length) {
|
||||
console.log(`\n⚠️ ${unresolved.length} link(s) built from an unrecognized base URL — skipped (register the base const so they can be checked):`)
|
||||
for (const u of [...new Set(unresolved)].sort()) console.log(` ${u}`)
|
||||
}
|
||||
|
||||
async function check(url) {
|
||||
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
let res = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'follow',
|
||||
signal: ctrl.signal,
|
||||
headers: { 'user-agent': 'windmill-docs-link-check' }
|
||||
})
|
||||
// Some hosts reject HEAD — fall back to GET.
|
||||
if (res.status === 405 || res.status === 501) {
|
||||
res = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: ctrl.signal,
|
||||
headers: { 'user-agent': 'windmill-docs-link-check' }
|
||||
})
|
||||
}
|
||||
clearTimeout(timer)
|
||||
return { url, status: res.status, ok: res.status < 400 }
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
if (attempt === RETRIES) return { url, status: 0, ok: false, error: String(err?.message || err) }
|
||||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple concurrency pool.
|
||||
const results = []
|
||||
let idx = 0
|
||||
async function worker() {
|
||||
while (idx < allUrls.length) {
|
||||
const url = allUrls[idx++]
|
||||
results.push(await check(url))
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, worker))
|
||||
|
||||
// An entry claims one thing — the page is not published yet — and 404 is the only
|
||||
// answer that means it. A timeout, 403 or 5xx on the same URL is a real fault, and
|
||||
// suppressing it would also read as "still waiting" and defer the staleness check.
|
||||
const isPendingDeploy = (r) => PENDING_DEPLOY.has(r.url) && r.status === 404
|
||||
|
||||
const pending = results.filter((r) => PENDING_DEPLOY.has(r.url))
|
||||
const waiting = results.filter(isPendingDeploy)
|
||||
if (waiting.length) {
|
||||
console.log(`\n⏳ ${waiting.length} link(s) waiting on a docs deploy:`)
|
||||
for (const p of waiting.sort((a, b) => a.url.localeCompare(b.url))) {
|
||||
console.log(` ${p.url}\n ${PENDING_DEPLOY.get(p.url)} — not live yet (${p.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry that outlived its reason exempts a URL from the check forever, so a stale
|
||||
// one has to fail the job: a line in a green log is not read at release time.
|
||||
const stale = [
|
||||
...pending.filter((p) => p.ok).map((p) => [p.url, 'the page is live']),
|
||||
...[...PENDING_DEPLOY.keys()].filter((u) => !urls.has(u)).map((u) => [u, 'nothing references it'])
|
||||
]
|
||||
|
||||
const failures = results.filter((r) => !r.ok && !isPendingDeploy(r))
|
||||
if (failures.length === 0 && stale.length === 0) {
|
||||
console.log(`\n✅ No broken docs links (${allUrls.length} checked).`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.log(`\n❌ ${failures.length} broken docs link(s):`)
|
||||
for (const f of failures.sort((a, b) => a.url.localeCompare(b.url))) {
|
||||
console.log(`\n ${f.url}`)
|
||||
console.log(` status: ${f.error ? `error (${f.error})` : f.status}`)
|
||||
for (const file of urls.get(f.url)) console.log(` ↳ ${file}`)
|
||||
}
|
||||
}
|
||||
if (stale.length) {
|
||||
console.log(`\n❌ ${stale.length} PENDING_DEPLOY entr(ies) to delete from this script:`)
|
||||
for (const [url, why] of stale.sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
console.log(`\n ${url}\n ${why}`)
|
||||
}
|
||||
}
|
||||
process.exit(1)
|
||||
@@ -57,11 +57,11 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: 1.3.10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -22,7 +22,6 @@ 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]
|
||||
@@ -36,7 +35,6 @@ 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:
|
||||
@@ -71,19 +69,17 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: 1.3.10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# Node 24 ships npm 11, which frontend/package-lock.json is authored
|
||||
# with; npm 10 rejects it ("Missing: picomatch@4.0.5 from lock file").
|
||||
# Node must also stay >= 22.19 for the frontend's undici 8.x, which the
|
||||
# Node 22.19+ is required by the frontend's undici 8.x, which the
|
||||
# Vitest bridge loads; Node 20 fails with markAsUncloneable.
|
||||
node-version: "24"
|
||||
node-version: "22"
|
||||
|
||||
# CE build used only as the AI proxy (login, workspace, provider resource,
|
||||
# /ai/proxy). No worker execution or MCP needed — global tools/drafts run
|
||||
@@ -120,17 +116,6 @@ jobs:
|
||||
npm ci
|
||||
npm run generate-backend-client
|
||||
|
||||
- name: Run harness unit tests
|
||||
working-directory: ./ai_evals
|
||||
run: |
|
||||
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
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
- name: cargo check
|
||||
working-directory: ./backend
|
||||
timeout-minutes: 16
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
- name: cargo check
|
||||
working-directory: ./backend
|
||||
timeout-minutes: 16
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
- name: cargo check
|
||||
working-directory: ./backend
|
||||
timeout-minutes: 16
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
- name: Fix stale v8 build cache
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
|
||||
@@ -50,12 +50,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
# This action defaults RUSTFLAGS to "-D warnings"; unset it so the test
|
||||
# run is not failed by cross-platform dead-code (cfg(unix)-only helpers
|
||||
# are unused on Windows). Warning hygiene is enforced on the Linux CI
|
||||
# and the build_windows_worker_ release build, not this test job.
|
||||
rustflags: ""
|
||||
toolchain: 1.93.0
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
@@ -63,9 +58,7 @@ jobs:
|
||||
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
# Pin to the Deno version shipped in the runtime image (Dockerfile) so CI
|
||||
# tests what production runs, instead of floating on the latest v2.x.
|
||||
deno-version: 2.2.1
|
||||
deno-version: v2.x
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
@@ -73,7 +66,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: 1.3.10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -81,7 +74,7 @@ jobs:
|
||||
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.11.24"
|
||||
version: "0.9.25"
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -101,6 +94,7 @@ jobs:
|
||||
|
||||
- name: Install OpenSSL via vcpkg
|
||||
run: |
|
||||
vcpkg.exe install openssl-windows:x64-windows
|
||||
vcpkg.exe install openssl:x64-windows-static
|
||||
vcpkg.exe integrate install
|
||||
|
||||
@@ -178,16 +172,13 @@ jobs:
|
||||
# binary link spikes several hundred MB of transient I/O. Capping at
|
||||
# 8 trades ~25% wall time for headroom on the ~75GB runner disk.
|
||||
CARGO_BUILD_JOBS: 8
|
||||
# backend/Cargo.toml keeps line tables on profile.dev for the (large)
|
||||
# windmill workspace crates; that debuginfo is emitted into every
|
||||
# object file and embedded in each test binary, and on windows-msvc
|
||||
# also spawns the mspdbsrv.exe PDB type server. Across the worker
|
||||
# crates' test build it drives the peak on the ~63GB free of the
|
||||
# runner disk (LNK1180 / disk-full during linking). CI reads no
|
||||
# backtraces, so drop it entirely for the dev/test profiles here:
|
||||
# debug = 0 means no .pdb and no LNK1318 type-server limit.
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
# backend/Cargo.toml sets split-debuginfo = "unpacked", which on
|
||||
# windows-msvc is coerced to "packed": every test-binary link spawns
|
||||
# the mspdbsrv.exe PDB type server and writes a large .pdb. CI needs
|
||||
# no debug info, so disable PDB generation for the dev/test profiles
|
||||
# here (avoids both LNK1318 type-server limit and PDB disk usage).
|
||||
CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off"
|
||||
CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off"
|
||||
# Tests' poll-time stack frames (deep nested async fn chains in
|
||||
# debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky
|
||||
# overflows under parallel-test contention.
|
||||
@@ -206,15 +197,9 @@ jobs:
|
||||
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
|
||||
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
|
||||
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
|
||||
# Windows ships a worker-only binary, so test the crates a worker runs
|
||||
# (windmill-worker/-common/-queue) via -p, not `--all`: this skips the
|
||||
# disk-heavy windmill-api test binaries (LNK1180) and the server-only
|
||||
# windmill-trigger-* crates (amqp does not build on Windows). Linux CI runs the rest.
|
||||
run: >
|
||||
cargo test
|
||||
--no-fail-fast
|
||||
-p windmill-worker
|
||||
-p windmill-common
|
||||
-p windmill-queue
|
||||
--features private,enterprise,deno_core,duckdb,python,rust,csharp,php,quickjs,parquet,mcp,scoped_cache,windmill-git-sync/private,windmill-object-store/private,windmill-object-store/enterprise
|
||||
--features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,csharp,php,quickjs,mcp,run_inline
|
||||
--all
|
||||
-- --nocapture --test-threads=10
|
||||
|
||||
@@ -50,21 +50,19 @@ jobs:
|
||||
dotnet-version: "9.0.x"
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
# Pin to the Deno version shipped in the runtime image (Dockerfile) so CI
|
||||
# tests what production runs, instead of floating on the latest v2.x.
|
||||
deno-version: 2.2.1
|
||||
deno-version: v2.x
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.21.5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: 1.3.10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "20"
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.11.24"
|
||||
version: "0.9.25"
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.3"
|
||||
@@ -90,7 +88,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
- name: Fix stale v8 build cache
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
@@ -239,47 +237,13 @@ jobs:
|
||||
- name: cargo test
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# setup-rust-toolchain exports RUSTFLAGS=-D warnings, and the RUSTFLAGS env
|
||||
# var fully REPLACES (never merges with) target.*.rustflags in
|
||||
# backend/.cargo/config.toml. That silently drops the config's
|
||||
# `-C link-arg=-fuse-ld=mold`, so CI links the many large integration-test
|
||||
# binaries (v8 + duckdb + every language runtime, statically linked) with the
|
||||
# default bfd linker. Its peak memory across ~12 parallel links OOM-kills the
|
||||
# runner mid-link (SIGTERM => exit 143, before any test runs). Re-add the mold
|
||||
# link arg here so CI links with mold like local dev, keeping -D warnings.
|
||||
# (config.toml's `linker = "clang"` still applies; env only overrides rustflags.)
|
||||
RUSTFLAGS: "-D warnings -C link-arg=-fuse-ld=mold"
|
||||
SQLX_OFFLINE: true
|
||||
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
|
||||
DISABLE_EMBEDDING: true
|
||||
RUST_LOG: "off"
|
||||
RUST_LOG_STYLE: never
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||
# Cap parallel rustc/link jobs below the 16 available cores. The tail of
|
||||
# the build links ~128 full-graph test binaries (one per tests/*.rs file
|
||||
# across the workspace); at high parallelism enough heavy codegen+link
|
||||
# units (rustc ~2.6GB, mold ~1GB each) overlap to exhaust the 64GB
|
||||
# runner. Matches backend-test-windows.yml, which already uses 8.
|
||||
CARGO_BUILD_JOBS: 8
|
||||
# Incremental compilation is per-run dead weight in CI: rust-cache
|
||||
# (cache-workspaces above) restores compiled dependency artifacts but
|
||||
# never persists target/**/incremental, so there is no prior state to
|
||||
# reuse in a one-shot `cargo test`. It only adds per-crate memory
|
||||
# overhead and extra disk. Off here (kept on for local dev via
|
||||
# .cargo/config.toml). Matches backend-test-windows.yml.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# backend/Cargo.toml keeps line tables on profile.dev for the (large)
|
||||
# windmill workspace crates; that debug info is emitted into every
|
||||
# object file and embedded in each test binary. Across the full
|
||||
# --all --features build it drives the memory/disk peak when mold
|
||||
# links the windmill-api-integration-tests binary, tipping the runner
|
||||
# over (lost runner reported as a canceled step). CI reads no
|
||||
# backtraces, so drop it entirely for the dev/test profiles here.
|
||||
# (test profile inherits dev, but the workspace crates link in as
|
||||
# dev-profile deps, so both must be set.) CI-only; local dev builds
|
||||
# are unaffected.
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
CARGO_BUILD_JOBS: 12
|
||||
# Tests' poll-time stack frames (deep nested async fn chains in
|
||||
# debug builds) reach ~1.8MB, leaving very thin headroom on the
|
||||
# default 2MB thread stack. 4MB gives ~2x buffer against flaky
|
||||
@@ -291,8 +255,5 @@ 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
|
||||
# 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)
|
||||
cd windmill-duckdb-ffi-internal && ./build_dev.sh && cd ..
|
||||
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
|
||||
|
||||
@@ -5,20 +5,6 @@ env:
|
||||
name: Build caddy-l4
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docker/DockerfileCaddyL4
|
||||
- docker/entrypoint-caddy.sh
|
||||
- docker/caddy-compat-normalize.awk
|
||||
- docker/caddy-l4.version
|
||||
- docker/test-caddy-compat.sh
|
||||
- Caddyfile
|
||||
# The version check below reads the pin out of docker-compose.yml, so a
|
||||
# compose-only bump has to trigger this workflow or the check never runs.
|
||||
- docker-compose.yml
|
||||
- .github/workflows/build-caddy-l4-image.yml
|
||||
|
||||
permissions: write-all
|
||||
|
||||
@@ -28,35 +14,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
# docker-compose.yml pins an exact tag, and the Caddyfile it must agree
|
||||
# with lives in the same checkout. Fail the build rather than publish a
|
||||
# version nothing references, which is how :latest drifted from the
|
||||
# Caddyfile in the first place.
|
||||
- name: Resolve and check image version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(tr -d '[:space:]' < docker/caddy-l4.version)"
|
||||
pinned="$(grep -oE 'caddy-l4:[^[:space:]"]+' docker-compose.yml | head -1 | cut -d: -f2-)"
|
||||
caddy="$(grep -m1 -oE '^FROM caddy:[0-9]+\.[0-9]+\.[0-9]+' docker/DockerfileCaddyL4 | cut -d: -f2)"
|
||||
if [ "$version" != "$pinned" ]; then
|
||||
echo "docker/caddy-l4.version is '$version' but docker-compose.yml pins '$pinned'" >&2
|
||||
echo "Bump both together." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Otherwise a caddy bump that forgets the version file publishes a tag
|
||||
# that names the wrong caddy.
|
||||
case "$version" in
|
||||
"$caddy"-*) ;;
|
||||
*)
|
||||
echo "docker/caddy-l4.version is '$version' but the Dockerfile pins caddy '$caddy'" >&2
|
||||
echo "The version must be <caddy-version>-<revision>." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-ee-public
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -66,22 +23,8 @@ jobs:
|
||||
tags: |
|
||||
type=sha
|
||||
type=ref,event=branch
|
||||
# Not gated on the default branch: docker-compose.yml pins this tag,
|
||||
# so it has to be publishable from a branch (workflow_dispatch)
|
||||
# before the pin merges, or main would reference a tag that does not
|
||||
# exist yet. The version is immutable, so republishing from main is
|
||||
# a no-op. Only branch pushes to main and manual dispatch run this
|
||||
# workflow, so a branch cannot claim the tag by accident.
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# The shim rewrites config a self-hoster never sees, so a silent
|
||||
# regression here strands them on a restart loop or a dead :80.
|
||||
- name: Test the legacy-Caddyfile compatibility shim
|
||||
run: |
|
||||
docker build -f docker/DockerfileCaddyL4 -t caddy-l4:ci ./docker
|
||||
docker/test-caddy-compat.sh caddy-l4:ci
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
@@ -63,7 +63,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
features=ee_rhel
|
||||
WM_BUILD_VERSION=${{ github.sha }}
|
||||
secrets: |
|
||||
rh_username=${{ secrets.RH_USERNAME }}
|
||||
rh_password=${{ secrets.RH_PASSWORD }}
|
||||
|
||||
@@ -65,7 +65,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
features=ee_rhel
|
||||
WM_BUILD_VERSION=${{ github.sha }}
|
||||
secrets: |
|
||||
rh_username=${{ secrets.RH_USERNAME }}
|
||||
rh_password=${{ secrets.RH_PASSWORD }}
|
||||
@@ -83,7 +82,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
features=ee_rhel
|
||||
WM_BUILD_VERSION=${{ github.sha }}
|
||||
secrets: |
|
||||
rh_username=${{ secrets.RH_USERNAME }}
|
||||
rh_password=${{ secrets.RH_PASSWORD }}
|
||||
|
||||
@@ -13,13 +13,9 @@ permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
publish_cli:
|
||||
# a tag-targeted dispatch would republish the release tags unsigned,
|
||||
# un-verifying the release; to republish a release, re-push its tag
|
||||
if: github.event_name == 'push' || !startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubicloud
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -46,23 +42,14 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
file: "./docker/DockerfileCli"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
|
||||
- name: Substitute EE code
|
||||
shell: bash
|
||||
@@ -45,12 +45,8 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
run: |
|
||||
cd backend
|
||||
# Stub the openapi specs to empty: they are compiled in via an ungated
|
||||
# include_str! but a worker binary never serves them, so this avoids
|
||||
# embedding ~2.5MB of spec.
|
||||
mkdir frontend/build && cd backend
|
||||
New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force
|
||||
New-Item -Path . -Name "windmill-api/openapi-deref.json" -ItemType "File" -Force
|
||||
cargo check --features=ee_windows
|
||||
|
||||
- name: Cargo build dynamic libraries windows
|
||||
@@ -62,6 +58,7 @@ jobs:
|
||||
- name: Cargo build binary windows
|
||||
timeout-minutes: 180
|
||||
run: |
|
||||
vcpkg.exe install openssl-windows:x64-windows
|
||||
vcpkg.exe install openssl:x64-windows-static
|
||||
vcpkg.exe integrate install
|
||||
$env:VCPKGRS_DYNAMIC=1
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Check frontend docs links
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-docs-links:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
frontend/src
|
||||
.github/scripts
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
- name: Verify docs links are not 404
|
||||
run: node .github/scripts/check-docs-links.mjs
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Check Organization Membership
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
commenter:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
description: 'The username to check. Auto-detected from the event context if not provided.'
|
||||
organization:
|
||||
required: false
|
||||
type: string
|
||||
default: 'windmill-labs'
|
||||
description: 'The organization to check membership for'
|
||||
trusted_bot:
|
||||
required: false
|
||||
type: string
|
||||
default: 'windmill-internal-app[bot]'
|
||||
description: 'The trusted bot username to allow'
|
||||
secrets:
|
||||
access_token:
|
||||
required: true
|
||||
description: 'The access token to use for org membership check'
|
||||
outputs:
|
||||
is_member:
|
||||
description: 'Whether the user is an organization member or trusted bot'
|
||||
value: ${{ jobs.check-membership.outputs.is_member }}
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
runs-on: ubicloud-standard-2
|
||||
outputs:
|
||||
is_member: ${{ steps.check-membership.outputs.is_member }}
|
||||
steps:
|
||||
- name: Determine commenter
|
||||
id: determine-commenter
|
||||
run: |
|
||||
COMMENTER="${{ inputs.commenter }}"
|
||||
if [[ -z "$COMMENTER" ]]; then
|
||||
if [[ "${{ github.event_name }}" == "issue_comment" || \
|
||||
"${{ github.event_name }}" == "pull_request_review_comment" ]]; then
|
||||
COMMENTER="${{ github.event.comment.user.login }}"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request_review" ]]; then
|
||||
COMMENTER="${{ github.event.review.user.login }}"
|
||||
else
|
||||
COMMENTER="${{ github.event.issue.user.login }}"
|
||||
fi
|
||||
fi
|
||||
echo "commenter=$COMMENTER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check organization membership
|
||||
id: check-membership
|
||||
env:
|
||||
ORG_ACCESS_TOKEN: ${{ secrets.access_token }}
|
||||
COMMENTER: ${{ steps.determine-commenter.outputs.commenter }}
|
||||
ORG: ${{ inputs.organization }}
|
||||
TRUSTED_BOT: ${{ inputs.trusted_bot }}
|
||||
run: |
|
||||
# 1. Allow the trusted bot straight away
|
||||
if [[ "$COMMENTER" == "$TRUSTED_BOT" ]]; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Disallow other bots
|
||||
if [[ "${COMMENTER}" =~ \[bot\]$ ]]; then
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3. Otherwise check if the user is a member of the organization
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $ORG_ACCESS_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
|
||||
|
||||
if [ "$STATUS" -eq 204 ]; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Check Write Access
|
||||
|
||||
# Authorizes a user to trigger privileged command workflows (/review, /ai, /plan,
|
||||
# /updatesqlx, ...). The webhook author_association reports PRIVATE org members as
|
||||
# CONTRIBUTOR/NONE (only public members show as MEMBER), so command jobs can't gate on
|
||||
# it alone. This mints the internal GitHub App token — which can see private members —
|
||||
# and confirms the user is a member or has write access to the repo. The app token is
|
||||
# minted fresh per run, so unlike the old ORG_ACCESS_TOKEN PAT it never expires.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
username:
|
||||
required: true
|
||||
type: string
|
||||
description: 'The user whose access to verify'
|
||||
trusted_bot:
|
||||
required: false
|
||||
type: string
|
||||
default: 'windmill-internal-app[bot]'
|
||||
description: 'A bot login that is always authorized'
|
||||
outputs:
|
||||
authorized:
|
||||
description: 'true if the user is the trusted bot, an org member, or has repo write access'
|
||||
value: ${{ jobs.check.outputs.authorized }}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
authorized: ${{ steps.check.outputs.authorized }}
|
||||
steps:
|
||||
# This check is purely additive: callers OR it with author_association, so it must
|
||||
# never fail the job. Failing here would block every dependent reviewer job through
|
||||
# `needs`, turning an unconfigured or misconfigured app into a total review outage
|
||||
# rather than a fallback to the author_association path.
|
||||
- name: Mint internal app token
|
||||
id: app
|
||||
if: vars.INTERNAL_APP_ID != ''
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ vars.INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.INTERNAL_APP_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Resolve authorization
|
||||
id: check
|
||||
env:
|
||||
# Without the app token, the default token still resolves public members and
|
||||
# repo collaborators; private members simply fall through to author_association.
|
||||
GH_TOKEN: ${{ steps.app.outputs.token || github.token }}
|
||||
USERNAME: ${{ inputs.username }}
|
||||
TRUSTED_BOT: ${{ inputs.trusted_bot }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ "$USERNAME" = "$TRUSTED_BOT" ]; then
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
ORG="${REPO%%/*}"
|
||||
# Org membership resolves private members too (204 = member, 404 = not).
|
||||
if gh api "orgs/$ORG/members/$USERNAME" --silent 2>/dev/null; then
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Fallback: effective repo permission (also covers outside collaborators).
|
||||
PERM=$(gh api "repos/$REPO/collaborators/$USERNAME/permission" --jq '.permission' 2>/dev/null || echo none)
|
||||
if [ "$PERM" = "admin" ] || [ "$PERM" = "write" ]; then
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||
echo "$USERNAME is neither the trusted bot, an org member, nor a repo writer."
|
||||
fi
|
||||
@@ -11,24 +11,20 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
# author_association misses private org members; check-access resolves them via the
|
||||
# internal app token. Both are OR'd below so public members still pass instantly.
|
||||
check-access:
|
||||
check-membership:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/plan')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/plan')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/plan')) ||
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '/plan'))
|
||||
uses: ./.github/workflows/check-write-access.yml
|
||||
with:
|
||||
username: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
claude-plan-action:
|
||||
needs: [check-access]
|
||||
needs: check-membership
|
||||
if: |
|
||||
needs.check-access.outputs.authorized == 'true' ||
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association)
|
||||
needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-4
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -49,7 +45,7 @@ jobs:
|
||||
allowed_bots: 'windmill-internal-app[bot]'
|
||||
trigger_phrase: '/plan'
|
||||
claude_args: |
|
||||
--model claude-opus-5
|
||||
--model claude-opus-4-8
|
||||
--system-prompt "# Claude Planning Mode
|
||||
|
||||
You are operating in PLANNING MODE ONLY. Your role is to create detailed, structured plans without making any code changes.
|
||||
|
||||
@@ -11,24 +11,20 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
# author_association misses private org members; check-access resolves them via the
|
||||
# internal app token. Both are OR'd below so public members still pass instantly.
|
||||
check-access:
|
||||
check-membership:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) ||
|
||||
(github.event_name == 'pull_request_review' && startsWith(github.event.review.body, '/ai') && !startsWith(github.event.review.body, '/ai-fast')) ||
|
||||
(github.event_name == 'issues' && startsWith(github.event.issue.body, '/ai') && !startsWith(github.event.issue.body, '/ai-fast'))
|
||||
uses: ./.github/workflows/check-write-access.yml
|
||||
with:
|
||||
username: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
claude-code-action:
|
||||
needs: [check-access]
|
||||
needs: check-membership
|
||||
if: |
|
||||
needs.check-access.outputs.authorized == 'true' ||
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association)
|
||||
needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -41,44 +37,6 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# Make the EE source (the *_ee.rs files in the companion repo) available so the
|
||||
# reviewer can see EE-only code (e.g. windmill-queue/src/jobs_ee.rs), not just the
|
||||
# CE surface. The EE ref is read from the PR head's backend/ee-repo-ref.txt (via the
|
||||
# API, so it reflects the PR's EE pin regardless of which ref is checked out here).
|
||||
- name: Check EE access
|
||||
id: ee
|
||||
env:
|
||||
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
run: |
|
||||
if [ -z "$EE_TOKEN" ] || [ -z "$PR_NUMBER" ]; then
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
HEAD_SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq .head.sha)
|
||||
REF=$(gh api "repos/${{ github.repository }}/contents/backend/ee-repo-ref.txt?ref=$HEAD_SHA" --jq .content | base64 -d | tr -d '[:space:]')
|
||||
if [ -z "$REF" ]; then
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ee_repo_ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout EE repository
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: windmill-labs/windmill-ee-private
|
||||
path: ./windmill-ee-private
|
||||
ref: ${{ steps.ee.outputs.ee_repo_ref }}
|
||||
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Substitute EE code
|
||||
if: steps.ee.outputs.available == 'true'
|
||||
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
@@ -93,4 +51,4 @@ jobs:
|
||||
}
|
||||
claude_args: |
|
||||
--allowedTools "Bash,WebFetch,WebSearch"
|
||||
--model claude-opus-5
|
||||
--model claude-opus-4-8
|
||||
|
||||
@@ -6,30 +6,14 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "cli/**"
|
||||
- "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:
|
||||
- "cli/**"
|
||||
- "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
|
||||
|
||||
@@ -32,19 +32,27 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
with:
|
||||
commenter: ${{ github.event.pull_request.user.login }}
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
codex-review:
|
||||
needs: check-membership
|
||||
runs-on: ubicloud-standard-2
|
||||
timeout-minutes: 30
|
||||
# A non-fork PR (head.repo.fork == false) can only be opened by someone with push
|
||||
# access to this repo, so fork==false already enforces write access. Do NOT re-add
|
||||
# an author_association gate: the pull_request webhook payload reports private org
|
||||
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
|
||||
# skips auto-review for every private member.
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
always() &&
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
needs.check-membership.result == 'skipped' ||
|
||||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
|
||||
) &&
|
||||
(
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -82,7 +90,6 @@ jobs:
|
||||
EVENT_BODY: ${{ github.event.pull_request.body }}
|
||||
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
|
||||
@@ -105,68 +112,24 @@ jobs:
|
||||
IS_FORK="$EVENT_FORK"
|
||||
PR_AUTHOR="$EVENT_AUTHOR"
|
||||
fi
|
||||
# Fork PRs run untrusted code with secrets present, so the automatic
|
||||
# pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER
|
||||
# means we arrived via workflow_call (a maintainer /codex comment gated
|
||||
# by check-write-access), so allow forks only on that path.
|
||||
if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then
|
||||
echo "Skipping Codex review for fork PR (automatic trigger)."
|
||||
if [ "$IS_FORK" = "true" ]; then
|
||||
echo "Skipping Codex review for fork PR."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# An agent-driven PR flips to ready only after a clean /review round on
|
||||
# a draft, marked by an author comment naming the head SHA (pr skill,
|
||||
# "Review rounds"). Re-reviewing that same head on ready_for_review is
|
||||
# redundant. The marker alone is author attestation, so also require
|
||||
# reviewer evidence: a Codex review (posted by github-actions[bot], not
|
||||
# forgeable by the author) that predates the marker and carries a
|
||||
# non-blocking verdict. Comment-triggered and synchronize runs never
|
||||
# skip. Keep the three copies of this check in sync (pr-ready-review /
|
||||
# codex-pr-review / pi-pr-review); a shared local action would need the
|
||||
# repo checked out before the check, which the fork paths here
|
||||
# deliberately avoid.
|
||||
if [ "$EVENT_ACTION" = "ready_for_review" ] && [ -z "$INPUT_PR_NUMBER" ]; then
|
||||
# Fetch failures fail open (no skip): an API hiccup must run the
|
||||
# review, never skip it or fail the job.
|
||||
COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
|
||||
MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
|
||||
'[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
|
||||
CODEX_VERDICT=''
|
||||
if [ -n "$MARKER_TIME" ]; then
|
||||
# Only Codex evidence that predates the marker counts: the ready-
|
||||
# triggered Codex run itself posts after the flip and must not
|
||||
# vouch for a sibling reviewer's skip.
|
||||
CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
|
||||
'[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
|
||||
| grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
|
||||
fi
|
||||
if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
|
||||
echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
# PR title/body are attacker-controlled free text. Use an unguessable
|
||||
# per-run delimiter so a fork can't embed a fixed heredoc terminator to
|
||||
# inject extra outputs — e.g. is_fork=false (last-write-wins), which
|
||||
# would re-enable the EE checkout and trusted-path settings for forks.
|
||||
RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
TITLE_EOF="TITLE_EOF_${RAND}"
|
||||
BODY_EOF="BODY_EOF_${RAND}"
|
||||
{
|
||||
echo "skip=false"
|
||||
echo "is_fork=$IS_FORK"
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "base_ref=$BASE_REF"
|
||||
echo "base_sha=$BASE_SHA"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "pr_author=$PR_AUTHOR"
|
||||
echo "title<<$TITLE_EOF"
|
||||
echo 'title<<PR_TITLE_EOF'
|
||||
printf '%s\n' "$PR_TITLE"
|
||||
echo "$TITLE_EOF"
|
||||
echo "body<<$BODY_EOF"
|
||||
echo 'PR_TITLE_EOF'
|
||||
echo 'body<<PR_BODY_EOF'
|
||||
printf '%s\n' "$PR_BODY"
|
||||
echo "$BODY_EOF"
|
||||
echo 'PR_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
@@ -175,17 +138,9 @@ jobs:
|
||||
with:
|
||||
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
|
||||
fetch-depth: 1
|
||||
# Don't persist github.token in .git/config: the review agent can read
|
||||
# the checkout, and on the fork path that token (issue/PR write) would
|
||||
# otherwise be exfiltratable. All later git ops target the public origin
|
||||
# and need no auth; EE checkout and gh use their own explicit tokens.
|
||||
persist-credentials: false
|
||||
|
||||
# Never expose the EE private-repo token to untrusted fork code. Skipping
|
||||
# this step leaves steps.ee.outputs.available empty, so the EE checkout and
|
||||
# substitution steps below are skipped too.
|
||||
- name: Check EE access
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true'
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
id: ee
|
||||
env:
|
||||
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
@@ -219,7 +174,7 @@ jobs:
|
||||
|
||||
- name: Install Codex CLI
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
run: npm install --global @openai/codex@0.153.4
|
||||
run: npm install --global @openai/codex@0.128.0
|
||||
|
||||
- name: Configure Codex auth
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
@@ -259,12 +214,9 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
run: |
|
||||
# Write outside the checkout: on the fork path the merge tree is
|
||||
# attacker-controlled, and a committed symlink at this path would
|
||||
# redirect the write.
|
||||
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
|
||||
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
|
||||
> "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json"
|
||||
> prior-comments.json || echo "[]" > prior-comments.json
|
||||
|
||||
- name: Write Codex review context
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
@@ -278,9 +230,9 @@ jobs:
|
||||
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
mkdir -p .github/codex
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const tmp = process.env.RUNNER_TEMP;
|
||||
const lines = [
|
||||
`Repository: ${process.env.PR_REPOSITORY}`,
|
||||
`PR number: ${process.env.PR_NUMBER}`,
|
||||
@@ -310,9 +262,9 @@ jobs:
|
||||
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
|
||||
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
|
||||
}
|
||||
if (fs.existsSync(`${tmp}/prior-comments.json`)) {
|
||||
if (fs.existsSync('prior-comments.json')) {
|
||||
try {
|
||||
const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8'));
|
||||
const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
|
||||
if (Array.isArray(comments) && comments.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
@@ -327,39 +279,19 @@ jobs:
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`);
|
||||
fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Run Codex review
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
PR_IS_FORK: ${{ steps.pr.outputs.is_fork }}
|
||||
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
|
||||
run: |
|
||||
if [ "$PR_IS_FORK" = "true" ]; then
|
||||
# Fork code is untrusted. Read the review policy/prompt from the base
|
||||
# ref (git show) rather than the attacker-controlled merge checkout,
|
||||
# so a malicious fork can't rewrite the reviewer's own instructions,
|
||||
# and run in a network-disabled sandbox to block secret exfiltration.
|
||||
git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/codex-prompt.md
|
||||
git show "origin/$PR_BASE_REF:.github/codex/pr-review.prompt.md" >> /tmp/codex-prompt.md
|
||||
SANDBOX_MODE=workspace-write
|
||||
else
|
||||
cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md
|
||||
SANDBOX_MODE=danger-full-access
|
||||
fi
|
||||
# The context file lives in RUNNER_TEMP (outside the attacker-controlled
|
||||
# checkout); tell the agent its absolute path.
|
||||
printf '\nReview context file (absolute path): %s\n' "$RUNNER_TEMP/pr-review-context.md" >> /tmp/codex-prompt.md
|
||||
# Write the final message outside the checkout too: a fork could commit
|
||||
# codex-final-message.md as a symlink and redirect this write to overwrite
|
||||
# e.g. a GitHub Action's index.js, which then runs with our credentials.
|
||||
cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md
|
||||
codex exec \
|
||||
-C "$GITHUB_WORKSPACE" \
|
||||
-m gpt-5.6-sol \
|
||||
-m gpt-5.5 \
|
||||
-c 'model_reasoning_effort="xhigh"' \
|
||||
-s "$SANDBOX_MODE" \
|
||||
-o "$RUNNER_TEMP/codex-final-message.md" \
|
||||
-s danger-full-access \
|
||||
-o codex-final-message.md \
|
||||
- < /tmp/codex-prompt.md
|
||||
|
||||
- name: Post Codex review comment
|
||||
@@ -367,52 +299,20 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
GH_JOB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = `${process.env.RUNNER_TEMP}/codex-final-message.md`;
|
||||
const path = `${process.env.GITHUB_WORKSPACE}/codex-final-message.md`;
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('Codex did not produce a final message; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
let body = fs.readFileSync(path, 'utf8').trim();
|
||||
const body = fs.readFileSync(path, 'utf8').trim();
|
||||
if (!body) {
|
||||
core.info('Codex final message was empty; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
// Defense-in-depth for fork reviews: the model call needs the provider
|
||||
// credential in the env, and the posted comment bypasses Actions log
|
||||
// masking. Strip any credential (API key, raw auth JSON, nested
|
||||
// tokens) that leaked into the review text before posting.
|
||||
const secrets = [];
|
||||
const addSecret = (v, min) => {
|
||||
if (typeof v === 'string' && v.length >= min) secrets.push(v);
|
||||
};
|
||||
addSecret(process.env.OPENAI_API_KEY, 8);
|
||||
addSecret(process.env.CODEX_AUTH_JSON, 8);
|
||||
addSecret(process.env.GH_JOB_TOKEN, 8);
|
||||
if (process.env.CODEX_AUTH_JSON) {
|
||||
try {
|
||||
const collect = (o) => {
|
||||
if (typeof o === 'string') addSecret(o, 20);
|
||||
else if (Array.isArray(o)) o.forEach(collect);
|
||||
else if (o && typeof o === 'object') Object.values(o).forEach(collect);
|
||||
};
|
||||
collect(JSON.parse(process.env.CODEX_AUTH_JSON));
|
||||
} catch (_) {}
|
||||
}
|
||||
for (const s of [...new Set(secrets)].sort((a, b) => b.length - a.length)) {
|
||||
body = body.split(s).join('[REDACTED]');
|
||||
}
|
||||
body = body.trim();
|
||||
if (!body) {
|
||||
core.info('Codex final message was empty after redaction; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -68,7 +68,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
features=ce_rpi
|
||||
WM_BUILD_VERSION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
|
||||
${{ steps.meta-public.outputs.tags }}
|
||||
|
||||
@@ -86,29 +86,19 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push publicly
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
build-args: |
|
||||
features=ce
|
||||
WM_BUILD_VERSION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
|
||||
${{ steps.meta-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-public.outputs.labels }}
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build_ee:
|
||||
runs-on: ubicloud
|
||||
if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee
|
||||
@@ -158,16 +148,13 @@ jobs:
|
||||
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
||||
|
||||
- name: Build and push publicly ee
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
build-args: |
|
||||
features=ee
|
||||
WM_BUILD_VERSION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
@@ -175,13 +162,6 @@ jobs:
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
attach_amd64_binary_to_release:
|
||||
needs: [build, build_ee]
|
||||
runs-on: ubicloud
|
||||
@@ -274,7 +254,6 @@ jobs:
|
||||
target: debuginfo
|
||||
build-args: |
|
||||
features=ee
|
||||
WM_BUILD_VERSION=${{ github.sha }}
|
||||
outputs: type=local,dest=./debuginfo
|
||||
|
||||
- name: Rename debug file with corresponding architecture
|
||||
@@ -376,21 +355,6 @@ jobs:
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
|
||||
|
||||
- uses: sigstore/cosign-installer@v4.1.2
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
with:
|
||||
cosign-release: "v2.6.5"
|
||||
# end-to-end release guard: the version tag pushed by this run must
|
||||
# verify against this exact run's identity (the mutable :latest/:dev
|
||||
# tags race with concurrent main builds, so they are not asserted here)
|
||||
- name: Verify release image is signed
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity "https://github.com/windmill-labs/windmill/.github/workflows/docker-image.yml@${GITHUB_REF}" \
|
||||
"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${GITHUB_REF_NAME#v}"
|
||||
|
||||
tag_latest_ee:
|
||||
runs-on: ubicloud
|
||||
needs: [run_integration_test, build_ee]
|
||||
@@ -412,21 +376,6 @@ jobs:
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main
|
||||
|
||||
- uses: sigstore/cosign-installer@v4.1.2
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
with:
|
||||
cosign-release: "v2.6.5"
|
||||
# end-to-end release guard: the version tag pushed by this run must
|
||||
# verify against this exact run's identity (the mutable :latest/:dev
|
||||
# tags race with concurrent main builds, so they are not asserted here)
|
||||
- name: Verify release ee image is signed
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity "https://github.com/windmill-labs/windmill/.github/workflows/docker-image.yml@${GITHUB_REF}" \
|
||||
"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${GITHUB_REF_NAME#v}"
|
||||
|
||||
verify_ee_image_vulnerabilities:
|
||||
runs-on: ubicloud
|
||||
needs: [tag_latest_ee]
|
||||
@@ -541,13 +490,11 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly ee
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileCuda"
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
@@ -555,13 +502,6 @@ jobs:
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-cuda
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build_slim:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
needs: [build]
|
||||
@@ -594,26 +534,17 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly ee
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileSlim"
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-slim
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build_ee_slim:
|
||||
needs: [build_ee]
|
||||
runs-on: ubicloud
|
||||
@@ -648,13 +579,11 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly ee
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileSlimEe"
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
@@ -662,13 +591,6 @@ jobs:
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-slim
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build_full:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
needs: [build]
|
||||
@@ -701,26 +623,17 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileFull"
|
||||
tags: |
|
||||
${{ steps.meta-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-public.outputs.labels }}
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-full
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build_ee_full:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
needs: [build_ee]
|
||||
@@ -753,23 +666,14 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly ee
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileFullEe"
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-full
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
@@ -23,8 +23,5 @@ 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
|
||||
|
||||
@@ -5,22 +5,21 @@ on:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
# /command comments can come from anyone; author_association misses private org
|
||||
# members, so check-access resolves them via the internal app token. Runs once and is
|
||||
# OR'd into each job's guard (public members still pass on author_association alone).
|
||||
check-access:
|
||||
if: github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/')
|
||||
uses: ./.github/workflows/check-write-access.yml
|
||||
with:
|
||||
username: ${{ github.event.comment.user.login }}
|
||||
secrets: inherit
|
||||
check-membership:
|
||||
if: >-
|
||||
github.event.issue.pull_request && (
|
||||
startsWith(github.event.comment.body, '/updatesqlx') ||
|
||||
startsWith(github.event.comment.body, '/demo') ||
|
||||
startsWith(github.event.comment.body, '/eeref') ||
|
||||
startsWith(github.event.comment.body, '/docs')
|
||||
)
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
update-sqlx:
|
||||
needs: [check-access]
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
|
||||
startsWith(github.event.comment.body, '/updatesqlx')
|
||||
needs: check-membership
|
||||
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/updatesqlx')
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -80,7 +79,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
|
||||
- name: Install xmlsec and gssapi build-time deps
|
||||
run: |
|
||||
@@ -148,11 +147,8 @@ jobs:
|
||||
})
|
||||
|
||||
demo:
|
||||
needs: [check-access]
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
|
||||
startsWith(github.event.comment.body, '/demo')
|
||||
needs: check-membership
|
||||
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/demo')
|
||||
runs-on: ubicloud-standard-2
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -231,11 +227,8 @@ jobs:
|
||||
fi
|
||||
|
||||
update-ee-ref:
|
||||
needs: [check-access]
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
|
||||
startsWith(github.event.comment.body, '/eeref')
|
||||
needs: check-membership
|
||||
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/eeref')
|
||||
runs-on: ubicloud-standard-2
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -320,11 +313,8 @@ jobs:
|
||||
})
|
||||
|
||||
update-docs:
|
||||
needs: [check-access]
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
|
||||
startsWith(github.event.comment.body, '/docs')
|
||||
needs: check-membership
|
||||
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/docs')
|
||||
runs-on: ubicloud-standard-2
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -9,11 +9,6 @@ on:
|
||||
- "backend/windmill-api-integration-tests/tests/git_sync*"
|
||||
- "backend/ee-repo-ref.txt"
|
||||
- "backend/windmill-common/src/workspaces.rs"
|
||||
- "frontend/src/lib/hubPaths.json"
|
||||
- "backend/windmill-worker/src/result_processor.rs"
|
||||
- "backend/windmill-api-workspaces/**"
|
||||
- "cli/src/commands/sync/**"
|
||||
- "cli/src/utils/git.ts"
|
||||
- "integration_tests/test/git_sync_test.py"
|
||||
- ".github/workflows/git-sync-test.yml"
|
||||
pull_request:
|
||||
@@ -23,11 +18,6 @@ on:
|
||||
- "backend/windmill-api-integration-tests/tests/git_sync*"
|
||||
- "backend/ee-repo-ref.txt"
|
||||
- "backend/windmill-common/src/workspaces.rs"
|
||||
- "frontend/src/lib/hubPaths.json"
|
||||
- "backend/windmill-worker/src/result_processor.rs"
|
||||
- "backend/windmill-api-workspaces/**"
|
||||
- "cli/src/commands/sync/**"
|
||||
- "cli/src/utils/git.ts"
|
||||
- "integration_tests/test/git_sync_test.py"
|
||||
- ".github/workflows/git-sync-test.yml"
|
||||
|
||||
@@ -60,8 +50,8 @@ jobs:
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Direct git sync file changes — always relevant.
|
||||
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|frontend/src/lib/hubPaths\.json|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
|
||||
# Direct git sync file changes — always relevant
|
||||
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Relevant: direct git sync file changes"
|
||||
exit 0
|
||||
@@ -131,11 +121,11 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: 1.3.10
|
||||
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
@@ -190,9 +180,6 @@ jobs:
|
||||
DENO_PATH: deno
|
||||
BUN_PATH: bun
|
||||
NODE_BIN_PATH: node
|
||||
# The auto-pull poller's SSRF guard rejects localhost git remotes;
|
||||
# the tests' Gitea runs on localhost.
|
||||
ALLOW_LOCAL_GIT_REMOTES: "true"
|
||||
run: |
|
||||
./target/debug/windmill &
|
||||
echo "Waiting for Windmill to be ready..."
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
go build
|
||||
- name: Pushes to another repository
|
||||
id: push_directory
|
||||
uses: cpina/github-action-push-to-another-repository@55306faa4ed53b815ae49e564af8cfb359d32ae2 # v1.7.3
|
||||
uses: cpina/github-action-push-to-another-repository@devel
|
||||
env:
|
||||
API_TOKEN_GITHUB: ${{ secrets.DENO_PAT }}
|
||||
with:
|
||||
|
||||
@@ -30,19 +30,27 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
with:
|
||||
commenter: ${{ github.event.pull_request.user.login }}
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
pi-review:
|
||||
needs: check-membership
|
||||
runs-on: ubicloud-standard-2
|
||||
timeout-minutes: 30
|
||||
# A non-fork PR (head.repo.fork == false) can only be opened by someone with push
|
||||
# access to this repo, so fork==false already enforces write access. Do NOT re-add
|
||||
# an author_association gate: the pull_request webhook payload reports private org
|
||||
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
|
||||
# skips auto-review for every private member.
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
always() &&
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
needs.check-membership.result == 'skipped' ||
|
||||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
|
||||
) &&
|
||||
(
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -75,7 +83,6 @@ jobs:
|
||||
EVENT_BODY: ${{ github.event.pull_request.body }}
|
||||
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
|
||||
@@ -98,68 +105,24 @@ jobs:
|
||||
IS_FORK="$EVENT_FORK"
|
||||
PR_AUTHOR="$EVENT_AUTHOR"
|
||||
fi
|
||||
# Fork PRs run untrusted code with secrets present, so the automatic
|
||||
# pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER
|
||||
# means we arrived via workflow_call (a maintainer /pi comment gated by
|
||||
# check-write-access), so allow forks only on that path.
|
||||
if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then
|
||||
echo "Skipping Pi review for fork PR (automatic trigger)."
|
||||
if [ "$IS_FORK" = "true" ]; then
|
||||
echo "Skipping Pi review for fork PR."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# An agent-driven PR flips to ready only after a clean /review round on
|
||||
# a draft, marked by an author comment naming the head SHA (pr skill,
|
||||
# "Review rounds"). Re-reviewing that same head on ready_for_review is
|
||||
# redundant. The marker alone is author attestation, so also require
|
||||
# reviewer evidence: a Codex review (posted by github-actions[bot], not
|
||||
# forgeable by the author) that predates the marker and carries a
|
||||
# non-blocking verdict. Comment-triggered and synchronize runs never
|
||||
# skip. Keep the three copies of this check in sync (pr-ready-review /
|
||||
# codex-pr-review / pi-pr-review); a shared local action would need the
|
||||
# repo checked out before the check, which the fork paths here
|
||||
# deliberately avoid.
|
||||
if [ "$EVENT_ACTION" = "ready_for_review" ] && [ -z "$INPUT_PR_NUMBER" ]; then
|
||||
# Fetch failures fail open (no skip): an API hiccup must run the
|
||||
# review, never skip it or fail the job.
|
||||
COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
|
||||
MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
|
||||
'[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
|
||||
CODEX_VERDICT=''
|
||||
if [ -n "$MARKER_TIME" ]; then
|
||||
# Only Codex evidence that predates the marker counts: the ready-
|
||||
# triggered Codex run itself posts after the flip and must not
|
||||
# vouch for a sibling reviewer's skip.
|
||||
CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
|
||||
'[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
|
||||
| grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
|
||||
fi
|
||||
if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
|
||||
echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
# PR title/body are attacker-controlled free text. Use an unguessable
|
||||
# per-run delimiter so a fork can't embed a fixed heredoc terminator to
|
||||
# inject extra outputs — e.g. is_fork=false (last-write-wins), which
|
||||
# would re-enable the EE checkout and trusted-path settings for forks.
|
||||
RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
TITLE_EOF="TITLE_EOF_${RAND}"
|
||||
BODY_EOF="BODY_EOF_${RAND}"
|
||||
{
|
||||
echo "skip=false"
|
||||
echo "is_fork=$IS_FORK"
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "base_ref=$BASE_REF"
|
||||
echo "base_sha=$BASE_SHA"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "pr_author=$PR_AUTHOR"
|
||||
echo "title<<$TITLE_EOF"
|
||||
echo 'title<<PR_TITLE_EOF'
|
||||
printf '%s\n' "$PR_TITLE"
|
||||
echo "$TITLE_EOF"
|
||||
echo "body<<$BODY_EOF"
|
||||
echo 'PR_TITLE_EOF'
|
||||
echo 'body<<PR_BODY_EOF'
|
||||
printf '%s\n' "$PR_BODY"
|
||||
echo "$BODY_EOF"
|
||||
echo 'PR_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
@@ -168,17 +131,9 @@ jobs:
|
||||
with:
|
||||
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
|
||||
fetch-depth: 1
|
||||
# Don't persist github.token in .git/config: the review agent can read
|
||||
# the checkout, and on the fork path that token (issue/PR write) would
|
||||
# otherwise be exfiltratable. All later git ops target the public origin
|
||||
# and need no auth; EE checkout and gh use their own explicit tokens.
|
||||
persist-credentials: false
|
||||
|
||||
# Never expose the EE private-repo token to untrusted fork code. Skipping
|
||||
# this step leaves steps.ee.outputs.available empty, so the EE checkout and
|
||||
# substitution steps below are skipped too.
|
||||
- name: Check EE access
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true'
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
id: ee
|
||||
env:
|
||||
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
@@ -212,9 +167,7 @@ jobs:
|
||||
|
||||
- name: Install Pi CLI
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
# 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
|
||||
run: npm install --global @mariozechner/pi-coding-agent
|
||||
|
||||
- name: Pre-fetch base and head refs for the PR
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
@@ -233,12 +186,9 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
run: |
|
||||
# Write outside the checkout: on the fork path the merge tree is
|
||||
# attacker-controlled, and a committed symlink at this path would
|
||||
# redirect the write.
|
||||
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
|
||||
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
|
||||
> "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json"
|
||||
> prior-comments.json || echo "[]" > prior-comments.json
|
||||
|
||||
- name: Write Pi review context
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
@@ -252,9 +202,9 @@ jobs:
|
||||
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
mkdir -p .github/pi
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const tmp = process.env.RUNNER_TEMP;
|
||||
const lines = [
|
||||
`Repository: ${process.env.PR_REPOSITORY}`,
|
||||
`PR number: ${process.env.PR_NUMBER}`,
|
||||
@@ -284,9 +234,9 @@ jobs:
|
||||
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
|
||||
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
|
||||
}
|
||||
if (fs.existsSync(`${tmp}/prior-comments.json`)) {
|
||||
if (fs.existsSync('prior-comments.json')) {
|
||||
try {
|
||||
const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8'));
|
||||
const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
|
||||
if (Array.isArray(comments) && comments.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
@@ -301,7 +251,7 @@ jobs:
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`);
|
||||
fs.writeFileSync('.github/pi/pr-review-context.md', `${lines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Run Pi review
|
||||
@@ -309,74 +259,16 @@ jobs:
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
PI_SKIP_VERSION_CHECK: '1'
|
||||
PR_IS_FORK: ${{ steps.pr.outputs.is_fork }}
|
||||
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
|
||||
PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
PI_HARDEN_FLAGS=()
|
||||
# Keep generated files (final message, events, context) outside the
|
||||
# checkout: on the fork path a committed symlink at any of these paths
|
||||
# would redirect our write and could overwrite an action's code that
|
||||
# then runs with our credentials. RUNNER_TEMP is outside the checkout.
|
||||
OUT_DIR="$RUNNER_TEMP"
|
||||
CTX="$RUNNER_TEMP/pr-review-context.md"
|
||||
if [ "$PR_IS_FORK" = "true" ]; then
|
||||
# Fork code is untrusted. Read the review policy/prompt from the base
|
||||
# ref (git show) rather than the attacker-controlled merge checkout,
|
||||
# so a malicious fork can't rewrite the reviewer's own instructions,
|
||||
# and drop the bash tool so the agent has no shell to exfiltrate with.
|
||||
git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/pi-prompt.md
|
||||
git show "origin/$PR_BASE_REF:.github/pi/pr-review.prompt.md" >> /tmp/pi-prompt.md
|
||||
PI_TOOLS=read,grep,find,ls
|
||||
|
||||
# The agent has no shell, so pre-compute the diff (base...head SHAs are
|
||||
# trusted) into the context file it reads. It may still read fork files
|
||||
# by absolute path for extra context — reads are safe.
|
||||
{
|
||||
echo ""
|
||||
echo "## Pre-computed review diff (base...head)"
|
||||
echo "You have no shell. The full diff is below. The repository checkout"
|
||||
echo "is at $GITHUB_WORKSPACE — you may read files there by absolute path."
|
||||
echo '```diff'
|
||||
git -C "$GITHUB_WORKSPACE" diff --unified=0 "$PR_BASE_SHA...$PR_HEAD_SHA"
|
||||
echo '```'
|
||||
} >> "$CTX"
|
||||
|
||||
# Pi resolves ALL project config from <cwd>/.pi (settings/packages,
|
||||
# extensions, skills, themes, prompts, SYSTEM.md); inside the fork
|
||||
# checkout a fork could inject any to run code or rewrite our system
|
||||
# prompt. Discovery is cwd-based, so run from a fresh empty dir.
|
||||
PI_WORKDIR=$(mktemp -d)
|
||||
cd "$PI_WORKDIR"
|
||||
|
||||
# Belt-and-suspenders on top of the isolated cwd: refuse discovery of
|
||||
# extensions/skills/templates/themes/context-files, and PI_OFFLINE=1 to
|
||||
# block any startup network op or package install. PI_OFFLINE gates only
|
||||
# startup network ops, not the provider inference call.
|
||||
PI_HARDEN_FLAGS=(--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files)
|
||||
export PI_OFFLINE=1
|
||||
else
|
||||
cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
|
||||
PI_TOOLS=read,grep,find,ls,bash
|
||||
fi
|
||||
# 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.
|
||||
cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
|
||||
pi -p \
|
||||
--provider deepseek \
|
||||
--model deepseek-v4-pro \
|
||||
--thinking high \
|
||||
--tools "$PI_TOOLS" \
|
||||
"${PI_HARDEN_FLAGS[@]}" \
|
||||
--tools read,grep,find,ls,bash \
|
||||
--mode json \
|
||||
< /tmp/pi-prompt.md \
|
||||
| tee "$OUT_DIR/pi-events.jsonl" \
|
||||
| tee pi-events.jsonl \
|
||||
| jq -rc --unbuffered '
|
||||
if .type == "agent_start" then "🤖 pi agent started"
|
||||
elif .type == "turn_start" then "── turn ──"
|
||||
@@ -404,51 +296,27 @@ jobs:
|
||||
| map(select(.role == "assistant"))
|
||||
| last
|
||||
| (.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
|
||||
' pi-events.jsonl > pi-final-message.md
|
||||
|
||||
- name: Post Pi review comment
|
||||
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
GH_JOB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = `${process.env.RUNNER_TEMP}/pi-final-message.md`;
|
||||
const path = `${process.env.GITHUB_WORKSPACE}/pi-final-message.md`;
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('Pi did not produce a final message; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
let body = fs.readFileSync(path, 'utf8').trim();
|
||||
const body = fs.readFileSync(path, 'utf8').trim();
|
||||
if (!body) {
|
||||
core.info('Pi final message was empty; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
// Defense-in-depth for fork reviews: the model call needs the provider
|
||||
// credential in the environment (readable via /proc/self/environ), and
|
||||
// the posted comment is an exfiltration channel that bypasses GitHub
|
||||
// Actions log masking. Strip the credential if it leaked into the text.
|
||||
for (const s of [process.env.DEEPSEEK_API_KEY, process.env.GH_JOB_TOKEN]) {
|
||||
if (typeof s === 'string' && s.length >= 8) {
|
||||
body = body.split(s).join('[REDACTED]');
|
||||
}
|
||||
}
|
||||
body = body.trim();
|
||||
if (!body) {
|
||||
core.info('Pi final message was empty after redaction; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -30,73 +30,38 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
with:
|
||||
commenter: ${{ github.event.pull_request.user.login }}
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
auto-review:
|
||||
needs: check-membership
|
||||
runs-on: ubuntu-latest
|
||||
# A non-fork PR (head.repo.fork == false) can only be opened by someone with push
|
||||
# access to this repo, so fork==false already enforces write access. Do NOT re-add
|
||||
# an author_association gate: the pull_request webhook payload reports private org
|
||||
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
|
||||
# skips auto-review for every private member.
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
always() &&
|
||||
(
|
||||
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
needs.check-membership.result == 'skipped' ||
|
||||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
|
||||
) &&
|
||||
(
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true)
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
steps:
|
||||
# An agent-driven PR flips to ready only after a clean /review round on a
|
||||
# draft, marked by an author comment naming the head SHA (pr skill, "Review
|
||||
# rounds"). Re-reviewing that same head on ready_for_review is redundant.
|
||||
# The marker alone is author attestation, so also require reviewer evidence:
|
||||
# a Codex review (posted by github-actions[bot], not forgeable by the author)
|
||||
# that predates the marker and carries a non-blocking verdict. Comment-
|
||||
# triggered (workflow_call) and opened runs never skip. Keep the three
|
||||
# copies of this check in sync (pr-ready-review / codex-pr-review /
|
||||
# pi-pr-review); a shared local action would need the repo checked out
|
||||
# before the check, which the codex/pi fork paths deliberately avoid.
|
||||
- name: Check clean-round marker
|
||||
id: marker
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
# Fetch failures fail open (skip=false): an API hiccup must run the
|
||||
# review, never skip it or fail the job.
|
||||
COMMENTS=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
|
||||
MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
|
||||
'[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
|
||||
CODEX_VERDICT=''
|
||||
if [ -n "$MARKER_TIME" ]; then
|
||||
# Only Codex evidence that predates the marker counts: the ready-
|
||||
# triggered Codex run itself posts after the flip and must not vouch
|
||||
# for a sibling reviewer's skip.
|
||||
CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
|
||||
'[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
|
||||
| grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
|
||||
fi
|
||||
if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
|
||||
echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.marker.outputs.skip != 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check EE access
|
||||
if: steps.marker.outputs.skip != 'true'
|
||||
id: ee
|
||||
env:
|
||||
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
@@ -123,7 +88,6 @@ jobs:
|
||||
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
||||
|
||||
- name: Resolve PR number
|
||||
if: steps.marker.outputs.skip != 'true'
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -143,7 +107,6 @@ jobs:
|
||||
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch prior PR discussion
|
||||
if: steps.marker.outputs.skip != 'true'
|
||||
id: prior
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -162,19 +125,12 @@ jobs:
|
||||
' prior-comments.json > prior-comments.md
|
||||
|
||||
- name: Read review prompt
|
||||
if: steps.marker.outputs.skip != 'true'
|
||||
id: review-prompt
|
||||
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<<$delimiter"
|
||||
echo 'REVIEW_PROMPT<<EOF'
|
||||
cat REVIEW.md
|
||||
echo ''
|
||||
cat .claude/review-prompt.md
|
||||
@@ -188,11 +144,10 @@ jobs:
|
||||
echo ''
|
||||
cat prior-comments.md
|
||||
fi
|
||||
echo "$delimiter"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Automatic PR Review
|
||||
if: steps.marker.outputs.skip != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
@@ -205,4 +160,4 @@ jobs:
|
||||
${{ env.REVIEW_PROMPT }}
|
||||
claude_args: |
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
--model claude-opus-5
|
||||
--model claude-opus-4-8
|
||||
|
||||
@@ -25,20 +25,16 @@ 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<<$delimiter"
|
||||
echo 'extra_prompt<<EXTRA_EOF'
|
||||
if [ -n "$REMAINDER_FIRST_LINE" ]; then
|
||||
printf '%s\n' "$REMAINDER_FIRST_LINE"
|
||||
fi
|
||||
if [ -n "$REST" ]; then
|
||||
printf '%s\n' "$REST"
|
||||
fi
|
||||
echo "$delimiter"
|
||||
echo 'EXTRA_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
@@ -46,24 +42,16 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
# author_association misses private org members; check-access resolves them via the
|
||||
# internal app token. Both are OR'd so public members still pass instantly.
|
||||
check-access:
|
||||
needs: [parse]
|
||||
check-membership:
|
||||
needs: parse
|
||||
if: needs.parse.outputs.command != ''
|
||||
uses: ./.github/workflows/check-write-access.yml
|
||||
with:
|
||||
username: ${{ github.event.comment.user.login }}
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
acknowledge:
|
||||
needs: [parse, check-access]
|
||||
if: |
|
||||
needs.parse.outputs.command != '' &&
|
||||
(
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
|
||||
needs.check-access.outputs.authorized == 'true'
|
||||
)
|
||||
needs: [parse, check-membership]
|
||||
if: needs.parse.outputs.command != '' && needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -79,153 +67,11 @@ jobs:
|
||||
"/repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
|
||||
-f content=eyes >/dev/null
|
||||
|
||||
# Decide, per agent, whether to launch a fresh run, re-run in place, or skip. A push
|
||||
# already auto-triggers codex/pi (and claude on open) against the PR head. Relaunching
|
||||
# via this issue_comment path both cancels those in-flight auto runs (shared concurrency
|
||||
# group) AND lands the new run's status on main — issue_comment runs never attach a
|
||||
# check to the PR head — leaving the PR showing only a cancelled review. So for every
|
||||
# command, launch an agent only when nothing covers the head commit; if the head's run
|
||||
# was cancelled/failed, re-run it in place (a re-run keeps the original pull_request
|
||||
# event, so its checks re-attach to the PR head); skip when a running or successful run
|
||||
# already covers it. `/review` applies this to all three agents; `/codex`, `/pi`,
|
||||
# `/claude` apply the same decision to just their own agent.
|
||||
plan:
|
||||
needs: [parse, check-access]
|
||||
if: |
|
||||
needs.parse.outputs.command != '' &&
|
||||
(
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
|
||||
needs.check-access.outputs.authorized == 'true'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
outputs:
|
||||
head_sha: ${{ steps.plan.outputs.head_sha }}
|
||||
launch_codex: ${{ steps.plan.outputs.launch_codex }}
|
||||
launch_pi: ${{ steps.plan.outputs.launch_pi }}
|
||||
launch_claude: ${{ steps.plan.outputs.launch_claude }}
|
||||
steps:
|
||||
- name: Decide per-agent launch vs re-run for the head commit
|
||||
id: plan
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMAND: ${{ needs.parse.outputs.command }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HEAD_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')
|
||||
echo "PR #$PR_NUMBER head: $HEAD_SHA"
|
||||
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
RUN_URL="$GITHUB_SERVER_URL/$REPO/actions/runs/$GITHUB_RUN_ID"
|
||||
|
||||
# A fresh launch runs from this issue_comment workflow (associated with main),
|
||||
# so it never appears in the PR-head run query below and its own check lands on
|
||||
# main, not the head. To keep fresh launches idempotent per head, mark the head
|
||||
# SHA with a `review-launch/<agent>` commit status at launch; the `finalize` job
|
||||
# resolves it to success/failure. A prior launch's status covering the head lets
|
||||
# a second comment skip instead of relaunching (which would cancel the first via
|
||||
# the reviewer's shared concurrency group). All status calls are best-effort — a
|
||||
# GitHub API hiccup must degrade to a relaunch, never abort the decision.
|
||||
mark_launch() {
|
||||
agent="$1"
|
||||
gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \
|
||||
-f state=pending -f "context=review-launch/$agent" -f "target_url=$RUN_URL" \
|
||||
-f "description=Review launched via /$COMMAND" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Returns "covered" if a prior fresh launch (this or an earlier comment run)
|
||||
# already covers the head: a success status, or a pending status whose launching
|
||||
# run is still alive. A pending whose run has completed is stale (that run
|
||||
# crashed before finalize) and does not count.
|
||||
launch_coverage() {
|
||||
agent="$1"
|
||||
st_json=$(gh api "repos/$REPO/commits/$HEAD_SHA/statuses" \
|
||||
--jq "[.[] | select(.context == \"review-launch/$agent\")] | first // empty" 2>/dev/null || true)
|
||||
[ -n "$st_json" ] || return 0
|
||||
state=$(jq -r '.state // empty' <<<"$st_json" 2>/dev/null || true)
|
||||
[ "$state" = success ] && { echo covered; return 0; }
|
||||
[ "$state" = pending ] || return 0
|
||||
target=$(jq -r '.target_url // empty' <<<"$st_json" 2>/dev/null || true)
|
||||
run_id=$(printf '%s' "$target" | grep -oE '[0-9]+$' || true)
|
||||
if [ -n "$run_id" ]; then
|
||||
run_state=$(gh run view "$run_id" --repo "$REPO" --json status --jq '.status' 2>/dev/null || true)
|
||||
[ "$run_state" = completed ] && return 0 # stale pending -> not covered
|
||||
fi
|
||||
echo covered
|
||||
}
|
||||
|
||||
decide() {
|
||||
wf="$1"; key="$2"; agent="$3"
|
||||
if [ "$(launch_coverage "$agent")" = covered ]; then
|
||||
echo "$key: a prior launch already covers $HEAD_SHA (review-launch/$agent) -> skip"
|
||||
echo "$key=false" >> "$GITHUB_OUTPUT"
|
||||
return
|
||||
fi
|
||||
# `--commit` matches runs whose head SHA is the PR head. Auto reviews run on
|
||||
# `pull_request` against that SHA; `/review` (issue_comment) runs execute on
|
||||
# main, so they never match and are not counted as covering the head commit.
|
||||
runs=$(gh run list --repo "$REPO" --workflow "$wf" --commit "$HEAD_SHA" --limit 40 \
|
||||
--json databaseId,status,conclusion)
|
||||
# Healthy = still running, or completed successfully: a review already
|
||||
# covers this commit, so skip.
|
||||
healthy=$(jq -r '[.[] | select(.status != "completed" or .conclusion == "success")] | length' <<<"$runs")
|
||||
if [ "$healthy" -gt 0 ]; then
|
||||
echo "$key: a running or successful review already covers $HEAD_SHA -> skip"
|
||||
echo "$key=false" >> "$GITHUB_OUTPUT"
|
||||
return
|
||||
fi
|
||||
# Re-run only genuinely interrupted runs (cancelled/failed/timed out) in
|
||||
# place, so their checks re-attach to the PR head instead of posting on
|
||||
# main. A `skipped` run produced no review and would just skip again (it is
|
||||
# the draft/fork gate), so it does not count — fall through to a fresh launch.
|
||||
retry_id=$(jq -r '[.[] | select(.status == "completed" and (.conclusion == "cancelled" or .conclusion == "failure" or .conclusion == "timed_out"))] | sort_by(.databaseId) | last | .databaseId // empty' <<<"$runs")
|
||||
if [ -n "$retry_id" ]; then
|
||||
if gh run rerun "$retry_id" --repo "$REPO" >/dev/null 2>&1; then
|
||||
echo "$key: re-ran interrupted run $retry_id (re-attaches to PR head)"
|
||||
echo "$key=false" >> "$GITHUB_OUTPUT"
|
||||
return
|
||||
fi
|
||||
echo "$key: re-run of $retry_id failed -> fresh launch"
|
||||
mark_launch "$agent"
|
||||
echo "$key=true" >> "$GITHUB_OUTPUT"
|
||||
return
|
||||
fi
|
||||
echo "$key: no usable review for $HEAD_SHA -> launch"
|
||||
mark_launch "$agent"
|
||||
echo "$key=true" >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
# `/review` targets all three agents; `/codex`, `/pi`, `/claude` target only
|
||||
# their own. A non-targeted agent is left untouched (no launch, no re-run).
|
||||
decide_if_targeted() {
|
||||
wf="$1"; key="$2"; agent="$3"
|
||||
if [ "$COMMAND" = review ] || [ "$COMMAND" = "$agent" ]; then
|
||||
decide "$wf" "$key" "$agent"
|
||||
else
|
||||
echo "$key: /$COMMAND does not target $agent -> skip"
|
||||
echo "$key=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
}
|
||||
|
||||
decide_if_targeted codex-pr-review.yml launch_codex codex
|
||||
decide_if_targeted pi-pr-review.yml launch_pi pi
|
||||
decide_if_targeted pr-ready-review.yml launch_claude claude
|
||||
|
||||
claude:
|
||||
needs: [parse, check-access, plan]
|
||||
needs: [parse, check-membership]
|
||||
if: |
|
||||
(
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
|
||||
needs.check-access.outputs.authorized == 'true'
|
||||
) &&
|
||||
needs.plan.outputs.launch_claude == 'true'
|
||||
needs.check-membership.outputs.is_member == 'true' &&
|
||||
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'claude')
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -240,13 +86,10 @@ jobs:
|
||||
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
|
||||
codex:
|
||||
needs: [parse, check-access, plan]
|
||||
needs: [parse, check-membership]
|
||||
if: |
|
||||
(
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
|
||||
needs.check-access.outputs.authorized == 'true'
|
||||
) &&
|
||||
needs.plan.outputs.launch_codex == 'true'
|
||||
needs.check-membership.outputs.is_member == 'true' &&
|
||||
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'codex')
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
@@ -262,13 +105,10 @@ jobs:
|
||||
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
|
||||
pi:
|
||||
needs: [parse, check-access, plan]
|
||||
needs: [parse, check-membership]
|
||||
if: |
|
||||
(
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
|
||||
needs.check-access.outputs.authorized == 'true'
|
||||
) &&
|
||||
needs.plan.outputs.launch_pi == 'true'
|
||||
needs.check-membership.outputs.is_member == 'true' &&
|
||||
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'pi')
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
@@ -281,34 +121,3 @@ jobs:
|
||||
secrets:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
|
||||
# Resolve the `review-launch/<agent>` head statuses that `plan` set to pending, so a
|
||||
# fresh launch's outcome is visible on the PR head (not just on main) and never lingers
|
||||
# as a stale pending check. Targets the exact SHA `plan` launched against, so a push
|
||||
# that moved the head mid-review does not stamp a status on the new head.
|
||||
finalize:
|
||||
needs: [plan, claude, codex, pi]
|
||||
if: always() && needs.plan.result == 'success' && needs.plan.outputs.head_sha != ''
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
statuses: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ needs.plan.outputs.head_sha }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
steps:
|
||||
- name: Finalize launch statuses on the PR head
|
||||
run: |
|
||||
set -uo pipefail
|
||||
finalize() {
|
||||
agent="$1"; launched="$2"; result="$3"
|
||||
[ "$launched" = true ] || return 0
|
||||
state=$([ "$result" = success ] && echo success || echo failure)
|
||||
gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \
|
||||
-f "state=$state" -f "context=review-launch/$agent" -f "target_url=$RUN_URL" \
|
||||
-f "description=Review $result" >/dev/null 2>&1 || true
|
||||
}
|
||||
finalize codex "${{ needs.plan.outputs.launch_codex }}" "${{ needs.codex.result }}"
|
||||
finalize pi "${{ needs.plan.outputs.launch_pi }}" "${{ needs.pi.result }}"
|
||||
finalize claude "${{ needs.plan.outputs.launch_claude }}" "${{ needs.claude.result }}"
|
||||
|
||||
@@ -84,9 +84,6 @@ jobs:
|
||||
|
||||
publish_extra:
|
||||
needs: [sleep, test_extra]
|
||||
# a tag-targeted dispatch would republish the release tags unsigned,
|
||||
# un-verifying the release; to republish a release, re-push its tag
|
||||
if: github.event_name == 'push' || !startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -115,24 +112,15 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly
|
||||
id: docker_build
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/DockerfileExtra
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
|
||||
- name: Sign and attest release image
|
||||
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
|
||||
uses: ./.github/actions/sign-attest-image
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
toolchain: 1.97.0
|
||||
toolchain: 1.93.0
|
||||
|
||||
- name: Substitute EE code
|
||||
shell: bash
|
||||
@@ -51,16 +51,13 @@ jobs:
|
||||
- name: Cargo build windows
|
||||
timeout-minutes: 180
|
||||
run: |
|
||||
vcpkg.exe install openssl-windows:x64-windows
|
||||
vcpkg.exe install openssl:x64-windows-static
|
||||
vcpkg.exe integrate install
|
||||
$env:VCPKGRS_DYNAMIC=1
|
||||
$env:OPENSSL_DIR="${Env:VCPKG_INSTALLATION_ROOT}\installed\x64-windows-static"
|
||||
cd backend
|
||||
# Stub the openapi specs to empty: they are compiled in via an ungated
|
||||
# include_str! but a worker binary never serves them, so this avoids
|
||||
# embedding ~2.5MB of spec.
|
||||
mkdir frontend/build && cd backend
|
||||
New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force
|
||||
New-Item -Path . -Name "windmill-api/openapi-deref.json" -ItemType "File" -Force
|
||||
cargo build --release --features=ee_windows
|
||||
- name: Rename binary with corresponding architecture
|
||||
run: |
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Refresh docs snapshot
|
||||
|
||||
# The backend embeds a vendored docs snapshot (backend/windmill-api/docs_snapshot/*.gz)
|
||||
# so in-product docs search works with no runtime egress. This job re-fetches it from
|
||||
# windmill.dev on a schedule and opens a PR when it changed, keeping the embedded docs
|
||||
# fresh independently of the release cadence (the binary embeds whatever is on the
|
||||
# source tree at build time, so a merged refresh rides into the next release build).
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Mondays 06:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubicloud
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app
|
||||
with:
|
||||
app-id: ${{ vars.INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.INTERNAL_APP_KEY }}
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.app.outputs.token }}
|
||||
- name: Fetch + re-gzip docs snapshot
|
||||
run: cd backend/windmill-api/docs_snapshot && ./fetch.sh
|
||||
- name: Sanity-check the fetched corpus
|
||||
# curl -f in fetch.sh rejects HTTP errors, but not a valid-but-garbage 200
|
||||
# (truncated file, error page). Guard against embedding a broken snapshot.
|
||||
run: |
|
||||
cd backend/windmill-api/docs_snapshot
|
||||
test "$(wc -c < llms-full.txt.gz)" -gt 100000
|
||||
test "$(wc -c < llms.txt.gz)" -gt 1000
|
||||
pages=$(gzip -dc llms-full.txt.gz | grep -c '^Source:' || true)
|
||||
echo "pages in snapshot: $pages"
|
||||
test "${pages:-0}" -ge 200
|
||||
- uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ steps.app.outputs.token }}
|
||||
branch: chore/refresh-docs-snapshot
|
||||
add-paths: backend/windmill-api/docs_snapshot/*.gz
|
||||
commit-message: "chore: refresh vendored docs snapshot"
|
||||
title: "chore: refresh vendored docs snapshot"
|
||||
body: |
|
||||
Automated refresh of the embedded docs snapshot
|
||||
(`backend/windmill-api/docs_snapshot/*.gz`) from windmill.dev.
|
||||
|
||||
Review the diff for unexpected churn (a bad upstream docs deploy would
|
||||
show up as a large drop in pages or content) before merging.
|
||||
@@ -8,10 +8,8 @@ jobs:
|
||||
name: "Release please"
|
||||
runs-on: ubicloud
|
||||
steps:
|
||||
# Config lives in release-please-config.json / .release-please-manifest.json:
|
||||
# a `release-type` input instead re-derives the last released version by
|
||||
# paginating every GitHub release, which on a repo this size is slow enough
|
||||
# to fail intermittently.
|
||||
- uses: googleapis/release-please-action@v5
|
||||
- uses: GoogleCloudPlatform/release-please-action@v3
|
||||
with:
|
||||
release-type: simple
|
||||
package-name: windmill
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# The python and typescript SDK unit suites, on release tags only: they guard
|
||||
# what gets published to npm / PyPI / JSR, and a tag is the moment that decides
|
||||
# it.
|
||||
#
|
||||
# This runs alongside the publish workflows rather than ahead of them, so it
|
||||
# reports a broken SDK rather than holding one back. Gating would mean putting
|
||||
# the job inside each publish workflow, since Actions cannot express `needs`
|
||||
# across workflows.
|
||||
name: SDK Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
typescript-client:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# No build step: these suites are deliberately free of the generated API
|
||||
# client, so they run against the sources as committed.
|
||||
- name: Run tests
|
||||
working-directory: ./typescript-client
|
||||
run: bun test --timeout 120000 tests/
|
||||
|
||||
python-client:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
# The interpreter is named explicitly: on a clean checkout uv picks the
|
||||
# runner's system python and stops with "not compatible with the locked
|
||||
# Python requirement" rather than fetching one. Keep in step with
|
||||
# `requires-python` in uv.lock.
|
||||
#
|
||||
# Note this is not the version a worker runs the SDK on — those are 3.12.
|
||||
# `uv.lock` asks for >=3.14, so pinning lower means regenerating it, which
|
||||
# is worth doing separately.
|
||||
- name: Install the interpreter the lockfile requires
|
||||
run: uv python install 3.14
|
||||
|
||||
# `--frozen` so a drifted lockfile fails here rather than quietly
|
||||
# resolving to something nobody has run.
|
||||
- name: Run tests
|
||||
working-directory: ./python-client/wmill
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: uv run --frozen --python 3.14 pytest tests/ -q
|
||||
@@ -150,21 +150,13 @@ jobs:
|
||||
COMMENT_URL: ${{ inputs.COMMENT_URL }}
|
||||
COMMENT_IS_EDIT: ${{ inputs.COMMENT_IS_EDIT }}
|
||||
run: |
|
||||
# 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.
|
||||
# 1) Find the thread by PR number
|
||||
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"
|
||||
|
||||
@@ -21,15 +21,9 @@ jobs:
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-signatures: "signatures/cla.json"
|
||||
path-to-document: "https://github.com/windmill-labs/windmill/blob/main/CLA.md"
|
||||
path-to-document: "https://github.com/windmill-labs/windmill/blob/master/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)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: YAML validator tests
|
||||
|
||||
# The schemas behind `wmill lint` are generated from the OpenAPI specs, so a spec change
|
||||
# can turn a valid synced file into a lint error without touching any validator code.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "windmill-yaml-validator/**"
|
||||
- "openflow.openapi.yaml"
|
||||
- "backend/windmill-api/openapi.yaml"
|
||||
- ".github/workflows/yaml-validator-tests.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "windmill-yaml-validator/**"
|
||||
- "openflow.openapi.yaml"
|
||||
- "backend/windmill-api/openapi.yaml"
|
||||
- ".github/workflows/yaml-validator-tests.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: windmill-yaml-validator
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
working-directory: windmill-yaml-validator
|
||||
run: npm test
|
||||
@@ -22,13 +22,8 @@ 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/
|
||||
frontend/node_modules
|
||||
typescript-client/node_modules
|
||||
ai_evals/node_modules
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--output-dir", "/tmp/playwright-mcp-${USER:-shared}"]
|
||||
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless"]
|
||||
},
|
||||
"playwright-headed": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--output-dir", "/tmp/playwright-mcp-${USER:-shared}"]
|
||||
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
".": "1.809.0"
|
||||
}
|
||||
+14
-30
@@ -5,19 +5,9 @@ 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
|
||||
|
||||
@@ -58,6 +48,7 @@ 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
|
||||
@@ -67,19 +58,11 @@ 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 && 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'
|
||||
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
|
||||
|
||||
frontendOnly:
|
||||
runtime: host
|
||||
@@ -95,6 +78,7 @@ 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
|
||||
@@ -103,16 +87,14 @@ profiles:
|
||||
kind: command
|
||||
split: right
|
||||
workingDir: frontend
|
||||
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'
|
||||
command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
|
||||
|
||||
agentOnly:
|
||||
runtime: host
|
||||
yolo: true
|
||||
envPassthrough: []
|
||||
systemPrompt: >
|
||||
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
|
||||
panes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
@@ -158,12 +140,14 @@ oneshot:
|
||||
— note the choice in the PR description if it matters.
|
||||
|
||||
# PR readiness
|
||||
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.
|
||||
Default to opening the PR as a draft. If you are highly confident in the
|
||||
change — the scope is small and well-understood, validation passed
|
||||
cleanly, and you would not change anything if a reviewer pushed back —
|
||||
open the PR as ready-for-review directly (omit `--draft` when invoking
|
||||
`gh pr create`, or call `gh pr ready <number>` after creation). Err on
|
||||
the side of draft when validation was partial, the change touches
|
||||
public APIs or shared infrastructure, or you made a non-obvious judgment
|
||||
call.
|
||||
|
||||
# Ending your turn
|
||||
Never end your turn with a question, a suggestion to "take a look", or a
|
||||
|
||||
@@ -5,60 +5,24 @@ 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.
|
||||
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.
|
||||
2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages
|
||||
3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`)
|
||||
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`, 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.
|
||||
4. **Validate**: After every change, run the appropriate checks per `docs/validation.md`
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
|
||||
- **Unreleased SDK changes**: `docs/wac-sdk-e2e.md` — exercising a client change on a real worker
|
||||
- **Agent workers**: `docs/agent-worker-e2e.md` — building and running one locally. An agent
|
||||
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.
|
||||
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy and `xhigh` reasoning, on `gpt-6-astra` rather than the action's `gpt-5.6-sol`; requires the `codex` CLI >= 0.153.4.
|
||||
- **Domain guides**: `.claude/skills/native-trigger/`
|
||||
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi.
|
||||
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
- **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
|
||||
|
||||
> **In a git worktree, the ports and database below are NOT the ones to use.** Each
|
||||
> worktree gets its own backend port, frontend port and Postgres database, so the
|
||||
> 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)
|
||||
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/` (port 3000+)
|
||||
- **DB**: `psql postgres://postgres:changeme@localhost:5432/windmill`
|
||||
@@ -66,53 +30,50 @@ are for a plain checkout with nothing running.
|
||||
- **Instance settings**: navigate to `/#superadmin-settings`
|
||||
- **Migrations**: use `cargo sqlx migrate add -r <name>` from `backend/` to create new migrations (never generate timestamps manually)
|
||||
|
||||
### Per-worktree ports and database
|
||||
## Verifying Frontend Changes
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
In a plain checkout, fall back to `.env` / `.env.local` (repo root) and `backend/.env`.
|
||||
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
|
||||
|
||||
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.
|
||||
**One-time setup:** run `npx playwright install chromium` to download the browser binary (Playwright won't fetch it automatically on first use).
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
```bash
|
||||
psql postgres://postgres:changeme@localhost:5432/postgres -tAc \
|
||||
"select datname from pg_database where datname like 'windmill%'" | grep "$(git branch --show-current | tr - _)"
|
||||
# the port the frontend actually proxies to (REMOTE of this worktree's vite):
|
||||
for p in $(pgrep -f vite); do case "$(readlink /proc/$p/cwd)" in *"$(basename "$(git rev-parse --show-toplevel)")"*)
|
||||
tr '\0' '\n' < /proc/$p/environ | grep -E '^REMOTE=|^PORT=';; esac; done
|
||||
**Attach the screenshots to the PR.** For any change under `frontend/`, embed screenshots of the affected UI in the PR body — the `pr` skill requires this and carries the upload recipe.
|
||||
|
||||
If you cannot exercise a UI change (no dev server, etc.), say so explicitly rather than claiming success.
|
||||
|
||||
## Banned Patterns
|
||||
|
||||
### `$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()
|
||||
```
|
||||
|
||||
Getting these wrong is not a cheap mistake:
|
||||
**Correct alternatives:**
|
||||
|
||||
- **`DATABASE_URL` pointed at another worktree's database silently destroys the sqlx
|
||||
cache.** `cargo run` and `cargo sqlx prepare` both compile `sqlx::query!` against the
|
||||
**live** database, so the wrong one fails with `relation "<your_new_table>" does not
|
||||
exist` — and `prepare` deletes the whole `.sqlx/` directory *before* it fails, leaving
|
||||
it gutted. Always `cp -r backend/.sqlx <tmp>/sqlx_backup` first (see the `update-sqlx`
|
||||
skill).
|
||||
- **The frontend proxies to its own worktree's backend port, not 8000.** Starting a
|
||||
backend on the wrong port leaves the UI up but every API call 502s, which reads like an
|
||||
application bug rather than a misconfiguration.
|
||||
- **Kill backends by pid scoped to this worktree's cwd** (`readlink /proc/<pid>/cwd`),
|
||||
never `pkill -f target/debug/windmill` — that kills every sibling worktree's backend.
|
||||
Beware that a `pgrep -f "<pattern>"` in a shell whose own command line contains
|
||||
`<pattern>` matches the shell itself.
|
||||
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
|
||||
|
||||
@@ -145,35 +106,8 @@ $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
|
||||
- **A simpler design found late is still the design.** Work already spent is not an argument
|
||||
for a shape, and neither is a clean review round, a passing suite, or a long PR thread. The
|
||||
signal to stop and re-derive rather than patch again is a change that keeps growing to defend
|
||||
its own structure: each review finding fixing an assumption the previous fix broke, the same
|
||||
class of bug reappearing somewhere new, or most of the diff being consequences of one early
|
||||
choice rather than the thing you set out to do. When that happens, say plainly what the
|
||||
simpler design is and what switching costs — a migration, a review cycle restarted from zero,
|
||||
work discarded — and let the user decide. Do not keep paying down the harder one because it
|
||||
is nearly finished, and do not present the accumulated cost as a reason to continue.
|
||||
- **Ship only the tests the PR needs.** A committed test must pin behavior a future change could plausibly break, and be the smallest setup that exercises the new logic. While developing, write as many exhaustive tests and do as much manual testing as you need to convince yourself the change works — then remove that scaffolding before marking the PR ready, keeping only the essential regression guard(s). A test that merely re-exercises pre-existing behavior, or needs elaborate fixtures to assert something trivial, is scaffolding: delete it. If nothing meaningful is left to guard, ship no test rather than a ceremonial one.
|
||||
- **Comments record constraints, not narration.** Write a comment only for what the code can't show: why a non-obvious approach is required, what breaks if it's "simplified" away. State each invariant once, at the place where someone would break it, in ≤4 lines. Don't describe what the next line does, don't repeat the same rationale at multiple sites, and don't address the PR reviewer (justifying a change belongs in the PR description, not the code). Reference nothing ephemeral — no numbered steps from your dev flow, no "the poller / the test does X" scaffolding, no transient state that won't exist for the next reader; keep only the essential, durable rationale. Describe the code as it is, never its drafting history: "we no longer do X", "unchanged behavior", "instead of the previous approach" are meaningless to a reader who never saw the earlier iteration — before finishing, reread your comments as if the current state is the only state that ever existed.
|
||||
- **Comments record constraints, not narration.** Write a comment only for what the code can't show: why a non-obvious approach is required, what breaks if it's "simplified" away. State each invariant once, at the place where someone would break it, in ≤4 lines. Don't describe what the next line does, don't repeat the same rationale at multiple sites, and don't address the PR reviewer (justifying a change belongs in the PR description, not the code). Describe the code as it is, never its drafting history: "we no longer do X", "unchanged behavior", "instead of the previous approach" are meaningless to a reader who never saw the earlier iteration — before finishing, reread your comments as if the current state is the only state that ever existed.
|
||||
- **Never attribute work to a specific customer, account, or "requested by a customer" in repo-tracked content** (PR descriptions, commit messages, code comments, docs). Describe changes by their technical motivation instead.
|
||||
|
||||
-1699
File diff suppressed because it is too large
Load Diff
-57
@@ -1,57 +0,0 @@
|
||||
# Windmill
|
||||
|
||||
Open-source platform for internal tools, workflows, API integrations, background jobs and UIs. This file pins the vocabulary that is specific to Windmill's domain, so that code, docs and reviews name the same thing the same way.
|
||||
|
||||
## Language
|
||||
|
||||
### Flows
|
||||
|
||||
**Step**:
|
||||
One node of a flow — the unit a user selects in the graph and configures in the right-hand panel. Typed as `FlowModule` in code.
|
||||
_Avoid_: module (ambiguous with the architectural sense), node, action
|
||||
|
||||
**Step setting**:
|
||||
A per-step runtime option stored on the step itself: retries, error handling, timeout, concurrency limit, priority, cache, debounce, early stop, skip, suspend, sleep, lifetime. Distinct from the step's inputs and its code. The panel that edits them is the **run settings** tab; a single setting is still a step setting.
|
||||
_Avoid_: advanced setting, step config, flow option
|
||||
|
||||
**Configured**:
|
||||
Said of a step setting whose config object is present on the step. Deliberately not the same as "would change the runtime's behaviour" — a setting can be configured and still be a no-op (`sleep` of `0`). Every surface that answers "is this setting on?" answers it this way.
|
||||
_Avoid_: enabled, active, effective
|
||||
|
||||
**Trigger step**:
|
||||
The first step of a polling flow. It runs on a schedule and returns the items found since its last run; an empty return means there is nothing to process and the flow stops early, marked skipped rather than failed.
|
||||
_Avoid_: poll script, trigger node, schedule step
|
||||
|
||||
**Default predicate**:
|
||||
The `stop_after_if` expression seeded onto a trigger step at creation, encoding what "nothing new" looks like. One value, owned in one place, shared by every path that creates a trigger step.
|
||||
|
||||
**Connect**:
|
||||
Arming an input so that the next property picked fills it. A property can be picked from the prop picker or, when the panel is docked beside the graph, by clicking a step node's output. At most one input is armed per panel, so a pick always has exactly one destination.
|
||||
_Avoid_: link, bind, plug (the icon is a plug; the action is connecting)
|
||||
|
||||
**Step input**:
|
||||
One argument of a step, edited in the step's input form. Its prop picker is a pane beside the form, always visible, so previous results can be browsed without connecting.
|
||||
_Avoid_: argument field, param
|
||||
|
||||
**Expression input**:
|
||||
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
|
||||
_Avoid_: JS field, code input
|
||||
|
||||
### Permissions
|
||||
|
||||
**Member**:
|
||||
A user or group granted a role on a folder, a group, or an item's extra ACL. The list of them is
|
||||
"Members (n)" everywhere it is shown, and one is added with "Add member".
|
||||
_Avoid_: participant, collaborator, owner, ACL entry, permission (that names the concept, not the people)
|
||||
|
||||
**Role**:
|
||||
The access level a member holds: viewer, writer or admin on a folder; member or admin on a group.
|
||||
Viewers read, writers also edit, admins also manage the members. A group role of **manager** —
|
||||
manages the group without belonging to it — is a legacy state the UI shows and can leave, but
|
||||
offers no way to enter.
|
||||
_Avoid_: permission level, access level, rank
|
||||
|
||||
**Owner**:
|
||||
Reserved for the path prefix that says where an item lives — `u/alice` or `f/team`. A folder's
|
||||
`owners` column in the database is its admin members; call those admins, never owners, in the UI.
|
||||
_Avoid_: using "owner" for a folder admin
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,27 +1,18 @@
|
||||
{
|
||||
layer4 {
|
||||
:25 {
|
||||
route {
|
||||
proxy {
|
||||
upstream windmill_server:2525
|
||||
}
|
||||
proxy {
|
||||
to windmill_server:2525
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{$BASE_URL} {
|
||||
# Default to all interfaces (IPv4 + IPv6) when ADDRESS is unset. A bare
|
||||
# `bind {$ADDRESS}` with an empty value makes Caddy >= 2.9 drop this whole
|
||||
# site, silently disabling the HTTP proxy while the :25 layer4 listener stays up.
|
||||
bind {$ADDRESS:0.0.0.0 ::}
|
||||
bind {$ADDRESS}
|
||||
|
||||
# Extra services: LSP, Multiplayer, Debugger (windmill_extra gateway).
|
||||
# reverse_proxy only reads its first argument as a matcher, so listing
|
||||
# several paths inline turns the rest into upstream addresses. The paths
|
||||
# have to go through a named matcher.
|
||||
@extra path /ws/* /ws_mp/* /ws_debug/*
|
||||
reverse_proxy @extra http://windmill_extra:3000
|
||||
# Extra services: LSP, Multiplayer, Debugger (windmill_extra gateway)
|
||||
reverse_proxy /ws/* /ws_mp/* /ws_debug/* http://windmill_extra:3000
|
||||
|
||||
# Search indexer, Enterprise Edition (windmill_indexer:8002)
|
||||
# reverse_proxy /api/srch/* http://windmill_indexer:8002
|
||||
|
||||
+27
-34
@@ -1,7 +1,7 @@
|
||||
ARG DEBIAN_IMAGE=debian:trixie-slim
|
||||
ARG RUST_IMAGE=rust:1.97-slim-trixie
|
||||
ARG DEBIAN_IMAGE=debian:bookworm-slim
|
||||
ARG RUST_IMAGE=rust:1.93-slim-bookworm
|
||||
|
||||
FROM debian:trixie-slim AS nsjail
|
||||
FROM debian:bookworm-slim AS nsjail
|
||||
|
||||
WORKDIR /nsjail
|
||||
|
||||
@@ -9,12 +9,12 @@ RUN apt-get -y update \
|
||||
&& apt-get install -y \
|
||||
bison=2:3.8.* \
|
||||
flex=2.6.* \
|
||||
g++=4:14.2.* \
|
||||
gcc=4:14.2.* \
|
||||
git=1:2.47.* \
|
||||
g++=4:12.2.* \
|
||||
gcc=4:12.2.* \
|
||||
git=1:2.39.* \
|
||||
libprotobuf-dev=3.21.* \
|
||||
libnl-route-3-dev=3.7.* \
|
||||
make=4.4.* \
|
||||
make=4.3-4.1 \
|
||||
pkg-config=1.8.* \
|
||||
protobuf-compiler=3.21.*
|
||||
|
||||
@@ -44,16 +44,13 @@ FROM rust_base AS windmill_duckdb_ffi_internal_builder
|
||||
|
||||
WORKDIR /windmill-duckdb-ffi-internal
|
||||
|
||||
RUN apt-get update && apt-get install -y clang=1:19.0* libclang-dev=1:19.0* cmake=3.31.* && \
|
||||
RUN apt-get update && apt-get install -y clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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
|
||||
|
||||
@@ -82,8 +79,6 @@ COPY /python-client/docs/ /frontend/static/pydocs/
|
||||
RUN npm run generate-backend-client
|
||||
ENV NODE_OPTIONS "--max-old-space-size=8192"
|
||||
ARG VITE_BASE_URL ""
|
||||
# Must be declared for the build-arg to reach the bundle. See frontend/svelte.config.js.
|
||||
ARG WM_BUILD_VERSION=""
|
||||
# Read more about macro in docker/dev.nu
|
||||
# -- MACRO-SPREAD-WASM-PARSER-DEV-ONLY -- #
|
||||
RUN npm run build
|
||||
@@ -103,7 +98,7 @@ ARG features=""
|
||||
|
||||
COPY --from=planner /windmill/recipe.json recipe.json
|
||||
|
||||
RUN apt-get update && apt-get install -y libxml2-dev=2.12.* libxmlsec1-dev=1.2.* libkrb5-dev libsasl2-dev libcurl4-openssl-dev clang=1:19.0* libclang-dev=1:19.0* cmake=3.31.* && \
|
||||
RUN apt-get update && apt-get install -y libxml2-dev=2.9.* libxmlsec1-dev=1.2.* libkrb5-dev libsasl2-dev libcurl4-openssl-dev clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -140,10 +135,11 @@ FROM ${DEBIAN_IMAGE}
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG KUBECTL_VERSION=1.36.2
|
||||
ARG HELM_VERSION=3.21.4
|
||||
ARG POWERSHELL_DEB_VERSION=7.5.0-1
|
||||
ARG KUBECTL_VERSION=1.28.7
|
||||
ARG HELM_VERSION=3.14.3
|
||||
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
|
||||
ARG GO_VERSION=1.26.8
|
||||
ARG GO_VERSION=1.26.0
|
||||
ARG APP=/usr/src/app
|
||||
ARG WITH_POWERSHELL=true
|
||||
ARG WITH_KUBECTL=true
|
||||
@@ -167,15 +163,14 @@ ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 tini gnupg libargon2-1 \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini gnupg lsb-release \
|
||||
&& if echo "$features" | grep -q "ee"; then apt-get install -y --no-install-recommends libsasl2-modules-gssapi-mit krb5-user; fi \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
|
||||
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release; echo "$VERSION_CODENAME")-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& apt-get clean \
|
||||
@@ -188,14 +183,12 @@ RUN if [ "$WITH_GIT" = "true" ]; then \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
else echo 'Building the image without git'; fi;
|
||||
|
||||
# PowerShell ships as a tarball: the upstream .deb depends on libicu<=74 which no longer exists in trixie
|
||||
RUN if [ "$WITH_POWERSHELL" = "true" ]; then \
|
||||
case "$TARGETPLATFORM" in \
|
||||
"linux/amd64") pwsh_arch=x64 ;; \
|
||||
"linux/arm64") pwsh_arch=arm64 ;; \
|
||||
*) pwsh_arch="" ;; \
|
||||
esac; \
|
||||
if [ -n "$pwsh_arch" ]; then apt-get update -y && apt install libicu76 -y && wget -O powershell.tar.gz "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-${pwsh_arch}.tar.gz" && apt-get clean \
|
||||
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then apt-get update -y && apt install libicu-dev -y && wget -O 'pwsh.deb' "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell_${POWERSHELL_DEB_VERSION}.deb_amd64.deb" && apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* && \
|
||||
dpkg --install 'pwsh.deb' && \
|
||||
rm 'pwsh.deb'; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get update -y && apt install libicu-dev -y && wget -O powershell.tar.gz "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-arm64.tar.gz" && apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /opt/microsoft/powershell/7 && \
|
||||
tar zxf powershell.tar.gz -C /opt/microsoft/powershell/7 && \
|
||||
@@ -240,7 +233,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Preinstall python runtimes to temp build location (will copy with world-writable perms later)
|
||||
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
|
||||
@@ -250,8 +243,8 @@ RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_r
|
||||
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode
|
||||
|
||||
|
||||
RUN curl -sL https://deb.nodesource.com/setup_24.x | bash -
|
||||
RUN apt-get -y update && apt-get install -y --no-install-recommends curl procps nodejs awscli && apt-get clean \
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
RUN apt-get -y update && apt-get install -y curl procps nodejs awscli && apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# go build is slower the first time it is ran, so we prewarm it in the build
|
||||
@@ -287,7 +280,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.4.0 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# Install windmill CLI
|
||||
RUN bun install -g windmill-cli \
|
||||
@@ -299,7 +292,7 @@ RUN bun install -g windmill-cli \
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash \
|
||||
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
|
||||
|
||||
COPY --from=php:8.3.33-cli-trixie /usr/local/bin/php /usr/bin/php
|
||||
COPY --from=php:8.3.30-cli-bookworm /usr/local/bin/php /usr/bin/php
|
||||
COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
@@ -310,13 +303,13 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo"
|
||||
ENV LD_LIBRARY_PATH="."
|
||||
|
||||
# nsjail runtime deps and binary
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32t64 libnl-route-3-200 libnl-3-200 \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
# crane: pulls + flattens images for the sandboxed container runtime (`# sandbox <image>`).
|
||||
# Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md.
|
||||
ARG CRANE_VERSION=v0.21.7
|
||||
ARG CRANE_VERSION=v0.20.6
|
||||
RUN arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \
|
||||
wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \
|
||||
|
||||
@@ -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="./CONTRIBUTING.md">Contributing</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="https://www.windmill.dev/docs/misc/contributing">Contributor's guide</a>
|
||||
</p>
|
||||
|
||||
# Windmill - Developer platform for APIs, background jobs, workflows and UIs
|
||||
@@ -62,7 +62,6 @@ 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)
|
||||
|
||||
@@ -261,8 +260,6 @@ 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 |
|
||||
@@ -285,7 +282,6 @@ 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
|
||||
|
||||
@@ -331,12 +327,6 @@ 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">
|
||||
|
||||
+204
-10
@@ -1,14 +1,208 @@
|
||||
# AI Evals
|
||||
# AI Evals Authoring Guide
|
||||
|
||||
Black-box benchmark cases for the Windmill AI generation modes (`flow`, `app`,
|
||||
`script`, `cli`, `global`).
|
||||
This folder contains black-box benchmark cases for:
|
||||
|
||||
**Authoring and running cases is documented in the `ai-evals` skill** — load it
|
||||
before adding/changing a case or running a benchmark. Claude Code reads
|
||||
`.claude/skills/ai-evals/SKILL.md`; Codex and Pi read
|
||||
`.agents/skills/ai-evals/SKILL.md` (same canonical file). Invoke with `/ai-evals` in
|
||||
Claude Code, `$ai-evals` in Codex, or `pi --skill ai-evals`.
|
||||
- `flow`
|
||||
- `app`
|
||||
- `script`
|
||||
- `cli`
|
||||
- `global`
|
||||
|
||||
For AI chat / copilot changes that these evals measure, see the `ai-chat` skill.
|
||||
The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape.
|
||||
|
||||
The full case format, fields, and fixture details remain in `ai_evals/README.md`.
|
||||
## Core rules
|
||||
|
||||
1. Write prompts like a real user request.
|
||||
2. Prefer behavior, inputs, constraints, and outcomes over internal implementation details.
|
||||
3. Keep deterministic validation narrow and hard.
|
||||
4. Put semantic expectations in `judgeChecklist`.
|
||||
5. Use `expected` fixtures only when exact structure really matters.
|
||||
|
||||
## Prompt writing
|
||||
|
||||
Prompts should sound like something a user would naturally ask.
|
||||
|
||||
Good:
|
||||
|
||||
- "Create a flow that routes support requests based on customer tier."
|
||||
- "Add a reset button that sets the counter back to 0."
|
||||
- "Create a flow that reuses the existing greeting script instead of duplicating the logic."
|
||||
|
||||
Bad:
|
||||
|
||||
- "Use `branchone` with 3 branches and a default branch."
|
||||
- "Create a `rawscript` step with this exact topology."
|
||||
- "This is a benchmark harness."
|
||||
|
||||
Do not write prompts as if the user knows Windmill internals unless the case is explicitly testing a power-user workflow.
|
||||
|
||||
## Flow-specific rules
|
||||
|
||||
This is the main principle you asked for:
|
||||
|
||||
- flow prompts should read like requests from a user who does not know the product internals
|
||||
- the user should ask for behavior, not for `branchone`, `branchall`, `rawscript`, `preprocessor_module`, `failure_module`, exact graph topology, or other internal constructs
|
||||
|
||||
That means:
|
||||
|
||||
- creation cases should describe the business behavior and expected result
|
||||
- modification cases may mention existing step names, because the user can see the current flow
|
||||
- only mention special Windmill constructs when the case is explicitly about those constructs
|
||||
|
||||
Examples:
|
||||
|
||||
- acceptable creation prompt:
|
||||
"Create a purchase approval flow that pauses for approval and asks the approver for a comment."
|
||||
- avoid:
|
||||
"Create a suspend step with one required event and a resume form."
|
||||
|
||||
For flow cases, do not fail a case just because the model chose a different valid topology.
|
||||
|
||||
## App-specific rules
|
||||
|
||||
App prompts should focus on user-visible behavior:
|
||||
|
||||
- what the UI should let the user do
|
||||
- what should persist
|
||||
- what backend behavior is needed
|
||||
|
||||
Avoid prompting in terms of React structure, component names, or implementation unless the case is specifically about editing an existing app.
|
||||
|
||||
## CLI-specific rules
|
||||
|
||||
CLI prompts can be more explicit about paths and file names because real CLI users often do specify them.
|
||||
|
||||
Still, avoid benchmark phrasing. The prompt should read like a repo task, not a harness instruction.
|
||||
|
||||
When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior.
|
||||
|
||||
## Global-specific rules
|
||||
|
||||
Global prompts should exercise workspace-level drafting behavior:
|
||||
|
||||
- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant
|
||||
- writing AI drafts rather than saving or deploying by default
|
||||
- producing coherent multi-artifact changes when the request crosses artifact boundaries
|
||||
|
||||
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
|
||||
|
||||
Datatable cases should set `skipJudge: true` and validate through tool-use
|
||||
(`requiredToolsUsed` / `forbiddenToolsUsed`) and SQL-argument assertions
|
||||
(`toolCallArgs` with `stringIncludesAnyOf`, e.g. `['select']`, `['create table']`,
|
||||
`['update', 'insert into']`). Two reasons the judge is unreliable here:
|
||||
|
||||
- `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql`
|
||||
produce no drafts, and the global judge only sees the drafts artifact — it
|
||||
scores a no-draft conversational answer as empty (same as the
|
||||
`askUserQuestion` cases).
|
||||
- Even a case that *does* produce a draft (a script reading the data table via
|
||||
`wmill.datatable()` at runtime) is mis-judged: the judge has no datatable SDK
|
||||
reference and penalizes correct `wmill.datatable()` usage as wrong. Verify the
|
||||
SDK call deterministically instead — `requiredDrafts.valueIncludes: ['wmill.datatable(']`
|
||||
plus forbidding `exec_datatable_sql` (keeping chat-time SQL distinct from
|
||||
runtime SDK use).
|
||||
|
||||
`stringIncludesAnyOf` is existential over calls (at least one matching call), so a
|
||||
mutation case still passes when the model mixes its UPDATE/INSERT with
|
||||
verification SELECTs. The in-memory engine (`datatableSqlEngine.ts`) is stateful
|
||||
within a case — writes persist, so a model that re-queries to verify its
|
||||
CREATE/UPDATE sees the change and does not loop. But the engine is best-effort
|
||||
(SELECT returns all rows of the referenced/first table with no WHERE/projection),
|
||||
so still never assert specific returned row values. Seed data via
|
||||
`workspace.datatables` in the `initial` fixture (see README).
|
||||
|
||||
## Deterministic validation
|
||||
|
||||
Use deterministic validation only for hard failures such as:
|
||||
|
||||
- missing required files
|
||||
- unexpected extra files when the prompt says not to create them
|
||||
- syntax errors
|
||||
- unresolved flow refs
|
||||
- missing required special modules or suspend config
|
||||
- obvious artifact corruption
|
||||
|
||||
Do not use deterministic validation to enforce one preferred implementation for broad creation tasks.
|
||||
|
||||
Examples of bad hard checks:
|
||||
|
||||
- exact step topology for a creation flow
|
||||
- exact branch structure when the prompt only asked for routing behavior
|
||||
- exact input shape when multiple reasonable shapes are acceptable
|
||||
|
||||
## Judge checklist
|
||||
|
||||
Every non-trivial case should have a `judgeChecklist`.
|
||||
|
||||
The checklist should capture:
|
||||
|
||||
- the user-visible behavior that must be present
|
||||
- important constraints
|
||||
- key completion criteria
|
||||
|
||||
The checklist should not duplicate low-level implementation details unless they are truly required by the task.
|
||||
|
||||
Good checklist items:
|
||||
|
||||
- "the flow calculates the order total with 8% tax"
|
||||
- "the app persists recipes appropriately for a raw Windmill app"
|
||||
- "the flow reuses the existing workspace script instead of rewriting the logic"
|
||||
|
||||
Bad checklist items:
|
||||
|
||||
- "uses `branchone`"
|
||||
- "contains a `rawscript` node"
|
||||
|
||||
## When to use `expected`
|
||||
|
||||
Use `expected` fixtures when the case is structure-sensitive, for example:
|
||||
|
||||
- exact file creation
|
||||
- exact script content
|
||||
- modification cases where a specific file must change in a specific way
|
||||
- cases where preserving an existing structure is part of the requirement
|
||||
|
||||
Do not use a full `expected` artifact as the semantic oracle for broad creation tasks when multiple valid outputs should pass.
|
||||
|
||||
## When to use `initial`
|
||||
|
||||
Use `initial` when the benchmark is about:
|
||||
|
||||
- editing an existing artifact
|
||||
- reusing existing workspace assets
|
||||
- preserving existing behavior while adding a change
|
||||
|
||||
If the case is greenfield, prefer no `initial`.
|
||||
|
||||
## Case design ladder
|
||||
|
||||
Prefer suites that get gradually harder:
|
||||
|
||||
1. trivial create case
|
||||
2. realistic create case
|
||||
3. reuse-existing-assets case
|
||||
4. modification case
|
||||
5. refactor case
|
||||
6. edge-case or niche product behavior
|
||||
|
||||
The last cases in a suite should cover unusual or product-specific behavior.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
Avoid these:
|
||||
|
||||
- benchmark framing in prompts
|
||||
- over-specified internal topology for creation tasks
|
||||
- judge checklists that just restate implementation details
|
||||
- deterministic validation that encodes one preferred solution
|
||||
- fixtures that are so minimal or brittle that they create false negatives
|
||||
|
||||
## Before adding a case
|
||||
|
||||
Ask:
|
||||
|
||||
1. Would a real user plausibly write this prompt?
|
||||
2. If the model solves it in a different valid way, would the case still pass?
|
||||
3. Are the hard deterministic checks only catching objectively broken output?
|
||||
4. Does the `judgeChecklist` describe the real success criteria?
|
||||
5. If this case fails, will the reason be understandable from the saved artifacts?
|
||||
|
||||
@@ -150,36 +150,6 @@ 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
|
||||
@@ -280,10 +250,6 @@ 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.
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
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()],
|
||||
};
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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,10 +1,9 @@
|
||||
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 {
|
||||
globalToolsFor,
|
||||
globalTools,
|
||||
prepareGlobalSystemMessage,
|
||||
prepareGlobalUserMessage,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
|
||||
@@ -13,19 +12,8 @@ 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";
|
||||
@@ -76,35 +64,11 @@ 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[];
|
||||
@@ -116,20 +80,11 @@ export interface GlobalEvalOptions {
|
||||
workspaceFixtures?: BenchmarkWorkspaceRunnables;
|
||||
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -143,77 +98,27 @@ export async function runGlobalEval(
|
||||
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
|
||||
|
||||
clearGlobalDrafts(workspaceRoot);
|
||||
registerBenchmarkWorkspaceRunnables(
|
||||
workspaceRoot,
|
||||
options.workspaceFixtures ?? {},
|
||||
);
|
||||
registerBenchmarkWorkspaceRunnables(workspaceRoot, 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 baseSystemMessage = prepareGlobalSystemMessage(undefined, {
|
||||
user: options.user,
|
||||
previewTools: options.sessionChat ?? false,
|
||||
});
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
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(),
|
||||
}),
|
||||
tools: [
|
||||
...getGlobalEvalTools(options.sessionChat ?? false),
|
||||
...(planMode?.tools ?? []),
|
||||
],
|
||||
helpers: panel
|
||||
? { ...evalArtifacts.helpers, openArtifact: panel.openArtifact }
|
||||
: evalArtifacts.helpers,
|
||||
systemMessage: prepareGlobalSystemMessage(undefined, { user: options.user }),
|
||||
userMessage: prepareGlobalUserMessage(
|
||||
userPrompt,
|
||||
[],
|
||||
injectActiveEditorContext ? { workspace: workspaceRoot } : {},
|
||||
),
|
||||
tools: getGlobalEvalTools(),
|
||||
helpers: {},
|
||||
apiKey,
|
||||
getOutput: async () => ({
|
||||
...(await collectGlobalDraftState(workspaceRoot)),
|
||||
artifacts: evalArtifacts.snapshot(),
|
||||
}),
|
||||
getOutput: () => collectGlobalDraftState(workspaceRoot),
|
||||
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
|
||||
onAssistantToken: options.runContext?.onAssistantChunk,
|
||||
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
|
||||
@@ -234,7 +139,6 @@ 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,
|
||||
@@ -242,7 +146,6 @@ export async function runGlobalEval(
|
||||
finalContextTokens: rawResult.finalContextTokens,
|
||||
};
|
||||
} finally {
|
||||
panel?.dispose();
|
||||
clearGlobalDrafts(workspaceRoot);
|
||||
clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
|
||||
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
|
||||
@@ -310,15 +213,10 @@ function clearLiveEditorDrafts(
|
||||
}
|
||||
}
|
||||
|
||||
// Gate session-preview tools on sessionChat, as production's globalToolsFor does.
|
||||
function getGlobalEvalTools(sessionChat: boolean): ProductionTool<{}>[] {
|
||||
function getGlobalEvalTools(): ProductionTool<{}>[] {
|
||||
const disableSearchApp = process.env[DISABLE_SEARCH_APP_ENV] === "1";
|
||||
return (
|
||||
globalToolsFor({ sessionPreview: sessionChat }) as ProductionTool<{}>[]
|
||||
)
|
||||
.filter(
|
||||
(tool) => !(disableSearchApp && tool.def.function.name === "search_app"),
|
||||
)
|
||||
return (globalTools as ProductionTool<{}>[])
|
||||
.filter((tool) => !(disableSearchApp && tool.def.function.name === "search_app"))
|
||||
.map((tool) => {
|
||||
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
|
||||
return tool;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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,15 +43,6 @@ 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;
|
||||
@@ -77,9 +68,6 @@ export async function runEval<THelpers, TOutput>(
|
||||
onAssistantToken,
|
||||
onAssistantMessageEnd,
|
||||
onToolCall,
|
||||
isPlanModeActive,
|
||||
isToolAvailable,
|
||||
getSystemMessage,
|
||||
} = params;
|
||||
let shouldEmitMessageStart = true;
|
||||
|
||||
@@ -131,11 +119,6 @@ export async function runEval<THelpers, TOutput>(
|
||||
} = {
|
||||
setToolStatus: () => {},
|
||||
removeToolStatus: () => {},
|
||||
isPlanModeActive,
|
||||
// Accepts the run form exactly as the model prefilled it: there is nobody here to
|
||||
// edit the arguments, so a case can assert what the model proposed but never how
|
||||
// it reacts to the user changing something.
|
||||
requestRunArgs: async (_toolId, form) => form.args,
|
||||
onNewToken: (token: string) => {
|
||||
if (shouldEmitMessageStart) {
|
||||
onAssistantMessageStart?.();
|
||||
@@ -157,17 +140,8 @@ export async function runEval<THelpers, TOutput>(
|
||||
try {
|
||||
const result = await runChatLoop({
|
||||
messages,
|
||||
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;
|
||||
},
|
||||
systemMessage,
|
||||
tools: wrappedTools,
|
||||
helpers,
|
||||
abortController,
|
||||
callbacks,
|
||||
|
||||
@@ -5,17 +5,12 @@ import type {
|
||||
Flow,
|
||||
Job,
|
||||
ListableApp,
|
||||
ListableResource,
|
||||
ListableVariable,
|
||||
Resource,
|
||||
Script
|
||||
} from '../../../frontend/src/lib/gen'
|
||||
import type {
|
||||
DataTableTables,
|
||||
DataTableTableSchema,
|
||||
EndpointTool,
|
||||
GetDraftForUserResponse,
|
||||
GetOwnDraftResponse,
|
||||
ListDraftsResponse,
|
||||
ScriptLang,
|
||||
UpdateDraftResponse,
|
||||
@@ -57,40 +52,6 @@ export interface BenchmarkWorkspaceApp {
|
||||
}
|
||||
}
|
||||
|
||||
export interface BenchmarkWorkspaceVariable {
|
||||
path: string
|
||||
value: string
|
||||
is_secret: boolean
|
||||
description?: string
|
||||
labels?: string[]
|
||||
ws_specific?: boolean
|
||||
}
|
||||
|
||||
/** An AI provider resource of the benchmark workspace, as an AI agent step would reference it.
|
||||
* `models` stands in for the provider's model listing, which no eval run can reach. */
|
||||
export interface BenchmarkWorkspaceAiProvider {
|
||||
path: string
|
||||
/** Resource type, which for AI resources is the provider kind (`anthropic`, `openai`, ...). */
|
||||
kind: string
|
||||
/** What this resource's `/ai/proxy/models` listing returns. */
|
||||
models?: string[]
|
||||
/** Set to point the resource at a gateway rather than the provider's own API. */
|
||||
base_url?: string
|
||||
/** Models the workspace AI settings selected for this provider. */
|
||||
configuredModels?: string[]
|
||||
/** Marks this provider's first configured model as the workspace default. */
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/** A plain (non-AI) resource of the benchmark workspace, for cases about referencing a
|
||||
* credential — passing one as a run argument, say. `value` is what `get_resource` returns. */
|
||||
export interface BenchmarkWorkspaceResource {
|
||||
path: string
|
||||
resource_type: string
|
||||
value?: Record<string, unknown>
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface BenchmarkWorkspaceJob {
|
||||
/** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */
|
||||
id?: string
|
||||
@@ -106,9 +67,6 @@ export interface BenchmarkWorkspaceRunnables {
|
||||
scripts?: BenchmarkWorkspaceScript[]
|
||||
flows?: BenchmarkWorkspaceFlow[]
|
||||
apps?: BenchmarkWorkspaceApp[]
|
||||
variables?: BenchmarkWorkspaceVariable[]
|
||||
aiProviders?: BenchmarkWorkspaceAiProvider[]
|
||||
resources?: BenchmarkWorkspaceResource[]
|
||||
datatables?: BenchmarkDatatableSeed[]
|
||||
jobs?: BenchmarkWorkspaceJob[]
|
||||
}
|
||||
@@ -132,13 +90,6 @@ export function resetBenchmarkMockBackend(): void {
|
||||
benchmarkDrafts.clear()
|
||||
}
|
||||
|
||||
// Stand-in for FolderService.createFolder so the global create_folder tool runs in
|
||||
// memory instead of mutating the real backend. Folders aren't otherwise modelled
|
||||
// (no folder-listing in evals), so this just echoes the created name.
|
||||
export function createBenchmarkFolder(_workspace: string, name: string): string {
|
||||
return name
|
||||
}
|
||||
|
||||
export function registerBenchmarkWorkspace(workspace: string): void {
|
||||
benchmarkWorkspaces.add(workspace)
|
||||
}
|
||||
@@ -246,156 +197,6 @@ export function getBenchmarkAppByPath(workspace: string, path: string): AppWithL
|
||||
return app ? buildBenchmarkApp(app) : null
|
||||
}
|
||||
|
||||
function buildBenchmarkVariable(
|
||||
workspace: string,
|
||||
seed: BenchmarkWorkspaceVariable,
|
||||
decryptSecret: boolean
|
||||
): ListableVariable {
|
||||
return {
|
||||
workspace_id: workspace,
|
||||
path: seed.path,
|
||||
// Mirror `get_variable`: a secret's value is withheld unless decryption was
|
||||
// asked for, so a reader genuinely cannot see it.
|
||||
value: seed.is_secret && !decryptSecret ? undefined : seed.value,
|
||||
is_secret: seed.is_secret,
|
||||
description: seed.description,
|
||||
labels: seed.labels,
|
||||
ws_specific: seed.ws_specific ?? false,
|
||||
extra_perms: {},
|
||||
edited_at: BENCHMARK_TIMESTAMP
|
||||
}
|
||||
}
|
||||
|
||||
export function listBenchmarkVariables(workspace: string): ListableVariable[] | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
if (!runnables) {
|
||||
return null
|
||||
}
|
||||
// The list route never decrypts.
|
||||
return (runnables.variables ?? []).map((seed) => buildBenchmarkVariable(workspace, seed, false))
|
||||
}
|
||||
|
||||
/** AI provider resources of a benchmark workspace, shaped like `ResourceService.listResource`
|
||||
* rows (which carry no value). Null when the workspace is not a benchmark one. */
|
||||
export function listBenchmarkAiProviderResources(workspace: string): ListableResource[] | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
if (!runnables) {
|
||||
return null
|
||||
}
|
||||
return (runnables.aiProviders ?? []).map((seed) => ({
|
||||
workspace_id: workspace,
|
||||
path: seed.path,
|
||||
resource_type: seed.kind,
|
||||
value: null,
|
||||
is_oauth: false,
|
||||
is_linked: false,
|
||||
is_refreshed: false,
|
||||
extra_perms: {},
|
||||
edited_at: BENCHMARK_TIMESTAMP
|
||||
}))
|
||||
}
|
||||
|
||||
/** Plain seeded resources of a benchmark workspace, shaped like `ResourceService.listResource`
|
||||
* rows. Null when the workspace is not a benchmark one. */
|
||||
export function listBenchmarkPlainResources(workspace: string): ListableResource[] | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
if (!runnables) {
|
||||
return null
|
||||
}
|
||||
return (runnables.resources ?? []).map((seed) => ({
|
||||
workspace_id: workspace,
|
||||
path: seed.path,
|
||||
resource_type: seed.resource_type,
|
||||
description: seed.description,
|
||||
value: null,
|
||||
is_oauth: false,
|
||||
is_linked: false,
|
||||
is_refreshed: false,
|
||||
extra_perms: {},
|
||||
edited_at: BENCHMARK_TIMESTAMP
|
||||
}))
|
||||
}
|
||||
|
||||
/** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both
|
||||
* seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI
|
||||
* providers too, and a case that lists resources and then reads one by path would otherwise get
|
||||
* a row it cannot fetch. */
|
||||
export function getBenchmarkResource(workspace: string, path: string): Resource | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
const seed = runnables?.resources?.find((entry) => entry.path === path)
|
||||
if (seed) {
|
||||
return {
|
||||
workspace_id: workspace,
|
||||
path: seed.path,
|
||||
resource_type: seed.resource_type,
|
||||
description: seed.description,
|
||||
value: seed.value ?? {},
|
||||
is_oauth: false,
|
||||
extra_perms: {}
|
||||
} as Resource
|
||||
}
|
||||
const provider = runnables?.aiProviders?.find((entry) => entry.path === path)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
workspace_id: workspace,
|
||||
path: provider.path,
|
||||
resource_type: provider.kind,
|
||||
value: getBenchmarkResourceValue(workspace, path) ?? {},
|
||||
is_oauth: false,
|
||||
extra_perms: {}
|
||||
} as Resource
|
||||
}
|
||||
|
||||
/** The value of a seeded resource. For an AI provider only the endpoint fields are modelled — a
|
||||
* key is never needed, because no eval run calls the provider through this resource. */
|
||||
export function getBenchmarkResourceValue(
|
||||
workspace: string,
|
||||
path: string
|
||||
): Record<string, unknown> | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
const plain = runnables?.resources?.find((entry) => entry.path === path)
|
||||
if (plain) {
|
||||
return plain.value ?? {}
|
||||
}
|
||||
const seed = runnables?.aiProviders?.find((entry) => entry.path === path)
|
||||
if (!seed) {
|
||||
return null
|
||||
}
|
||||
return seed.base_url ? { base_url: seed.base_url } : {}
|
||||
}
|
||||
|
||||
/** The AI settings of a benchmark workspace, as `WorkspaceService.getCopilotInfo` returns them. */
|
||||
export function getBenchmarkAiConfig(workspace: string): Record<string, unknown> | null {
|
||||
const seeds = benchmarkWorkspaceRunnables.get(workspace)?.aiProviders
|
||||
if (!seeds) {
|
||||
return null
|
||||
}
|
||||
const providers: Record<string, unknown> = {}
|
||||
let defaultModel: { model: string; provider: string } | undefined
|
||||
for (const seed of seeds) {
|
||||
const models = seed.configuredModels ?? seed.models ?? []
|
||||
providers[seed.kind] = { resource_path: seed.path, models }
|
||||
if (seed.isDefault && models[0]) {
|
||||
defaultModel = { model: models[0], provider: seed.kind }
|
||||
}
|
||||
}
|
||||
return { providers, ...(defaultModel ? { default_model: defaultModel } : {}) }
|
||||
}
|
||||
|
||||
export function getBenchmarkVariableByPath(
|
||||
workspace: string,
|
||||
path: string,
|
||||
decryptSecret = true
|
||||
): ListableVariable | null {
|
||||
const seed = benchmarkWorkspaceRunnables
|
||||
.get(workspace)
|
||||
?.variables?.find((entry) => entry.path === path)
|
||||
|
||||
return seed ? buildBenchmarkVariable(workspace, seed, decryptSecret) : null
|
||||
}
|
||||
|
||||
export function createBenchmarkCompletedJob(input: {
|
||||
workspace: string
|
||||
jobKind: CompletedJob['job_kind']
|
||||
@@ -486,28 +287,21 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
|
||||
/**
|
||||
* In-memory stand-in for the per-user draft backend (`DraftService`). The global
|
||||
* AI chat now persists and reads drafts through the backend DB instead of an
|
||||
* in-tab `UserDraft` cell, so the eval mocks the draft endpoints it exercises
|
||||
* (`updateDraft` / `getOwnDraft` / `getDraftForUser` / `listDrafts`) and keeps the
|
||||
* in-tab `UserDraft` cell, so the eval mocks the three draft endpoints it
|
||||
* exercises (`updateDraft` / `getDraftForUser` / `listDrafts`) and keeps the
|
||||
* saved values here, keyed by workspace + draft kind + storage path. Mirrors the
|
||||
* semantics of the production unit test's mock in
|
||||
* `frontend/src/lib/components/copilot/chat/global/core.test.ts`.
|
||||
*/
|
||||
const benchmarkDrafts = new Map<
|
||||
string,
|
||||
{ workspace: string; kind: UserDraftItemKind; path: string; value: unknown; createdAt: string }
|
||||
{ workspace: string; kind: UserDraftItemKind; path: string; value: unknown }
|
||||
>()
|
||||
|
||||
// Counter-based timestamps: deterministic run-to-run (same event order → same
|
||||
// values) but MONOTONIC per update, because production bumps a draft row's
|
||||
// created_at on every upsert and the diff snapshot cache keys patch reuse on
|
||||
// it — a fixed timestamp would serve stale patches after an edit. No eval
|
||||
// simulates a concurrent writer, so every save is accepted and the conflict
|
||||
// branch is never taken.
|
||||
let benchmarkDraftClock = 0
|
||||
function nextBenchmarkDraftTimestamp(): string {
|
||||
benchmarkDraftClock += 1
|
||||
return new Date(benchmarkDraftClock * 1000).toISOString()
|
||||
}
|
||||
// Fixed timestamp so artifacts stay deterministic. No eval simulates a
|
||||
// concurrent writer, so every save is accepted and the conflict branch is
|
||||
// never taken — the syncer just records this as its `last_sync` baseline.
|
||||
const BENCHMARK_DRAFT_TIMESTAMP = '1970-01-01T00:00:00.000Z'
|
||||
|
||||
function benchmarkDraftKey(workspace: string, kind: string, path: string): string {
|
||||
return `${workspace}::${kind}::${path}`
|
||||
@@ -538,8 +332,7 @@ export function seedBenchmarkDraft(
|
||||
workspace,
|
||||
kind,
|
||||
path,
|
||||
value,
|
||||
createdAt: nextBenchmarkDraftTimestamp()
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -552,7 +345,6 @@ export function updateBenchmarkDraft(input: {
|
||||
}): UpdateDraftResponse {
|
||||
const key = benchmarkDraftKey(input.workspace, input.kind, input.path)
|
||||
const value = input.requestBody?.value
|
||||
const createdAt = nextBenchmarkDraftTimestamp()
|
||||
if (value == null) {
|
||||
benchmarkDrafts.delete(key)
|
||||
} else {
|
||||
@@ -560,11 +352,10 @@ export function updateBenchmarkDraft(input: {
|
||||
workspace: input.workspace,
|
||||
kind: input.kind,
|
||||
path: input.path,
|
||||
value,
|
||||
createdAt
|
||||
value
|
||||
})
|
||||
}
|
||||
return { status: 'saved', current_timestamp: createdAt }
|
||||
return { status: 'saved', current_timestamp: BENCHMARK_DRAFT_TIMESTAMP }
|
||||
}
|
||||
|
||||
/** Mirror `DraftService.getDraftForUser`: 404-shaped throw when absent so the
|
||||
@@ -578,33 +369,7 @@ export function getBenchmarkDraftForUser(input: {
|
||||
if (!entry) {
|
||||
throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 })
|
||||
}
|
||||
return { value: entry.value, created_at: entry.createdAt }
|
||||
}
|
||||
|
||||
/** Mirror `DraftService.getOwnDraft`: `null` (200) when absent — unlike
|
||||
* `getDraftForUser`, absence is not an error on this route. */
|
||||
export function getBenchmarkOwnDraft(input: {
|
||||
workspace: string
|
||||
kind: UserDraftItemKind
|
||||
path: string
|
||||
}): GetOwnDraftResponse {
|
||||
const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path))
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
return { value: entry.value, created_at: entry.createdAt }
|
||||
}
|
||||
|
||||
/** Whether a deployed benchmark item exists for a draft row's kind+path —
|
||||
* drives `draft_only`, which production computes against the deployed tables. */
|
||||
function benchmarkDeployedExists(workspace: string, kind: UserDraftItemKind, path: string): boolean {
|
||||
if (kind === 'script') return Boolean(getBenchmarkScriptByPath(workspace, path))
|
||||
if (kind === 'flow') return Boolean(getBenchmarkFlowByPath(workspace, path))
|
||||
if (kind === 'app' || kind === 'raw_app') return Boolean(getBenchmarkAppByPath(workspace, path))
|
||||
if (kind === 'variable') return Boolean(getBenchmarkVariableByPath(workspace, path))
|
||||
// The remaining drawer kinds (resources/schedules/triggers) have no deployed
|
||||
// benchmark stores today.
|
||||
return false
|
||||
return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP }
|
||||
}
|
||||
|
||||
/** Mirror `DraftService.listDrafts`: metadata rows (no value) for a workspace. */
|
||||
@@ -615,9 +380,9 @@ export function listBenchmarkDrafts(workspace: string): ListDraftsResponse {
|
||||
kind: entry.kind,
|
||||
path: entry.path,
|
||||
summary: (entry.value as { summary?: string } | null)?.summary,
|
||||
draft_only: !benchmarkDeployedExists(workspace, entry.kind, entry.path),
|
||||
draft_only: true,
|
||||
legacy_draft: false,
|
||||
created_at: entry.createdAt
|
||||
created_at: BENCHMARK_DRAFT_TIMESTAMP
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -752,35 +517,6 @@ export function runBenchmarkScriptPreview(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export function runBenchmarkScriptByPath(input: {
|
||||
workspace: string
|
||||
path: string
|
||||
args?: Record<string, unknown>
|
||||
}): string {
|
||||
const script = getBenchmarkScriptByPath(input.workspace, input.path)
|
||||
return createBenchmarkCompletedJob({
|
||||
workspace: input.workspace,
|
||||
jobKind: 'script',
|
||||
success: script !== null,
|
||||
scriptPath: input.path,
|
||||
args: input.args,
|
||||
result:
|
||||
script !== null
|
||||
? {
|
||||
path: input.path,
|
||||
args: input.args ?? {},
|
||||
mocked: true
|
||||
}
|
||||
: {
|
||||
error: `Script "${input.path}" not found in benchmark workspace`
|
||||
},
|
||||
logs:
|
||||
script !== null
|
||||
? 'Mock benchmark script run completed successfully.'
|
||||
: `Script "${input.path}" not found in benchmark workspace.`
|
||||
})
|
||||
}
|
||||
|
||||
export function runBenchmarkFlowByPath(input: {
|
||||
workspace: string
|
||||
path: string
|
||||
@@ -936,385 +672,3 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
|
||||
raw_app: true
|
||||
}
|
||||
}
|
||||
|
||||
// ============= API endpoint catalog (McpService.listMcpTools + raw fetch) =============
|
||||
// The global chat's API catalog tools list endpoints via McpService and execute
|
||||
// them with a plain relative fetch('/api/...'), which has no meaning in the
|
||||
// vitest environment. A representative slice of the real catalog is served here,
|
||||
// and `handleBenchmarkApiFetch` answers the executed calls.
|
||||
|
||||
const BENCHMARK_MCP_TOOLS: EndpointTool[] = [
|
||||
{
|
||||
name: 'listWorkers',
|
||||
description: 'List workers',
|
||||
instructions: 'List all workers with their last ping and job counts.',
|
||||
path: '/workers/list',
|
||||
method: 'GET',
|
||||
query_params_schema: {
|
||||
type: 'object',
|
||||
properties: { page: { type: 'integer' }, per_page: { type: 'integer' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'listQueue',
|
||||
description: 'List queued jobs',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs/queue/list',
|
||||
method: 'GET',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' } },
|
||||
required: ['workspace']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'getJob',
|
||||
description: 'get job',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs_u/get/{id}',
|
||||
method: 'GET',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, id: { type: 'string', format: 'uuid' } },
|
||||
required: ['workspace', 'id']
|
||||
},
|
||||
query_params_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
no_logs: { type: 'boolean' },
|
||||
no_code: { type: 'boolean' },
|
||||
approval_token: { type: 'string' }
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'runScriptByPath',
|
||||
description: 'Run the deployed version of a script by path',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs/run/p/{path}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
},
|
||||
body_schema: { type: 'object', properties: {} }
|
||||
},
|
||||
{
|
||||
name: 'runFlowByPath',
|
||||
description: 'Run the deployed version of a flow by path',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/jobs/run/f/{path}',
|
||||
method: 'POST',
|
||||
path_params_schema: {
|
||||
type: 'object',
|
||||
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
|
||||
required: ['workspace', 'path']
|
||||
},
|
||||
body_schema: { type: 'object', properties: {} }
|
||||
},
|
||||
// Draft-covered endpoints, present so steering cases exercise the guard the
|
||||
// way production does (hidden from search, refused at call time).
|
||||
{
|
||||
name: 'getScriptByPath',
|
||||
description: 'Get a script by path',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/scripts/get/p/{path}',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'createFlow',
|
||||
description: 'Create a flow',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/flows/create',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
name: 'deleteSchedule',
|
||||
description: 'Delete a schedule',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/schedules/delete/{path}',
|
||||
method: 'DELETE'
|
||||
},
|
||||
{
|
||||
name: 'getVariable',
|
||||
description: 'Get a variable',
|
||||
instructions: '',
|
||||
path: '/w/{workspace}/variables/get/{path}',
|
||||
method: 'GET'
|
||||
}
|
||||
]
|
||||
|
||||
export function listBenchmarkMcpTools(): EndpointTool[] {
|
||||
return BENCHMARK_MCP_TOOLS
|
||||
}
|
||||
|
||||
/** A stand-in Windmill hub. `search_hub_scripts` and a `hub/` read go out over
|
||||
* relative `/api/...` fetches, which have no origin here, so without these the
|
||||
* hub tools throw and no case can exercise hub reuse. Serving fixtures rather
|
||||
* than the live hub also keeps assertions on script content stable as the real
|
||||
* hub republishes new versions. */
|
||||
const BENCHMARK_HUB_SCRIPTS = [
|
||||
{
|
||||
version_id: 22235,
|
||||
app: 'holded',
|
||||
summary: 'Send Document',
|
||||
terms: 'holded invoice document send email mail',
|
||||
language: 'bun',
|
||||
content: `//native
|
||||
type Holded = {
|
||||
apiKey: string;
|
||||
};
|
||||
/**
|
||||
* Send Document
|
||||
* Send a specific document by email.
|
||||
*/
|
||||
export async function main(
|
||||
auth: Holded,
|
||||
docType: string,
|
||||
documentId: string,
|
||||
body: {
|
||||
mailTemplateId?: string;
|
||||
emails: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
docIds?: string;
|
||||
},
|
||||
) {
|
||||
const url = new URL(
|
||||
\`https://api.holded.com/api/invoicing/v1/documents/\${docType}/\${documentId}/send\`,
|
||||
);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
key: auth.apiKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(\`\${response.status} \${text}\`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
`,
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['auth', 'docType', 'documentId', 'body'],
|
||||
properties: {
|
||||
auth: { type: 'object', format: 'resource-holded' },
|
||||
docType: { type: 'string' },
|
||||
documentId: { type: 'string' },
|
||||
body: { type: 'object' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version_id: 28294,
|
||||
app: 'discord',
|
||||
summary: 'Send a message to Discord using Webhook',
|
||||
terms: 'discord webhook message send chat channel',
|
||||
language: 'bunnative',
|
||||
content: `//native
|
||||
|
||||
type DiscordWebhook = {
|
||||
webhook_url: string;
|
||||
};
|
||||
export async function main(discord_webhook: DiscordWebhook, message: string) {
|
||||
const response = await fetch(\`\${discord_webhook.webhook_url}?wait=true\`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: message }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(\`\${response.status} \${await response.text()}\`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
`,
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['discord_webhook', 'message'],
|
||||
properties: {
|
||||
discord_webhook: { type: 'object', format: 'resource-discord_webhook' },
|
||||
message: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/** Naive whole-word overlap — enough to rank a handful of fixtures for a natural
|
||||
* query without pulling an embedding model into the benchmark. Every frontend eval
|
||||
* shares this handler, so the bar to match is deliberately high: naming the
|
||||
* integration, or overlapping on three meaningful words. A looser bar answers
|
||||
* "send a Slack message" with the Discord fixture, handing an unrelated case a
|
||||
* plausible-looking wrong integration. */
|
||||
function searchBenchmarkHubScripts(text: string) {
|
||||
const tokens = new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((token) => token.length > 2)
|
||||
)
|
||||
return BENCHMARK_HUB_SCRIPTS.map((script) => {
|
||||
const words = new Set(
|
||||
`${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/)
|
||||
)
|
||||
const score = [...tokens].filter((token) => words.has(token)).length
|
||||
return { script, score, namesApp: tokens.has(script.app) }
|
||||
})
|
||||
.filter((entry) => entry.namesApp || entry.score >= 3)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ script }, index) => ({
|
||||
ask_id: script.version_id,
|
||||
id: script.version_id,
|
||||
version_id: script.version_id,
|
||||
summary: script.summary,
|
||||
app: script.app,
|
||||
kind: 'script',
|
||||
score: 1 - index * 0.01
|
||||
}))
|
||||
}
|
||||
|
||||
/** The hub keys a script by its version id; the app and slug segments that
|
||||
* follow are descriptive, so match on the id exactly as the real hub does. */
|
||||
function getBenchmarkHubScript(path: string) {
|
||||
const versionId = Number(path.replace(/^\/api\/scripts\/hub\/get_full\/hub\//, '').split('/')[0])
|
||||
return BENCHMARK_HUB_SCRIPTS.find((script) => script.version_id === versionId)
|
||||
}
|
||||
|
||||
const BENCHMARK_WORKERS = [
|
||||
{
|
||||
worker: 'wk-benchmark-1',
|
||||
worker_instance: 'benchmark-host',
|
||||
last_ping: 2,
|
||||
started_at: BENCHMARK_TIMESTAMP,
|
||||
jobs_executed: 42,
|
||||
custom_tags: null,
|
||||
worker_group: 'default',
|
||||
wm_version: 'benchmark'
|
||||
},
|
||||
{
|
||||
worker: 'wk-benchmark-2',
|
||||
worker_instance: 'benchmark-host',
|
||||
last_ping: 5,
|
||||
started_at: BENCHMARK_TIMESTAMP,
|
||||
jobs_executed: 17,
|
||||
custom_tags: null,
|
||||
worker_group: 'default',
|
||||
wm_version: 'benchmark'
|
||||
}
|
||||
]
|
||||
|
||||
const BENCHMARK_JOB_GET_PATH = /^\/api\/w\/([^/]+)\/jobs_u\/get\/([^/]+)$/
|
||||
const BENCHMARK_RUN_BY_PATH = /^\/api\/w\/([^/]+)\/jobs\/run\/(p|f)\/([^/]+)$/
|
||||
|
||||
/** `executeEndpoint` sends a JSON string; anything else means no args were supplied. */
|
||||
function parseBenchmarkRequestBody(
|
||||
body: BodyInit | null | undefined
|
||||
): Record<string, unknown> | undefined {
|
||||
if (typeof body !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `handleBenchmarkApiFetch` has an answer for this `/api/...` url.
|
||||
* Any other relative fetch must keep its normal (non-benchmark) behavior —
|
||||
* intercepting it with a synthetic 404 sends the model into retry loops. */
|
||||
// Not anchored: the frontend builds this URL from location.origin, so it arrives absolute. The
|
||||
// workspace id is greedy because an eval workspace is a temp directory path, slashes and all.
|
||||
const BENCHMARK_AI_MODELS_PATH = /\/api\/w\/(.+)\/ai\/proxy\/models$/
|
||||
|
||||
export function hasBenchmarkApiHandler(url: string): boolean {
|
||||
const path = url.split('?')[0]
|
||||
return (
|
||||
path === '/api/workers/list' ||
|
||||
BENCHMARK_JOB_GET_PATH.test(path) ||
|
||||
BENCHMARK_RUN_BY_PATH.test(path) ||
|
||||
/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path) ||
|
||||
path === '/api/embeddings/query_hub_scripts' ||
|
||||
path.startsWith('/api/scripts/hub/get_full/') ||
|
||||
BENCHMARK_AI_MODELS_PATH.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
/** Answer a relative `/api/...` fetch — from the API catalog executor, or from the
|
||||
* chat's hub tools. */
|
||||
export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Response {
|
||||
const path = url.split('?')[0]
|
||||
if (path === '/api/workers/list') {
|
||||
return Response.json(BENCHMARK_WORKERS)
|
||||
}
|
||||
// The provider's own model listing, which grounds an AI agent step's model id. Keyed by the
|
||||
// resource the caller names, so two seeded providers can serve different models.
|
||||
const aiModels = BENCHMARK_AI_MODELS_PATH.exec(path)
|
||||
if (aiModels) {
|
||||
const headers = new Headers(init?.headers)
|
||||
const resourcePath = headers.get('X-Resource-Path') ?? ''
|
||||
const seed = benchmarkWorkspaceRunnables
|
||||
.get(decodeURIComponent(aiModels[1]))
|
||||
?.aiProviders?.find((entry) => entry.path === resourcePath)
|
||||
return Response.json({ data: (seed?.models ?? []).map((id) => ({ id })) })
|
||||
}
|
||||
if (/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)) {
|
||||
return Response.json([])
|
||||
}
|
||||
const jobGet = BENCHMARK_JOB_GET_PATH.exec(path)
|
||||
if (jobGet) {
|
||||
const id = decodeURIComponent(jobGet[2])
|
||||
const job = getBenchmarkCompletedJob(decodeURIComponent(jobGet[1]), id)
|
||||
if (!job) {
|
||||
return Response.json({ error: `Job not found for "${id}"` }, { status: 404 })
|
||||
}
|
||||
// The real endpoint lets a caller drop the bulky fields. Ignoring that here would
|
||||
// size the model's context off a payload it explicitly asked to shrink.
|
||||
const query = new URLSearchParams(url.split('?')[1] ?? '')
|
||||
if (query.get('no_logs') === 'true') {
|
||||
delete job.logs
|
||||
}
|
||||
if (query.get('no_code') === 'true') {
|
||||
delete job.raw_code
|
||||
}
|
||||
return Response.json(job)
|
||||
}
|
||||
const runByPath = BENCHMARK_RUN_BY_PATH.exec(path)
|
||||
if (runByPath) {
|
||||
const workspace = decodeURIComponent(runByPath[1])
|
||||
const runnablePath = decodeURIComponent(runByPath[3])
|
||||
const args = parseBenchmarkRequestBody(init?.body)
|
||||
// The real endpoint answers with the bare job id as text, not JSON.
|
||||
return new Response(
|
||||
runByPath[2] === 'f'
|
||||
? runBenchmarkFlowByPath({ workspace, path: runnablePath, args })
|
||||
: runBenchmarkScriptByPath({ workspace, path: runnablePath, args })
|
||||
)
|
||||
}
|
||||
if (path === '/api/embeddings/query_hub_scripts') {
|
||||
const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? ''
|
||||
return Response.json(searchBenchmarkHubScripts(text))
|
||||
}
|
||||
if (path.startsWith('/api/scripts/hub/get_full/')) {
|
||||
const script = getBenchmarkHubScript(path)
|
||||
if (!script) {
|
||||
return Response.json({ error: 'hub script not found' }, { status: 404 })
|
||||
}
|
||||
return Response.json({
|
||||
content: script.content,
|
||||
language: script.language,
|
||||
schema: script.schema,
|
||||
summary: script.summary
|
||||
})
|
||||
}
|
||||
return Response.json({ error: `no benchmark handler for ${path}` }, { status: 404 })
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user